@@ -0,0 +1,575 @@
<!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 64 px ;
}
. wrap { max-width : 1120 px ; margin : 0 auto ; padding : 28 px 20 px 0 }
/* ---------- 머리말 ---------- */
. head { border-bottom : 2 px solid var ( - - vermilion ) ; padding-bottom : 14 px ; margin-bottom : 26 px ; position : relative }
. eyebrow {
font-size : 11 px ; letter-spacing : .32 em ; color : var ( - - slate ) ;
font-weight : 700 ; text-transform : uppercase ; margin-bottom : 10 px ;
}
. head h1 {
font-family : var ( - - display ) ;
font-weight : 400 ;
font-size : clamp ( 30 px , 7 vw , 54 px ) ;
line-height : .98 ;
margin : 0 ;
color : var ( - - paper ) ;
letter-spacing : -.01 em ;
}
. head h1 em { font-style : normal ; color : var ( - - vermilion ) ; display : block }
. meta {
display : flex ; gap : 18 px ; align-items : baseline ;
position : absolute ; right : 0 ; bottom : 14 px ;
}
. meta b { font-family : var ( - - ui ) ; font-weight : 700 ; font-size : 22 px ; color : var ( - - amber ) ; font-variant-numeric : tabular-nums }
. meta span { font-size : 11 px ; letter-spacing : .18 em ; color : var ( - - slate ) ; display : block ; font-weight : 700 }
@ media ( max-width : 640px ) {
. meta { position : static ; margin-top : 14 px }
}
/* ---------- 본문 배치 ---------- */
. stage { display : grid ; grid-template-columns : minmax ( 0 , auto ) minmax ( 280 px , 1 fr ) ; gap : 40 px ; align-items : start }
@ media ( max-width : 900px ) { . stage { grid-template-columns : 1 fr ; gap : 26 px } }
/* ---------- 현재 단서 띠 ---------- */
. cluebar {
display : flex ; align-items : center ; gap : 12 px ;
background : var ( - - panel ) ; border-left : 5 px solid var ( - - vermilion ) ;
padding : 11 px 14 px ; margin-bottom : 14 px ; min-height : 56 px ;
}
. cb-num {
background : var ( - - vermilion ) ; color : #fff ; font-weight : 900 ; font-size : 14 px ;
min-width : 26 px ; height : 26 px ; display : grid ; place-items : center ; flex : none ;
}
. cb-dir { font-size : 11 px ; font-weight : 700 ; letter-spacing : .2 em ; color : var ( - - amber ) ; flex : none }
. cb-text { font-size : 15 px ; line-height : 1.45 ; color : var ( - - paper ) ; font-weight : 300 }
/* ---------- 격자 ---------- */
. board-frame { position : relative ; width : min ( 94 vw , 468 px ) }
. board {
display : grid ; grid-template-columns : repeat ( 9 , 1 fr ) ; gap : 2 px ;
background : #000 ; border : 3 px solid #000 ;
box-shadow : 9 px 9 px 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 .09 s linear ;
}
. cell . block { background : #0B0A08 ; cursor : default }
. cell . n {
position : absolute ; top : 2 px ; left : 4 px ; font-family : var ( - - ui ) ;
font-size : clamp ( 8 px , 1.9 vw , 10 px ) ; 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 ( 17 px , 4.4 vw , 27 px ) ; line-height : 1 ; pointer-events : none ;
transform : translateY ( 1 px ) ;
}
. 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 : 16 px ;
z-index : 2 ;
}
/* ---------- 도구 ---------- */
. tools { display : flex ; flex-wrap : wrap ; gap : 7 px ; margin-top : 22 px }
. tools button {
font-family : var ( - - ui ) ; font-size : 12 px ; font-weight : 700 ; letter-spacing : .06 em ;
color : var ( - - bone ) ; background : transparent ; border : 1 px solid #3A352D ;
padding : 9 px 13 px ; cursor : pointer ; transition : .14 s ;
}
. 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 : 2 px solid var ( - - amber ) ; outline-offset : 2 px }
. tip { font-size : 12 px ; line-height : 1.7 ; color : var ( - - slate ) ; margin : 16 px 0 0 ; max-width : 44 ch }
. tip kbd {
font-family : var ( - - ui ) ; font-size : 11 px ; font-weight : 700 ; color : var ( - - bone ) ;
border : 1 px solid #3A352D ; padding : 1 px 5 px ;
}
/* ---------- 단서 목록 ---------- */
. cluegroup + . cluegroup { margin-top : 30 px }
. cluegroup h2 {
font-family : var ( - - ui ) ; font-size : 11 px ; font-weight : 900 ; letter-spacing : .3 em ;
color : var ( - - ink ) ; background : var ( - - amber ) ; display : inline-block ;
padding : 4 px 11 px ; margin : 0 0 12 px ;
}
. cluegroup ol { list-style : none ; margin : 0 ; padding : 0 }
. cluegroup li {
display : grid ; grid-template-columns : 30 px 1 fr ; gap : 10 px ; align-items : baseline ;
padding : 8 px 10 px 8 px 6 px ; cursor : pointer ; border-left : 3 px solid transparent ;
border-bottom : 1 px solid #26221D ;
}
. cluegroup li b { font-weight : 900 ; font-size : 13 px ; color : var ( - - slate ) ; text-align : right }
. cluegroup li p { margin : 0 ; font-size : 13.5 px ; line-height : 1.5 ; color : #BFB8A8 ; font-weight : 300 }
. cluegroup li i { font-style : normal ; color : var ( - - slate ) ; font-size : 11 px ; margin-left : 5 px }
. 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 : 16 px 20 px ; display : flex ; gap : 14 px ; align-items : center ; justify-content : center ;
font-weight : 700 ; font-size : 15 px ; letter-spacing : .02 em ; z-index : 9 ;
animation : rise .35 s 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 >