육각 격자 버블 슈터. 같은 색 둘만 붙어도 터진다. 오프라인 전용 PWA — 네트워크 호출이 한 줄도 없다.
▶ 플레이하기
React 19 · TypeScript · Vite · vite-plugin-pwa.
상태관리 라이브러리 없이 useState/useRef만 쓰고, 보드는 SVG로 그린다.
- 같은 색 구슬이 2개 이상 인접하면 전부 터진다. 색이 곧 구슬의 정체성이라 화면에 글자가 없다
- 한 번에 길게 이을수록 구슬당 점수가 오른다 (
GROUP_BONUS) - 천장과의 연결이 끊긴 덩어리는 통째로 떨어지고, 덩어리가 클수록 점수가 급격히 커진다 — 색을 맞추는 것보다 매달린 구조를 읽는 쪽이 크게 남는다
- 새 행은 같은 색을 2~3개씩 이어 붙여 깐다. 칸마다 색을 따로 뽑으면 보드가 잘게 부서져 한 발에 두세 개밖에 못 지운다
- 3발마다 천장이 한 줄 내려온다. 10행에 닿으면 끝
색은 6가지다. 팔레트는 색상환을 6등분하되 명도를 서로 어긋나게 잡았다 — 색상이 뭉개져 보이는 경우에도 밝기 차이로 구분할 수 있어야 하기 때문이다.
일부 구슬에는 문양이 붙어 있고, 그 구슬이 사라질 때 효과가 터진다. 효과로 사라진 자리에 또 아이템이 있으면 연쇄로 이어진다.
| 문양 | 이름 | 효과 | 등장 |
|---|---|---|---|
| 폭발 | 폭탄 | 맞닿은 6칸을 함께 없앤다 | 흔함 |
| 좌우 화살표 | 번개 | 그 구슬이 있는 가로줄을 통째로 없앤다 | 보통 |
| 별 | 별 | 보드에 남은 같은 색을 전부 없앤다 | 드묾 |
연속으로 터뜨릴수록 그 발의 점수 전체에 배율이 붙는다 (COMBO_MULT, 최대 ×3).
한 발이라도 헛치면 0으로 돌아간다. 2콤보부터 화면 가운데에 크게 뜬다.
마우스가 달린 기기(pointer: fine)와 터치 기기를 다르게 대접한다.
- PC — 누르지 않아도 마우스를 따라 조준한다. 조준선도 늘 진하게 두고, 창 전체에서 포인터를 받으므로 보드 밖으로 마우스가 나가도 조준이 끊기지 않는다. 보드 폭이 460px이라 이걸 하지 않으면 넓은 화면에서는 대부분이 사각지대가 된다
- 터치 — 누른 동안만 조준선이 진해진다. 늘 누른 채 조준하므로 그걸로 충분하다
- 키보드 —
←→로 조준(누르고 있으면 계속 돈다),Space·Enter로 발사
- 시작 화면 — 이어하기(저장된 판이 있을 때만), 새 게임, 게임 방법, 공유하기
- 공유하기 — 모바일은 시스템 공유 시트, 없으면 주소를 복사한다. 둘 다 네트워크를 타지 않아 오프라인 원칙을 깨지 않는다
- 이어하기 — 진행 중인 판이
localStorage에 통째로 저장된다. 창을 닫아도 그대로 이어진다
연출은 소소하게 넷을 넣었다. 넷 이상 터지면 보드가 흔들리고, 쏠 때 포신이 뒤로 밀렸다
돌아오고, 점수가 오를 때 숫자가 튀고, 사망선이 코앞이면 가장자리가 붉게 뛴다.
prefers-reduced-motion을 켜면 전부 멈춘다.
글자나 숫자는 화면에 없습니다. 색만 보면 됩니다.
Node 22 LTS 기준입니다(.nvmrc). nvm을 쓴다면 nvm install && nvm use로 맞출 수 있습니다.
24 이상도 동작하지만, 23.x는 vitest가 지원하지 않으니 피해 주세요.
npm install
npm run dev # 개발 서버
npm run build # 프로덕션 빌드 (tsc + vite + PWA)
npm run sim # 헤드리스 봇 밸런스 시뮬레이션
npm run test # vitest — 아직 테스트 파일이 없다설치 없이 파일 하나로 열어보고 싶으면:
npx vite build --config vite.single.config.ts # → dist-single/index.htmlGitHub Pages에 올린다. main에 푸시하면 .github/workflows/deploy.yml이
빌드해서 게시하고, 주소는 https://gnuinu.github.io/hex-refinery/다.
처음 한 번은 저장소 Settings → Pages → Source를 GitHub Actions로 바꿔야 한다.
이걸 안 하면 워크플로가 마지막 deploy 단계에서 실패한다.
머지 전에 미리 보고 싶으면 Actions 탭에서 브랜치를 골라 수동 실행하면 된다
(workflow_dispatch). 다만 그렇게 하면 실제 주소가 그 브랜치 내용으로 덮이므로
확인이 끝나면 main에서 다시 한 번 돌린다.
주소가 루트가 아니라 /hex-refinery/ 아래인데, base: './'라 그대로 뜬다.
서브패스에서 에셋·서비스워커·오프라인 재방문까지 확인했다.
GitHub Pages는 응답 헤더를 건드릴 수 없다. 그래서 CSP를 헤더가 아니라
index.html의 meta로 넣는다(vite.config.ts의 cspMeta 플러그인). 호스팅을
옮겨도 따라온다는 장점이 있다. 캐시 제어도 불가능하지만 Pages의 기본 TTL이
10분 수준이라, 서비스워커가 오래 캐시돼 옛 버전에 갇히는 문제는 생기지 않는다.
src/config/balance.ts 튜닝 상수 — 난이도는 여기서만 바뀝니다
src/config/theme.ts 팔레트 (구체 그라디언트 3색 + 림라이트)
src/domain/ React를 전혀 모르는 순수 로직
hex.ts offset 육각 좌표, 이웃 6방, 픽셀 변환
trajectory.ts 벽 반사 + 충돌 + 셀 스냅
match.ts 동일 색 flood fill
items.ts 아이템이 터질 때 함께 사라지는 범위
floating.ts 천장 미연결 클러스터 판정
resolve.ts applyShot() → { state, phases }
src/state/useGame.ts Phase 로그를 시간축으로 재생
src/ui/ SVG 보드, HUD, 시작 화면, 게임 방법
sim/quick.ts 탐욕 봇 시뮬레이터
핵심은 applyShot(state, angle)이 최종 상태가 아니라 단계별 이벤트 로그를
함께 돌려준다는 것이다. UI는 travel → attach → clear → item* → drop → descend를
순서대로 재생만 하면 되고, 아이템 연쇄가 몇 단이든 로직은 그대로다.
렌더에서 지키는 세 가지가 부드러움을 만든다.
- 비행 중인 탄의 좌표는 React state에 넣지 않고 노드에 직접 쓴다 (
useGame의flightRef) - 타일 레이어는
memo로 떼어 조준 각도만 바뀔 때 구슬 전체를 다시 그리지 않는다 - 화면 흔들기와 발사 반동은
element.animate()로 건다 — 흔들자고 리렌더를 일으키면 흔들리는 동안 프레임이 깎여 연출이 역효과가 난다
셋 중 하나라도 어기면 발사할 때마다 프레임이 튄다. 1440×900과 1920×1080에서 조준·발사 구간을 재면 median 16.7ms, 32ms를 넘는 프레임은 0개다.
RNG 상태까지 GameState에 들어 있어서, 상태 하나만 저장하면 이후 전개가 완전히 재현된다.
localStorage에 진행 중 세션이 통째로 저장되는 것도 이 덕분이다.
탐욕 봇 시뮬레이션(400발 상한) 기준: 12판 평균 210발, 5판이 상한까지 생존. 봇은 1수만 보므로 사람이 더 오래 버틴다.
난이도는 서로 얽힌 값 넷이 함께 정한다 — COLOR_COUNT, SHOTS_PER_DESCENT,
COLOR_RUN_MIN/MAX, ITEM_CHANCE. 어느 하나도 혼자 움직이지 않는다.
하강 한 번은 89개를 얹는 반면 한 발이 지우는 양은 색 가짓수와 뭉침 길이가 정하고,
아이템은 그 위에 한 번에 820개를 얹어 버린다.
절벽이 가파르다. 색 뭉침과 아이템을 넣었더니 5발 간격에서 봇이 8판 중 8판을
끝까지 살아남아서(최고 행이 11 중 4~7) 3발 간격에 사망선을 10행으로 당겨 겨우 맞췄다.
반대로 2발 간격은 평균 43발로 무너진다. 하나라도 건드리면 반드시 npm run sim으로 다시 잰다.

