추리 미스터리 사이트인 '제과 사건부(零課事件簿)'를 공개했습니다. 매일 1개의 작은 수수께끼와, 여러 장을 거쳐 진상에 도달할 수 있는 '사건 파일'이 플레이 가능한 사이트입니다.
이 사이트는 HTML 파일 단 하나로 작동합니다. 서버도 데이터베이스도, 빌드 과정도 필요 없습니다. GitHub에 파일을 올리고 GitHub Pages로 공개하는 것만으로 충분합니다. 소스는 shoyo-maya-bot/reika-jikenbo에서 확인할 수 있습니다.
이 글에서는 이러한 형태임에도 불구하고 '매일 문제가 바뀌고', '진행 상황이 저장되며', '시리즈를 추가할 수 있는' 사이트를 어떻게 만들었는지 소개합니다. 추리 문제의 스포일러는 하지 않으니, 플레이하기 전에 읽으셔도 안심하셔도 됩니다.
어떤 사이트인가요
| 기능 | 내용 |
|---|
| 오늘의 작은 수수께끼 | 매일 0시(일본 시간)에 문제가 바뀝니다. 연속 정답 일수와 최근 7일간의 기록이 표시됩니다. |
| ... | |
이것을 index.html 안에 있는 HTML, CSS, JavaScript만으로 만들었습니다. | |
구성: 파일은 단 3개
리포지토리에는 이 3가지만 있습니다.
reika-jikenbo/
index.html ← 사이트 전체 (약 600줄)
README.md
...
GitHub Pages는 아무것도 하지 않으면 Jekyll이라는 시스템으로 파일을 가공한 후 공개합니다. .nojekyll을 두면 그 처리를 건너뛰고, 파일을 그대로 공개합니다. 파일 하나짜리 사이트라면 이것만으로 충분합니다.
공개 과정은 리포지토리의 Settings > Pages에서 공개할 브랜치를 선택하기만 하면 됩니다. push를 하면 잠시 후 사이트에 반영됩니다.
구조 1: 문제는 '데이터'로 분리해 두기
이 사이트에서 가장 중요하게 생각하는 것은 화면을 만드는 코드와 수수께끼의 내용(데이터)을 분리한 것입니다.
사건 파일, 매일의 작은 수수께끼, 다음 회차 예고는 각각 JavaScript 배열이나 객체로 작성되어 있습니다.
const FILES = [FILE01, FILE02];
const NEXT = { no:
시간(밀리초)에 9시간을 더해서 일본 시간으로 맞춘다
1일의 밀리초로 나누어 '1970년 1월 1일부터 며칠째인지'라는 누적 번호를 만든다
그 번호를 문제 수로 나눈 나머지로, 오늘의 문제를 고른다
누적 번호는 일본 시간 0시에 1개씩 늘어나므로, 문제도 0시에 바뀝니다. 누가 어느 나라에서 보더라도 같은 날에는 같은 문제가 출제됩니다.
문제를 다 사용하면 첫 번째 문제로 돌아가지만, `DAILY`에 문제를 추가해 가면, 다시 돌아올 때까지의 일수가 늘어납니다.
연속 정답일 수도 이 누적 번호로 계산합니다. 정답한 날짜의 번호를 기록해 두고, 오늘(아직 풀지 않았다면 어제)부터 거슬러 올라가서 연속된 일수를 셉니다.
## 구조 3: 답변의 '표기 차이'를 흡수하기
수수께끼를 푸는 데 있어 은근히 중요한 것이 바로 답변 판정입니다. '맞는데, 쓰는 방식만 다르면 오답'인 경우는 플레이어를 실망시킵니다.
이 사이트에서는 입력된 답변과 정답 양쪽 모두를 같은 규칙으로 정리한 후 비교합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function norm(s){
return String(s).normalize("NFKC").toLowerCase()
.replace(/[ァ-ヶ]/g, c => String.fromCharCode(c.charCodeAt(0) - 0x60))
...
}
하는 작업은 다음과 같습니다.
-
`normalize("NFKC")`로 전각 영문/숫자를 반각으로 통일한다 -
영문을 소문자로 만든다
- 가타카나를 히라가나로 바꾼다 (문자 코드를 일정한 수만큼 이동)
- 공백이나 구두점, 괄호를 제거한다
- 사람 이름 답변을 위해 마지막 '님'을 제거한다
게다가 정답은 배열로 여러 개 가질 수 있도록 했습니다. 히라가나, 한자, 다른 표기 방식을 나열해 두면 어떤 것으로 답해도 정답이 됩니다.
시간처럼 표기 방식이 많은 답변은 문자열을 나열하는 대신, 판정 함수를 갖게 했습니다.
// 설명용 예시 (실제 문제의 정답은 아닙니다): '오후 3시 15분'을 정답으로 할 경우
check: s => /^(午後|pm)?(3|03|15)(:|時)?15(分)?$/.test(s)
'오후 3시 15분', '15:15', '3시 15분' 같은 표기 방식을 정규 표현식으로 하나의 판정에 통합했습니다.
## 구조 4: 진행 상황은 브라우저에 저장하기
푼 장, 사용한 힌트 수, 매일의 소수께끼 기록은 브라우저의 `localStorage`에 저장합니다.
const KEY = "reika-v2";
let st = { files:{}, daily:{} };
try { const s = JSON.parse(localStorage.getItem(KEY)); if (s) st = Object.assign(st, s); } catch(e){}
...
고안한 부분은 3가지가 있습니다.
-
**읽기/쓰기를 반드시** 프라이빗 브라우즈 등에서는 `try`로 감싼다. `localStorage`가 사용 불가능할 때가 있습니다. 그럴 때도 저장만 안 될 뿐 사이트는 작동합니다. -
**저장 키에 버전을 붙인다.** 저장하는 형식을 바꿀 때, 키 이름(`reika-v2`)을 바꾸면 오래된 형태의 데이터와 섞이지 않습니다. -
**오래된 데이터를 계승한다.** 사건 파일이 하나만 있었던 시절의 키(`reika-file01`)가 남아있다면, 새로운 형태로 옮깁니다. 이전에 즐기던 사람의 기록이 사라지지 않습니다.
서버에 아무것도 보내지 않으므로, 방문자의 개인 정보를 맡을 필요가 없습니다. 대신, 기록은 그 브라우저에만 남고 다른 단말기로는 계승되지 않습니다.
## 구조 5: 다시 오게 만드는 장치
1 파일의 사이트에서도 플레이어가 돌아오고 싶게 만드는 장치는 만들 수 있습니다.
-
**연속 정답과, 최근 7일 기록.** 매일 올 이유가 됩니다. -
**계급.** 장을 풀면 3점, 사건을 해결하면 10점, 소수께끼 정답에 2점. 힌트를 사용하면 조금 줄어듭니다. '다음 계급까지 몇 점 남음'이 나오므로, 조금씩 진전시키고 싶게 됩니다. -
**다음 회차 예고 카운트다운.** 다음 사건 파일의 공개일을 `NEXT`에 날짜 시간으로 적어두기만 하면 표시됩니다. -
**답변을 포함하지 않는 공유 문구.** 해결하면 소요 시간과 힌트 사용 횟수를 정리한 문구를 복사할 수 있습니다. 스포일러 없이, 플레이했던 것을 전파할 수 있습니다.
## 외형 및 사용 편의성 개선점
⚠️ `[IMG:N]` 형식 토큰은 이미지 placeholder 입니다. 번역하지 말고 원래 위치에 그대로 유지하세요.
**다크 모드 지원.** 색상은 CSS 변수에 모아두고 OS 설정에 맞춰 전환합니다. -
**삽화는 SVG로 그리기.** 시계나 양초 등 수수께끼에 사용되는 그림은 JavaScript로 SVG를 조립합니다. 이미지 파일이 필요 없고, 다크 모드에서도 색상이 바뀝니다. -
**스크린 리더 배려.** 그림에는 `aria-label`로 설명을 추가하고, 정답/오답 메시지는 `aria-live`로 읽을 수 있게 했습니다. -
**움직임 줄이기 설정 존중.** OS에서 '움직임 줄이기' 설정을 한 사용자에게는 애니메이션을 멈춥니다.
## 이 제작의 약점
물론, 1파일의 정적 사이트에는 한계가 있습니다.
**정답이 소스 코드에 보임**
정답 판별은 브라우저에서 이루어지기 때문에 페이지 소스를 열면 답을 읽을 수 있습니다. 가볍게 즐기는 사이트라면 허용할 수 있지만, 랭킹이나 상품을 걸려면 정답을 해시 값으로 하거나, 판별을 서버로 옮기는 등의 대책이 필요합니다.
**기록이 기기를 넘나들지 못함**
`localStorage`는 브라우저별 저장소이기 때문에 스마트폰과 PC에서 기록이 따로 남습니다.
**파일 크기가 커짐**
사건 파일을 추가할수록 `index.html`은 길어집니다. 본 수가 늘어나면, 사건별 데이터를 별도의 JSON 파일로 분리하는 것이 다음 단계입니다.
## 직접 만들 때의 절차
- GitHub에서 새 리포지토리를 만들고,
`index.html`과 빈 `.nojekyll`을 배치합니다. Settings → Pages에서 공개할 브랜치를 선택합니다.
- 먼저, 문제 데이터의 형태를 정합니다 (질문, 정답 배열, 힌트, 해설).
- 데이터를 읽어 화면을 만드는 함수를 작성합니다.
- 정답 판별에 표기 유연성을 흡수하는 함수를 넣습니다.
- 진행 상황을 `localStorage`에 저장합니다 (읽기/쓰기는 `try`로 감쌉니다). - 푸시(push)하고, 공개된 페이지를 스마트폰에서도 확인해 봅니다.
3번 항목을 먼저 하는 것이 핵심입니다. 데이터 형태만 정하면, 문제 추가는 자신도 AI의 도움을 받아도 쉽습니다.
## 흔히 저지르는 실수
**표시 코드 안에 문제를 직접 작성함**
문제마다 HTML을 작성하다 보면, 문제를 추가할 때마다 표시 코드를 만져야 해서 깨지기 쉽습니다. 문제는 데이터로 분리해야 합니다.
**정답 판별을 너무 엄격하게 함**
전각과 반각, 가타카나와 히라가나의 차이 때문에 오답 처리하면, 즐기는 사람이 떠나갑니다. 비교하기 전에 양쪽 모두 같은 규칙으로 정리해야 합니다.
`localStorage`가 반드시 사용 가능하다는 전제하에 작성함
사용할 수 없는 환경에서는 예외로 인해 스크립트가 멈추고 사이트가 새하얗게 될 수 있습니다. 읽기/쓰기는 `try`로 감쌉니다.
**저장 형태를 바꿨는데 키는 그대로 사용함**
오래된 형태의 데이터를 새로운 코드가 읽으면, 표시가 깨질 수 있습니다. 형태를 바꾸었다면 키의 버전을 올리고, 필요하다면 오래된 데이터를 옮겨야 합니다.
## 요약
서버도 빌드 과정도 없어도, 아이디어만 있으면 '매일 즐길 수 있는' 사이트는 만들 수 있습니다.
- 문제는 데이터로 분리하고, 추가는 데이터를 작성하는 것만으로 끝냅니다.
- 날짜 계산은 브라우저에서 하고, 서버 없이 일일 단위로 만듭니다.
- 정답 표기 유연성은 비교하기 전에 정리하여 흡수합니다.
- 진행 상황은 브라우저에 저장하고, 개인 정보는 맡지 않습니다.
'제과 사건부'에서는 앞으로도 사건 파일을 추가할 예정입니다. 다음 'FILE 03'은 10월 18일 19시에 공개될 예정입니다. 한번 도전해 보세요, 수사관님.
### Discussion

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89