Files
crossword/site/index.html
T
plparkandClaude Opus 5 65588c339c Add static crossword site with nginx image and k8s manifests
- site/index.html: Russian literature crossword page
- Dockerfile + nginx conf serving on 8080 with /healthz
- k8s Deployment + NodePort Service (31380) in plpark-int-ns
- Jenkinsfile: build/push to gitea registry, apply to rancher

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:01:48 +09:00

576 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>가로세로 낱말풀이 · 러시아 문학</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Black+Han+Sans&family=Gothic+A1:wght@300;500;700;900&family=Nanum+Myeongjo:wght@400;700;800&display=swap" rel="stylesheet">
<style>
:root{
--ink:#12100E;
--panel:#1B1815;
--paper:#E7E1D3;
--paper-lit:#F3E3C8;
--amber:#E8B23C;
--vermilion:#C6362A;
--vermilion-dim:#8E2A21;
--slate:#8A8272;
--bone:#D8D2C4;
--cellfont:"Nanum Myeongjo","Apple SD Gothic Neo","Malgun Gothic",serif;
--ui:"Gothic A1","Apple SD Gothic Neo","Malgun Gothic","Noto Sans KR",sans-serif;
--display:"Black Han Sans","Gothic A1","Apple SD Gothic Neo",sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{
background:var(--ink);
color:var(--bone);
font-family:var(--ui);
font-weight:300;
-webkit-text-size-adjust:100%;
padding:0 0 64px;
}
.wrap{max-width:1120px;margin:0 auto;padding:28px 20px 0}
/* ---------- 머리말 ---------- */
.head{border-bottom:2px solid var(--vermilion);padding-bottom:14px;margin-bottom:26px;position:relative}
.eyebrow{
font-size:11px;letter-spacing:.32em;color:var(--slate);
font-weight:700;text-transform:uppercase;margin-bottom:10px;
}
.head h1{
font-family:var(--display);
font-weight:400;
font-size:clamp(30px,7vw,54px);
line-height:.98;
margin:0;
color:var(--paper);
letter-spacing:-.01em;
}
.head h1 em{font-style:normal;color:var(--vermilion);display:block}
.meta{
display:flex;gap:18px;align-items:baseline;
position:absolute;right:0;bottom:14px;
}
.meta b{font-family:var(--ui);font-weight:700;font-size:22px;color:var(--amber);font-variant-numeric:tabular-nums}
.meta span{font-size:11px;letter-spacing:.18em;color:var(--slate);display:block;font-weight:700}
@media (max-width:640px){
.meta{position:static;margin-top:14px}
}
/* ---------- 본문 배치 ---------- */
.stage{display:grid;grid-template-columns:minmax(0,auto) minmax(280px,1fr);gap:40px;align-items:start}
@media (max-width:900px){.stage{grid-template-columns:1fr;gap:26px}}
/* ---------- 현재 단서 띠 ---------- */
.cluebar{
display:flex;align-items:center;gap:12px;
background:var(--panel);border-left:5px solid var(--vermilion);
padding:11px 14px;margin-bottom:14px;min-height:56px;
}
.cb-num{
background:var(--vermilion);color:#fff;font-weight:900;font-size:14px;
min-width:26px;height:26px;display:grid;place-items:center;flex:none;
}
.cb-dir{font-size:11px;font-weight:700;letter-spacing:.2em;color:var(--amber);flex:none}
.cb-text{font-size:15px;line-height:1.45;color:var(--paper);font-weight:300}
/* ---------- 격자 ---------- */
.board-frame{position:relative;width:min(94vw,468px)}
.board{
display:grid;grid-template-columns:repeat(9,1fr);gap:2px;
background:#000;border:3px solid #000;
box-shadow:9px 9px 0 var(--vermilion-dim);
width:100%;
}
.cell{
position:relative;aspect-ratio:1/1;background:var(--paper);
display:grid;place-items:center;cursor:pointer;user-select:none;
transition:background .09s linear;
}
.cell.block{background:#0B0A08;cursor:default}
.cell .n{
position:absolute;top:2px;left:4px;font-family:var(--ui);
font-size:clamp(8px,1.9vw,10px);font-weight:700;color:var(--vermilion);line-height:1;pointer-events:none;
}
.cell .ch{
font-family:var(--cellfont);font-weight:700;color:#14120F;
font-size:clamp(17px,4.4vw,27px);line-height:1;pointer-events:none;
transform:translateY(1px);
}
.cell.lit{background:var(--paper-lit)}
.cell.cur{background:var(--amber)}
.cell.given .ch{color:var(--vermilion)}
.cell.bad::after{
content:"";position:absolute;inset:0;pointer-events:none;
background:
linear-gradient(to bottom left, transparent 46%, var(--vermilion) 46%, var(--vermilion) 54%, transparent 54%);
opacity:.85;
}
.board.solved .cell:not(.block){background:#EFE4C6}
#ime{
position:absolute;pointer-events:none;opacity:0;
border:0;padding:0;margin:0;background:transparent;
color:transparent;caret-color:transparent;font-size:16px;
z-index:2;
}
/* ---------- 도구 ---------- */
.tools{display:flex;flex-wrap:wrap;gap:7px;margin-top:22px}
.tools button{
font-family:var(--ui);font-size:12px;font-weight:700;letter-spacing:.06em;
color:var(--bone);background:transparent;border:1px solid #3A352D;
padding:9px 13px;cursor:pointer;transition:.14s;
}
.tools button:hover{border-color:var(--amber);color:var(--amber)}
.tools button.on{background:var(--vermilion);border-color:var(--vermilion);color:#fff}
.tools button:focus-visible{outline:2px solid var(--amber);outline-offset:2px}
.tip{font-size:12px;line-height:1.7;color:var(--slate);margin:16px 0 0;max-width:44ch}
.tip kbd{
font-family:var(--ui);font-size:11px;font-weight:700;color:var(--bone);
border:1px solid #3A352D;padding:1px 5px;
}
/* ---------- 단서 목록 ---------- */
.cluegroup+.cluegroup{margin-top:30px}
.cluegroup h2{
font-family:var(--ui);font-size:11px;font-weight:900;letter-spacing:.3em;
color:var(--ink);background:var(--amber);display:inline-block;
padding:4px 11px;margin:0 0 12px;
}
.cluegroup ol{list-style:none;margin:0;padding:0}
.cluegroup li{
display:grid;grid-template-columns:30px 1fr;gap:10px;align-items:baseline;
padding:8px 10px 8px 6px;cursor:pointer;border-left:3px solid transparent;
border-bottom:1px solid #26221D;
}
.cluegroup li b{font-weight:900;font-size:13px;color:var(--slate);text-align:right}
.cluegroup li p{margin:0;font-size:13.5px;line-height:1.5;color:#BFB8A8;font-weight:300}
.cluegroup li i{font-style:normal;color:var(--slate);font-size:11px;margin-left:5px}
.cluegroup li:hover{background:#1B1815}
.cluegroup li.cross{border-left-color:var(--vermilion-dim)}
.cluegroup li.cross b{color:var(--bone)}
.cluegroup li.on{background:var(--vermilion);border-left-color:var(--amber)}
.cluegroup li.on b,.cluegroup li.on p,.cluegroup li.on i{color:#fff}
.cluegroup li.filled b{color:var(--amber)}
/* ---------- 완료 ---------- */
.done{
position:fixed;inset:auto 0 0;background:var(--vermilion);color:#fff;
padding:16px 20px;display:flex;gap:14px;align-items:center;justify-content:center;
font-weight:700;font-size:15px;letter-spacing:.02em;z-index:9;
animation:rise .35s ease-out;
}
@keyframes rise{from{transform:translateY(100%)}to{transform:translateY(0)}}
.done[hidden]{display:none}
@media (prefers-reduced-motion:reduce){
*{animation:none !important;transition:none !important}
}
</style>
</head>
<body>
<div class="wrap">
<header class="head">
<div class="eyebrow">Русская литература · 9 × 9</div>
<h1>러시아 문학<em>가로세로 낱말풀이</em></h1>
<div class="meta">
<div><b id="timer">00:00</b><span>경과 시간</span></div>
<div><b id="progress">0/40</b><span>채운 칸</span></div>
</div>
</header>
<main class="stage">
<section>
<div class="cluebar">
<span class="cb-num" id="cbNum">–</span>
<span class="cb-dir" id="cbDir">가로</span>
<span class="cb-text" id="cbText">칸을 눌러 시작하세요.</span>
</div>
<div class="board-frame">
<div class="board" id="board" role="grid" aria-label="가로세로 낱말풀이 격자"></div>
<input id="ime" type="text" lang="ko" inputmode="text"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
aria-label="글자 입력">
</div>
<div class="tools">
<button type="button" data-act="flip">가로 ↔ 세로</button>
<button type="button" data-act="check">검사</button>
<button type="button" data-act="hint">이 칸 힌트</button>
<button type="button" data-act="reveal">정답 보기</button>
<button type="button" data-act="clear">전부 지우기</button>
</div>
<p class="tip">
같은 칸을 한 번 더 누르면 가로 ↔ 세로가 바뀌고, 오른쪽 단서도 그에 맞춰 바뀝니다.<br>
<kbd>화살표</kbd> 이동 · <kbd>Space</kbd> 방향 전환 · <kbd>Tab</kbd> 다음 단어 · <kbd>Backspace</kbd> 지우고 뒤로
</p>
</section>
<aside>
<section class="cluegroup">
<h2>가로 열쇠</h2>
<ol id="listA"></ol>
</section>
<section class="cluegroup">
<h2>세로 열쇠</h2>
<ol id="listD"></ol>
</section>
</aside>
</main>
</div>
<div class="done" id="done" hidden>
<span id="doneMsg">전부 맞혔습니다.</span>
</div>
<script>
const PUZZLE = {"size": 9, "grid": [["아", "버", "지", "와", "아", "들", null, null, "고"], [null, null, null, null, "흐", null, null, null, "리"], ["브", null, "롤", null, "마", "야", "콥", "스", "키"], ["로", null, "리", null, "토", null, null, "페", null], ["츠", "베", "타", "예", "바", null, null, "이", null], ["키", null, null, "세", null, null, null, "드", null], [null, null, "부", "닌", null, "어", "둠", "의", "힘"], [null, null, "활", null, null, "머", null, "여", null], [null, null, null, null, null, "니", null, "왕", null]], "nums": [[1, 0, 0, 0, 2, 0, 0, 0, 3], [0, 0, 0, 0, 0, 0, 0, 0, 0], [4, 0, 5, 0, 6, 0, 0, 7, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0], [8, 0, 0, 9, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 10, 0, 0, 11, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0]], "entries": [{"num": 1, "dir": "A", "r": 0, "c": 0, "answer": "아버지와아들", "clue": "허무주의자 바자로프가 등장하는 투르게네프 장편", "kind": "작품"}, {"num": 2, "dir": "D", "r": 0, "c": 4, "answer": "아흐마토바", "clue": "《레퀴엠》을 남긴 20세기 러시아의 여성 시인", "kind": "작가"}, {"num": 3, "dir": "D", "r": 0, "c": 8, "answer": "고리키", "clue": "《어머니》 《밑바닥에서》를 쓴 사회주의 리얼리즘 작가", "kind": "작가"}, {"num": 4, "dir": "D", "r": 2, "c": 0, "answer": "브로츠키", "clue": "미국으로 망명해 노벨문학상을 받은 러시아 시인", "kind": "작가"}, {"num": 5, "dir": "D", "r": 2, "c": 2, "answer": "롤리타", "clue": "나보코프를 세계적 논쟁의 중심에 세운 장편", "kind": "작품"}, {"num": 6, "dir": "A", "r": 2, "c": 4, "answer": "마야콥스키", "clue": "러시아 미래파를 대표하는 혁명기의 시인", "kind": "작가"}, {"num": 7, "dir": "D", "r": 2, "c": 7, "answer": "스페이드의여왕", "clue": "카드의 비밀에 집착한 게르만의 파멸", "kind": "작품"}, {"num": 8, "dir": "A", "r": 4, "c": 0, "answer": "츠베타예바", "clue": "망명과 귀국의 비극을 겪은 20세기 여성 시인", "kind": "작가"}, {"num": 9, "dir": "D", "r": 4, "c": 3, "answer": "예세닌", "clue": "농촌의 서정을 노래한 시인, 무용가 이사도라 덩컨의 남편", "kind": "작가"}, {"num": 10, "dir": "A", "r": 6, "c": 2, "answer": "부닌", "clue": "《미탸의 사랑》을 쓴, 러시아 최초의 노벨문학상 수상 작가", "kind": "작가"}, {"num": 10, "dir": "D", "r": 6, "c": 2, "answer": "부활", "clue": "네흘류도프와 카츄샤가 등장하는 톨스토이 후기 장편", "kind": "작품"}, {"num": 11, "dir": "A", "r": 6, "c": 5, "answer": "어둠의힘", "clue": "농촌의 죄와 참회를 다룬 톨스토이의 희곡", "kind": "작품"}, {"num": 11, "dir": "D", "r": 6, "c": 5, "answer": "어머니", "clue": "혁명에 눈뜨는 펠라게야를 그린 고리키의 장편", "kind": "작품"}]};
</script>
<script>
(function(){
"use strict";
const N = PUZZLE.size;
const SOL = PUZZLE.grid;
const NUMS = PUZZLE.nums;
const board = document.getElementById('board');
const ime = document.getElementById('ime');
const listA = document.getElementById('listA');
const listD = document.getElementById('listD');
const cbNum = document.getElementById('cbNum');
const cbDir = document.getElementById('cbDir');
const cbText = document.getElementById('cbText');
const elTimer = document.getElementById('timer');
const elProg = document.getElementById('progress');
const doneBar = document.getElementById('done');
const doneMsg = document.getElementById('doneMsg');
const idx = (r,c) => r*N + c;
const isOpen = (r,c) => r>=0 && r<N && c>=0 && c<N && SOL[r][c] !== null;
/* ---------- 데이터 준비 ---------- */
const ENTRIES = PUZZLE.entries.map((e, i) => {
const cells = [];
for (let k=0; k<e.answer.length; k++){
cells.push(e.dir === 'A' ? [e.r, e.c+k] : [e.r+k, e.c]);
}
return {i, num:e.num, dir:e.dir, answer:e.answer, clue:e.clue, kind:e.kind, cells};
});
ENTRIES.sort((a,b) => a.num - b.num || (a.dir === 'A' ? -1 : 1));
ENTRIES.forEach((e,i) => e.i = i);
const owner = []; // owner[idx] = {A:entry|null, D:entry|null}
for (let i=0;i<N*N;i++) owner[i] = {A:null, D:null};
ENTRIES.forEach(e => e.cells.forEach(([r,c]) => { owner[idx(r,c)][e.dir] = e; }));
const TOTAL = SOL.flat().filter(v => v !== null).length;
/* ---------- 상태 ---------- */
const user = new Array(N*N).fill('');
const given = new Set(); // 힌트/정답으로 채워진 칸
let dir = 'A';
let cur = ENTRIES[0].cells[0].slice();
let session = null; // {cells, written}
let composing = false;
let checking = false;
let solved = false;
let t0 = null, tick = null;
/* ---------- 격자 그리기 ---------- */
const cellEls = new Array(N*N).fill(null);
for (let r=0;r<N;r++){
for (let c=0;c<N;c++){
const d = document.createElement('div');
d.className = 'cell' + (SOL[r][c] === null ? ' block' : '');
if (SOL[r][c] !== null){
d.setAttribute('role','gridcell');
d.dataset.r = r; d.dataset.c = c;
if (NUMS[r][c]) d.innerHTML = '<span class="n">' + NUMS[r][c] + '</span>';
const ch = document.createElement('span');
ch.className = 'ch';
d.appendChild(ch);
cellEls[idx(r,c)] = d;
}
board.appendChild(d);
}
}
/* ---------- 단서 목록 ---------- */
function buildList(ol, d){
ENTRIES.filter(e => e.dir === d).forEach(e => {
const li = document.createElement('li');
li.dataset.i = e.i;
li.innerHTML = '<b>' + e.num + '</b><p>' + e.clue +
'<i>(' + e.answer.length + '글자 · ' + e.kind + ')</i></p>';
li.addEventListener('click', () => {
startTimer();
const empty = e.cells.find(([r,c]) => !user[idx(r,c)]) || e.cells[0];
focusCell(empty[0], empty[1], e.dir);
});
ol.appendChild(li);
e.li = li;
});
}
buildList(listA, 'A');
buildList(listD, 'D');
/* ---------- 화면 갱신 ---------- */
function currentEntry(){ return owner[idx(cur[0],cur[1])][dir]; }
function render(){
const e = currentEntry();
const lit = new Set(e ? e.cells.map(([r,c]) => idx(r,c)) : []);
for (let i=0;i<N*N;i++){
const el = cellEls[i];
if (!el) continue;
const r = Math.floor(i/N), c = i%N;
el.querySelector('.ch').textContent = user[i] || '';
el.classList.toggle('lit', lit.has(i) && i !== idx(cur[0],cur[1]));
el.classList.toggle('cur', i === idx(cur[0],cur[1]));
el.classList.toggle('given', given.has(i));
el.classList.toggle('bad', checking && !!user[i] && user[i] !== SOL[r][c]);
}
const filled = user.filter(Boolean).length;
elProg.textContent = filled + '/' + TOTAL;
ENTRIES.forEach(x => {
x.li.classList.remove('on','cross');
x.li.classList.toggle('filled', x.cells.every(([r,c]) => !!user[idx(r,c)]));
});
const other = owner[idx(cur[0],cur[1])][dir === 'A' ? 'D' : 'A'];
if (other) other.li.classList.add('cross');
if (e){
e.li.classList.add('on');
e.li.scrollIntoView({block:'nearest'});
cbNum.textContent = e.num;
cbDir.textContent = e.dir === 'A' ? '가로' : '세로';
cbText.textContent = e.clue;
}
}
function placeIme(){
const el = cellEls[idx(cur[0],cur[1])];
if (!el) return;
ime.style.left = el.offsetLeft + 'px';
ime.style.top = el.offsetTop + 'px';
ime.style.width = el.offsetWidth + 'px';
ime.style.height = el.offsetHeight + 'px';
}
window.addEventListener('resize', placeIme);
/* ---------- 입력 세션 ---------- */
function commit(){
if (!session) return;
for (let i=0;i<session.written;i++){
const [r,c] = session.cells[i];
const ch = user[idx(r,c)];
if (ch && !/^[가-힣]$/.test(ch)) user[idx(r,c)] = ''; // 조합이 끝나지 않은 자모는 버린다
}
ime.value = '';
session = null;
}
function focusCell(r, c, want){
if (!isOpen(r,c)) return;
commit();
const o = owner[idx(r,c)];
dir = o[want] ? want : (o.A ? 'A' : 'D');
cur = [r,c];
const e = currentEntry();
const k = e ? e.cells.findIndex(([a,b]) => a === r && b === c) : 0;
session = {cells: e ? e.cells.slice(k) : [[r,c]], written: 0};
ime.value = '';
placeIme();
render();
try { ime.focus({preventScroll:true}); } catch(_) { ime.focus(); }
}
function setCell(r, c, ch){
const i = idx(r,c);
user[i] = ch;
if (ch) given.delete(i);
}
function onInput(){
if (!session) return;
const cells = session.cells;
if (!composing){
const cleaned = ime.value.replace(/\s+/g,'');
if (cleaned !== ime.value) ime.value = cleaned;
}
let buf = Array.from(ime.value).filter(ch => ch !== ' ');
if (!composing && buf.length > cells.length){
buf = buf.slice(0, cells.length);
ime.value = buf.join('');
}
for (let i=buf.length; i<session.written; i++){
setCell(cells[i][0], cells[i][1], '');
}
const n = Math.min(buf.length, cells.length);
for (let i=0;i<n;i++) setCell(cells[i][0], cells[i][1], buf[i]);
session.written = n;
const k = Math.min(buf.length, cells.length - 1);
cur = cells[k].slice();
placeIme();
render();
checkSolved();
}
ime.addEventListener('compositionstart', () => { composing = true; });
ime.addEventListener('compositionend', () => { composing = false; onInput(); });
ime.addEventListener('input', onInput);
ime.addEventListener('blur', () => { commit(); render(); });
ime.addEventListener('keydown', ev => {
if (ev.isComposing || ev.keyCode === 229) return;
const k = ev.key;
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown'){
ev.preventDefault(); arrow(k); return;
}
if (k === ' '){ ev.preventDefault(); flip(); return; }
if (k === 'Tab'){ ev.preventDefault(); hopEntry(ev.shiftKey ? -1 : 1); return; }
if (k === 'Enter'){ ev.preventDefault(); hopEntry(1); return; }
if (k === 'Backspace' && ime.value.length === 0){ ev.preventDefault(); stepBack(); return; }
});
/* ---------- 이동 ---------- */
function arrow(key){
const want = (key === 'ArrowLeft' || key === 'ArrowRight') ? 'A' : 'D';
const [r,c] = cur;
if (dir !== want && owner[idx(r,c)][want]){ focusCell(r, c, want); return; }
const dr = key === 'ArrowUp' ? -1 : key === 'ArrowDown' ? 1 : 0;
const dc = key === 'ArrowLeft' ? -1 : key === 'ArrowRight' ? 1 : 0;
let nr = r + dr, nc = c + dc;
while (nr>=0 && nr<N && nc>=0 && nc<N){
if (isOpen(nr,nc)){ focusCell(nr, nc, dir); return; }
nr += dr; nc += dc;
}
}
function stepBack(){
const e = currentEntry();
if (!e) return;
const k = e.cells.findIndex(([a,b]) => a === cur[0] && b === cur[1]);
if (k > 0){
const [r,c] = e.cells[k-1];
setCell(r, c, '');
focusCell(r, c, dir);
}
}
function flip(){
const o = owner[idx(cur[0],cur[1])];
const nd = dir === 'A' ? 'D' : 'A';
if (o[nd]) focusCell(cur[0], cur[1], nd);
}
function hopEntry(step){
const e = currentEntry();
const at = e ? e.i : 0;
const next = ENTRIES[(at + step + ENTRIES.length) % ENTRIES.length];
const empty = next.cells.find(([r,c]) => !user[idx(r,c)]) || next.cells[0];
focusCell(empty[0], empty[1], next.dir);
}
/* ---------- 클릭 ---------- */
board.addEventListener('click', ev => {
const el = ev.target.closest('.cell');
if (!el || el.classList.contains('block')) return;
startTimer();
const r = +el.dataset.r, c = +el.dataset.c;
const same = (r === cur[0] && c === cur[1]);
const o = owner[idx(r,c)];
let want = dir;
if (same && o[dir === 'A' ? 'D' : 'A']) want = dir === 'A' ? 'D' : 'A';
focusCell(r, c, want);
});
/* ---------- 도구 ---------- */
document.querySelector('.tools').addEventListener('click', ev => {
const b = ev.target.closest('button');
if (!b) return;
const act = b.dataset.act;
if (act === 'flip'){ flip(); }
if (act === 'check'){
checking = !checking;
b.classList.toggle('on', checking);
render();
}
if (act === 'hint'){
commit();
const [r,c] = cur;
user[idx(r,c)] = SOL[r][c];
given.add(idx(r,c));
focusCell(r, c, dir);
checkSolved();
}
if (act === 'reveal'){
if (!confirm('정답을 모두 채웁니다. 계속할까요?')) return;
commit();
for (let r=0;r<N;r++) for (let c=0;c<N;c++){
if (SOL[r][c] === null) continue;
if (user[idx(r,c)] !== SOL[r][c]) given.add(idx(r,c));
user[idx(r,c)] = SOL[r][c];
}
stopTimer();
solved = true;
board.classList.add('solved');
doneMsg.textContent = '정답을 모두 펼쳤습니다.';
doneBar.hidden = false;
render();
}
if (act === 'clear'){
if (!confirm('입력한 글자를 전부 지웁니다. 계속할까요?')) return;
commit();
user.fill('');
given.clear();
solved = false;
board.classList.remove('solved');
doneBar.hidden = true;
focusCell(cur[0], cur[1], dir);
}
});
/* ---------- 완료 판정 ---------- */
function checkSolved(){
if (solved) return;
for (let r=0;r<N;r++) for (let c=0;c<N;c++){
if (SOL[r][c] === null) continue;
if (user[idx(r,c)] !== SOL[r][c]) return;
}
solved = true;
stopTimer();
board.classList.add('solved');
const helped = given.size;
doneMsg.textContent = helped
? '전부 맞혔습니다. (' + elTimer.textContent + ' · 힌트 ' + helped + '칸)'
: '힌트 없이 전부 맞혔습니다. ' + elTimer.textContent;
doneBar.hidden = false;
}
/* ---------- 시간 ---------- */
function startTimer(){
if (t0 || solved) return;
t0 = Date.now();
tick = setInterval(() => {
const s = Math.floor((Date.now() - t0)/1000);
elTimer.textContent = String(Math.floor(s/60)).padStart(2,'0') + ':' + String(s%60).padStart(2,'0');
}, 1000);
}
function stopTimer(){ if (tick) clearInterval(tick); tick = null; }
/* ---------- 시작 ---------- */
focusCell(cur[0], cur[1], 'A');
ime.blur();
render();
})();
</script>
</body>
</html>