추리 미스터리 사이트인 '제0과 사건부(零課事件簿)'를 공개했습니다. 매일 하나의 작은 수수께끼와, 여러 장을 거쳐 진상에 도달할 수 있는 '사건 파일'을 즐길 수 있는 웹사이트입니다.
이 사이트는 HTML 파일 단 하나만으로 작동합니다. 서버도, 데이터베이스도, 빌드 과정도 필요 없습니다. GitHub에 파일을 올리고 GitHub Pages로 공개했을 뿐입니다. 소스는 shoyo-maya-bot/reika-jikenbo에서 확인할 수 있습니다.
본문에서는 이러한 형태임에도 불구하고 '매일 문제가 바뀐다', '진행 상황이 저장된다', '시리즈를 추가할 수 있다'는 웹사이트를 어떻게 만들었는지 소개합니다. 추리 문제의 정답 스포일러는 하지 않으므로, 플레이하기 전에 읽으셔도 안심하셔도 됩니다.
| 기능 | 내용 |
|---|
| 오늘의 작은 수수께끼 | 매일 0시(일본 시간)에 문제가 바뀝니다. 연속 정답 일수와 최근 7일 기록이 표시됩니다. |
| ... | |
이를 index.html 안에 있는 HTML, CSS, JavaScript만으로 만들었습니다. | |
리포지토리에는 이 세 가지 파일만 있습니다.
reika-jikenbo/
index.html ← 사이트 전체 (약 600줄)
README.md
...
GitHub Pages는 아무 조치 없이 파일을 올리면 Jekyll이라는 시스템으로 파일을 가공한 후 공개합니다. .nojekyll을 배치하면 이 처리를 건너뛰고, 파일을 그대로 공개할 수 있습니다. 파일 하나로 구성된 사이트라면 이것만으로 충분합니다.
공개 절차는 리포지토리의 Settings > Pages에서 공개할 브랜치를 선택하는 것만으로 끝납니다. push를 하면 잠시 후 사이트에 반영됩니다.
이 사이트에서 가장 중요하게 생각한 부분은 화면을 만드는 코드와 수수께끼 내용(데이터)을 분리했다는 점입니다.
사건 파일, 오늘의 작은 수수께끼, 다음 예고는 각각 JavaScript의 배열이나 객체로 작성했습니다.
const FILES = [FILE01, FILE02];
const NEXT = { no:"FILE 03", title:"유령 호텔 시간표", genre:"열차와 알리바이", date:"2026-10-18T19:00:00+09:00" };
const DAILY = [
...
한 개의 장(챕터)은 다음과 같은 형태를 가집니다 (내용은 설명용 더미 데이터입니다).
{
id:"c1", no:"제1장", title:"장의 제목", lv:"난이도 ★☆☆",
story:["이야기 문장 1", "이야기 문장 2"],
doc:`<span class="tag">자료 1</span><h3>자료의 제목</h3>...`,
...
}
화면을 만드는 함수는 이 데이터를 읽어 어떤 사건이든 동일한 형태로 표시합니다. 새로운 사건을 추가할 때는 데이터를 하나 작성하여 FILES에 추가하기만 하면 됩니다. README에도 다음과 같이 적혀 있습니다.
사건 파일은 FILES 배열, 오늘의 작은 수수께끼는 DAILY 배열, 다음 예고는 NEXT를 수정하여 추가합니다.
데이터의 형태가 정해져 있으면, AI에게 새로운 문제 추가를 요청할 때도 편리합니다. 형태가 정해져 있기 때문에, AI가 임의로 화면 코드를 변경할 염려가 줄어듭니다.
예를 들어, 에이전트(agent) 형태의 AI에게는 다음과 같이 지시합니다.
index.html의 DAILY 배열에 작은 수수께끼 5문제를 추가해 주세요. 기존 문제와 동일한 형식(q, a, ex)을 따르고, a에는 표기 오류가 있는 정답도 넣어주세요. 화면을 만드는 코드는 변경하지 마세요.
'어디에', '어떤 형식으로', '무엇을 바꾸지 말아야 하는지'를 명시하는 것이 요령입니다. 이는 시리즈의 다른 글에서 작성한 '에이전트에게 지시할 때는 범위와 완료 조건, 그리고 하지 않아야 할 것을 적는다'는 것과 같은 사고방식입니다.
매일 문제가 바뀌는 사이트라고 하면 서버에서 날짜를 보고 문제를 선택하는 메커니즘이 필요하다고 생각하기 쉽습니다. 이 사이트는 방문자의 브라우저에서 계산합니다.
function jstDay(t = Date.now()){ return Math.floor((t + 9*3600e3) / 86400e3); }
const today = jstDay(), d = DAILY[today % DAILY.length];
- 현재 시각(밀리초)에 9시간을 더하여 일본 시간으로 만듭니다.
- 하루를 밀리초로 나눈 값으로 '1970년 1월 1일부터 며칠째'인지 하는 누적 번호를 얻습니다.
- 이 번호를 문제의 개수로 나눈 나머지로 오늘의 문제를 선택합니다.
일련번호(通し番号)는 일본 시간 0시에 1씩 증가하므로 문제도 0시에 바뀝니다. 누가 어느 나라에서 보든 같은 날에는 같은 문제가 출제됩니다.
문제를 다 풀면 첫 번째 문제로 돌아가지만, DAILY 변수에 문제를 추가하면 다시 돌아오기까지의 일수를 늘릴 수 있습니다.
연속으로 정답을 맞힌 일수도 이 일련번호를 기준으로 계산합니다. 정답을 맞힌 날짜 번호를 기록해 두고, 오늘(아직 풀지 않았다면 어제)부터 거슬러 올라가 연속된 일수를 셉니다.
추리 게임에서 은근히 중요한 것이 바로 답안 판정입니다. '맞는데도 쓰기 방식이 다르다는 이유로 오답'인 경우는 플레이어를 실망시킵니다.
이 사이트에서는 입력된 답과 정답 모두를 동일한 규칙으로 정리(normalize)한 후에 비교합니다.
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분'과 같은 표기법을 정규 표현식으로 하나의 판정 로직에 통합했습니다.
풀었던 장, 사용한 힌트 수, 매일의 소미스터리 기록은 브라우저의 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)가 남아 있다면, 새로운 형태로 옮깁니다. 이전에 즐기던 사람의 기록이 사라지지 않게 합니다.
서버에 아무것도 보내지 않으므로 방문자의 개인 정보를 맡을 필요가 없습니다. 대신, 기록은 그 브라우저에만 남고 다른 단말기로는 계승되지 않습니다.
1파일 사이트에서도 플레이어가 돌아오고 싶게 만드는 장치는 만들 수 있습니다.
-
연속 정답과 직전 7일간의 기록. 매일 오게 하는 이유가 됩니다. - 계급(Rank). 장을 풀면 3점, 사건을 해결하면 10점, 소미스터리 정답으로 2점을 얻습니다. 힌트를 사용하면 조금 줄어듭니다. '다음 계급까지 몇 점 남음'이 표시되므로 조금씩 진전시키고 싶게 됩니다. - 차기 예고 카운트다운. 다음 사건 파일의 공개일을 NEXT에 날짜와 시간으로 적어두기만 하면 표시됩니다. - 스포일러가 없는 공유 문구. 해결하면 소요 시간과 힌트 사용 횟수를 정리한 문구를 복사할 수 있습니다. 스포일러 없이, 플레이했던 것을 알릴 수 있습니다.
-
다크 모드 대응. 색상은 CSS 변수에 모아두고 OS 설정에 맞춰 전환합니다. - 삽화는 SVG로 그린다. 시계나 양초 등 미스터리에 사용되는 그림은 JavaScript로 SVG를 조립합니다. 이미지 파일이 필요 없고, 다크 모드에서도 색상이 바뀝니다. - 읽어주기에 배려. 그림에는 aria-label을 붙여 설명을 달고, 정답/오답 메시지는 aria-live로 읽어지도록 합니다. - 움직임을 줄이는 설정을 존중. OS에서 '움직임 줄이기' 설정이 되어 있는 사람에게는 애니메이션을 멈춥니다.
물론, 1개 파일의 정적 사이트에는 한계도 있습니다.
정답이 소스에서 보임
정답 판별은 브라우저에서 이루어지기 때문에 페이지의 소스를 열면 답을 읽을 수 있습니다. 가볍게 즐기는 사이트라면 허용할 수 있지만, 랭킹이나 상품을 걸려면, 답을 해시 값(hash value)으로 하거나, 판별을 서버로 옮기는 등의 대책이 필요합니다.
기록이 기기를 넘지 않음
localStorage는 브라우저별로 저장되기 때문에, 스마트폰과 PC에서 기록은 별개입니다.
파일이 커져감
사건 파일을 추가할수록 index.html은 길어집니다. 본 수가 늘어나면, 사건별 데이터를 별도의 JSON 파일로 분리하는 것이 다음 단계입니다.
- GitHub에서 새로운 리포지토리(repository)를 만들고,
index.html과 빈 .nojekyll을 배치합니다 - Settings → Pages에서 공개할 브랜치(branch)를 선택합니다
- 먼저, 문제 데이터의 형태를 정합니다 (질문, 답 배열, 힌트, 해설).
- 데이터를 읽어 화면을 만드는 함수를 작성합니다.
- 답 판별에 표기 유래(表記ゆれ, variation in notation)를 흡수하는 함수를 넣습니다.
- 진행 상황을
localStorage에 저장합니다 (읽기/쓰기는 try로 감쌉니다) - push하고, 공개된 페이지를 스마트폰에서도 확인해 봅니다
세 번째 항목을 먼저 하는 것이 핵심입니다. 데이터의 형태만 정해지면, 문제 추가는 본인 스스로 하든 AI에게 의존하든 간단해집니다.
표시 코드 안에 문제를 직접 작성함
문제마다 HTML을 작성하게 되면, 문제를 추가할 때마다 표시 코드를 건드려야 하므로 깨지기 쉽습니다. 문제는 데이터로 분리해야 합니다.
답 판별을 너무 엄격하게 함
전각과 반각, 가타카나와 히라가나의 차이로 오답 처리하면, 즐기는 사람이 떠나게 됩니다. 비교하기 전에 양쪽 모두를 같은 규칙으로 정리해야 합니다.
localStorage가 반드시 사용 가능하다는 전제하에 작성합니다
사용할 수 없는 환경에서는 예외(exception)로 스크립트가 멈추고, 사이트가 새하얗게 될 수 있습니다. 읽기/쓰기는 try로 감쌉니다.
저장 형태를 바꿨는데, 키(key)는 그대로 두는 경우
오래된 형태의 데이터를 새로운 코드가 읽으면서 표시가 깨질 수 있습니다. 형태를 바꿨다면 키의 버전을 올리고, 필요하다면 오래된 데이터를 옮겨야 합니다.
서버도 빌드(build)도 없어도, 아이디어만 있으면 "매일 즐길 수 있는" 사이트는 만들 수 있습니다.
- 문제는 데이터로 분리하고, 추가는 데이터를 작성하는 것으로 합니다.
- 날짜 계산은 브라우저에서 진행하여, 서버 없이 일일 단위로 만듭니다.
- 답의 표기 유래는 비교하기 전에 정리하여 흡수합니다.
- 진행 상황은 브라우저에 저장하며, 개인 정보는 맡지 않습니다.
제0과 사건부에서는 앞으로도 사건 파일을 추가할 예정입니다. 다음 "FILE 03"은 10월 18일 19시에 공개될 예정입니다. 괜찮으시면 도전해 보세요, 수사관님.
이 글은 Zenn에도 게시되어 있습니다.