Skip to content

Repository files navigation

버블 슈터

육각 격자 버블 슈터. 같은 색 둘만 붙어도 터진다. 오프라인 전용 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.html

배포

GitHub Pages에 올린다. main에 푸시하면 .github/workflows/deploy.yml이 빌드해서 게시하고, 주소는 https://gnuinu.github.io/hex-refinery/다.

처음 한 번은 저장소 Settings → Pages → SourceGitHub Actions로 바꿔야 한다. 이걸 안 하면 워크플로가 마지막 deploy 단계에서 실패한다.

머지 전에 미리 보고 싶으면 Actions 탭에서 브랜치를 골라 수동 실행하면 된다 (workflow_dispatch). 다만 그렇게 하면 실제 주소가 그 브랜치 내용으로 덮이므로 확인이 끝나면 main에서 다시 한 번 돌린다.

주소가 루트가 아니라 /hex-refinery/ 아래인데, base: './'라 그대로 뜬다. 서브패스에서 에셋·서비스워커·오프라인 재방문까지 확인했다.

GitHub Pages는 응답 헤더를 건드릴 수 없다. 그래서 CSP를 헤더가 아니라 index.html의 meta로 넣는다(vite.config.tscspMeta 플러그인). 호스팅을 옮겨도 따라온다는 장점이 있다. 캐시 제어도 불가능하지만 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를 순서대로 재생만 하면 되고, 아이템 연쇄가 몇 단이든 로직은 그대로다.

렌더에서 지키는 세 가지가 부드러움을 만든다.

  1. 비행 중인 탄의 좌표는 React state에 넣지 않고 노드에 직접 쓴다 (useGameflightRef)
  2. 타일 레이어는 memo로 떼어 조준 각도만 바뀔 때 구슬 전체를 다시 그리지 않는다
  3. 화면 흔들기와 발사 반동은 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으로 다시 잰다.

Releases

Packages

Contributors

Languages