Files
blub-blub/docs/DESIGN.md
T
ttipo 89069b06f0 클리커에서 집·일꾼 방치형 구조로 전환
거품은 이제 클릭이 아니라 바다에 있는 생물이 시간에 따라 번다. 화면 가장자리에
깔아 두는 위젯인데 클릭이 주된 수입원이면 연타가 최적 플레이가 되어 형태와
어긋난다. 클릭 수익과 클릭 업그레이드를 걷어내고 업그레이드는 전부 생산 배율로
바꿨다. 물을 만지면 거품이 퍼지지만 얻는 것은 없다.

격자 칸을 차지하는 것은 집뿐이고, 생물은 칸이 아니라 집에 산다(한 채당 3마리).
칸 → 집 → 생물 → 거품으로 한계가 두 겹 걸린다. 예전에는 생물과 장식이 같은
자원(칸)을 놓고 경쟁해서 최적 플레이가 늘 "장식을 사지 말 것"이었는데, 집과
일꾼으로 나누니 그 경쟁이 의존으로 바뀐다. 집은 같은 종류도 여러 채 지을 수 있고
거품을 벌지 않는다 — 정원과 생산을 한 물건이 주면 곱연산이 두 번 걸린다.

어느 생물이 어느 집에 사는지는 저장하지 않는다. 집은 정원만 주므로 어디 살든
결과가 같고, 저장하면 재배정과 인덱스 관리만 붙는다. 그릴 때 구역마다 배정한다.
그래서 저장 형식에서 생물은 위치 없이 {id: 마리 수}다. 옛 저장본은 그때의
장식을 집으로, 생물을 마릿수로 옮겨오고 정원이 모자라면 집을 세워 메운다.

수입이 생물에서만 나오므로 막다른 길을 두 곳에서 막았다. 마지막 생물은 내보낼 수
없고, 살던 생물이 갈 곳이 없어지는 집은 헐 수 없다.

집과 생물의 그림 형태를 갈랐다. 집 10채 중 8채가 생물과 같은 rock이어서 항아리와
거품돌이 색만 다른 같은 덩어리로 보였다. 집은 vessel/wreck/pillar만, 생물은
plant/rock/fish/glow만 쓴다. 헤엄치는 생물은 집을 도는 타원 궤도에 올라가고,
원점이 궤도를 따라 움직이므로 Y 정렬이 앞뒤를 알아서 바꿔준다 — 뒤로 돌면 집에
가렸다가 앞으로 나온다. 얕은 바다 생물이 거품돌과 해초뿐이라 이 움직임을 볼 수
없어서 구피를 넣었다.

실행해 보고 잡은 버그 셋:
- 저장본이 없으면 load_game이 그냥 반환해 빈 바다로 시작했다. 집도 생물도 없어
  아무 일도 일어나지 않는다. 이제 새 게임을 시작한다.
- 창 높이·도킹·모니터 설정이 복원되지 않고 기본값으로 덮어써지고 있었다.
  window_state에 미리 있던 키만 복사하던 것을 저장본에 있는 것을 전부 싣도록 고쳤다.
- 정보 바가 격자 첫 줄을 덮어 왼쪽 위에 놓인 것이 보이지 않았다. GRID_TOP 상수를
  버리고 HUD가 잰 실제 바 높이를 받아 쓴다.
- main.gd가 초기화할 때 존재하지 않는 aquarium.rebuild()를 부르고 있었다.

스모크 테스트 159개.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uze4mF1NAGfFRio77cMFqg
2026-09-01 22:39:25 +09:00

15 KiB
Raw Blame History

Blub Blub 설계 메모

한 줄 요약

화면 아래 가장자리에 깔려 조용히 돌아가는 바닷속 스트립. 다른 창을 쓰는 동안 시선의 가장자리에 있고, 꾸미면 예뻐지고, 예뻐지면 더 빨리 자란다. 러스티의 농장/몬스터 빅팜 같은 "일하면서 곁눈질하는" 형태를 따른다.

핵심 설계 판단

클릭으로 벌지 않는다. 이건 클리커가 아니라 방치형 위젯이다. 거품은 오직 바다에 놓인 생물이 시간에 따라 번다. 화면 가장자리에 깔아 두고 다른 일을 하는 물건인데 클릭이 주된 수입원이면 위젯을 쳐다보며 연타하는 것이 최적 플레이가 되어, 형태와 어긋난다. 물을 만지면 거품이 퍼지지만 그건 연출일 뿐 얻는 것은 없다.

여기서 두 가지가 따라온다. 첫째, 새로 시작하면 항아리 집 한 채와 거품돌·구피 한 마리씩이 이미 있다(Catalog.STARTER_HOUSES / STARTER_FISH). 바닥에 붙는 것과 헤엄치는 것을 하나씩 둔 것은, 켜자마자 두 가지 움직임이 다 보이게 하기 위해서다. 빈 바다는 집도 생물도 없어서 아무리 기다려도 아무 일이 일어나지 않기 때문이다. 시작 거품 20은 세 번째 거품돌 값에 맞춰 두어, 시작하자마자 남은 한 자리를 직접 채워보며 구조를 익히게 했다. 둘째, 마지막 남은 생물은 내보낼 수 없다(GameState.can_release_fish). 생물이 0이 되면 수입도 0이 되고, 환불받은 거품이 다음 한 마리 값에 못 미치면 영영 되돌아올 수 없기 때문이다. 업그레이드도 전부 생산 배율(bps_mult)이고, 클릭 관련 항목은 남기지 않았다.

저장본이 아예 없으면(이 컴퓨터에서 처음 켰으면) SaveManager.load_game이 새 게임을 시작한다. 클릭으로 벌던 시절의 저장본처럼 집이나 생물이 없는 저장본을 불러올 때도 GameState.from_dict가 시작 상태를 채워준다.

집은 건물, 생물은 일꾼이다. 칸을 차지하는 것은 집뿐이고, 생물은 칸이 아니라 집에 산다. 한 채가 3마리를 들인다(Catalog.HOUSE_CAPACITY). 그래서 한계가 두 겹이다.

칸  →  집을 몇 채 지을 수 있는가
집  →  생물을 몇 마리 키울 수 있는가
생물 →  거품을 얼마나 버는가

앞서는 생물도 장식도 똑같이 한 칸을 먹는 "놓는 것"이었다. 그러면 둘이 같은 자원(칸)을 놓고 직접 경쟁해서, 최적 플레이는 언제나 "장식을 사지 말고 칸을 생물로 채우는 것"이 된다. 꾸미기를 성장에 묶으려고 아름다움 배율을 넣어도 이 경쟁을 완전히 이기지는 못했다. 집과 일꾼으로 나누면 경쟁이 의존으로 바뀐다. 집을 짓지 않으면 생물을 늘릴 방법이 아예 없으므로, 꾸미는 것이 성장의 우회로가 아니라 전제 조건이 된다.

정원은 어느 집이든 3으로 같게 두었다. 그래야 큰 집과 작은 집이 다른 축에서 갈린다. 3×2 난파선과 1×1 유리병 편지가 똑같이 3마리를 들이므로, 넓은 집은 아름다움으로, 좁은 집은 칸 효율로 값을 한다. 정원을 크기에 비례시키면 큰 집이 모든 면에서 우월해져 선택이 사라진다. 집마다 다르게 하고 싶으면 카탈로그 항목에 capacity를 넣으면 된다(Catalog.capacity_of).

집은 같은 종류라도 여러 채 짓는다. 정원을 늘리는 것이 성장의 주된 축이므로, 장식처럼 종류당 한 번만 살 수 있으면 상한이 곧바로 막힌다. 값은 생물과 똑같이 지을수록 ×1.15로 오른다. 집이 거품을 직접 벌지 않게 둔 것도 이 때문이다 — 정원과 생산을 한 물건이 동시에 주면 곱연산이 두 번 걸려 곡선이 터진다.

어느 생물이 어느 집에 사는지는 저장하지 않는다. 집은 정원만 주므로 어느 집에 살든 결과가 같다. 저장하면 집을 헐거나 옮길 때 재배정 로직과 인덱스 관리가 붙는데, 그 복잡도로 사는 것이 아무것도 없다. 그릴 때 구역마다 집을 격자 순서로, 생물을 카탈로그 순서로 세워 채운다(Aquarium._assign_fish). 그래서 저장 형식에서 생물은 위치 없이 {id: 마리 수}다.

막다른 길을 두 군데서 막는다. 수입이 생물에서만 나오므로 생물이 0이 되면 되돌아올 길이 없다. 그래서 마지막 한 마리는 내보낼 수 없고(can_release_fish), 살고 있는 생물이 갈 곳이 없어지는 집은 헐 수 없다(can_remove_house).

칸이 곧 집의 한계다. 클리커 장르는 보통 한 종류를 무한히 사서 가격 곡선으로만 속도를 조절한다. 그러면 배치는 장식이 되고, 꾸미기는 성장과 무관한 곁가지가 된다. 여기서는 격자 칸이 지을 수 있는 집 수를 직접 제한하고, 그 집이 다시 생물 수를 제한한다. "무엇을 어디에 지을까"가 매 순간 진짜 고민이 되고, 자리를 늘리는 마지막 길이 다음 구역을 여는 것이라 꾸미기·성장·진행이 한 줄로 이어진다.

직교 격자 + 3/4 부감 시점을 쓴다. 아이소메트릭은 가로·세로 두 축이 모두 화면 높이를 먹기 때문에(높이 = (열+행) × 타일높이 / 2) 220px 스트립에서는 격자가 10×3으로 쪼그라든다. 직교 격자는 세로가 행 수만큼만 필요해서 45×6 = 270칸이 나온다.

그 위에 바닥은 위에서, 물체는 옆에서 그리는 고전적인 2D 관례를 얹었다. 러스티의 농장·스타듀밸리가 쓰는 방식이다. 완전한 탑다운은 물고기를 등에서만 보게 되고 구조물이 납작한 얼룩으로 보여서, 무엇인지 알아볼 수가 없었다.

구현은 단순하다. 노드의 원점을 발자국의 아래 가운데(물체가 바닥에 닿는 지점)에 두고 그림을 위(-Y)로 그린다. 그러면 Y 정렬만으로 앞줄이 뒷줄을 가리는 순서가 저절로 맞는다. height(칸 높이의 배수)가 1을 넘으면 뒷줄을 가리므로, 이 값이 곧 배치의 무게가 된다.

구역은 왼쪽에서 오른쪽으로 깊어진다. 스트립이 가로로 길다는 형태를 그대로 진행 방향으로 썼다. 왼쪽 끝이 수면에 가까운 얕은 바다, 오른쪽 끝이 열수구다. 한 화면에 지나온 길과 앞으로 갈 길이 같이 보이기 때문에, 위젯을 흘깃 볼 때마다 "어디까지 왔는지"가 설명 없이 읽힌다.

다음 구역을 열려면 앞 구역을 꾸며야 한다. 해금 조건을 거품만으로 두면 플레이어는 앞 구역을 최소한만 건드리고 계속 오른쪽으로 달린다. 앞 구역의 아름다움을 조건으로 걸어서, 진도를 빼려면 반드시 지금 있는 바다를 가꾸게 했다.

꾸미기와 성장을 하나로 묶었다. 방치형 게임에서 꾸미기는 보통 "번 돈을 쓰는 곳"에 그치고, 그러면 꾸미기가 성장의 경쟁 상대가 되어 아무도 꾸미지 않는다. 그래서 생물도 집도 모두 아름다움(beauty) 수치를 가지고, 아름다움이 다시 전체 생산량에 곱해지도록 했다. 꾸미는 것과 최적으로 플레이하는 것이 같은 방향을 가리킨다.

아름다움 배율 = 1 + sqrt(아름다움) × 0.02

제곱근이라 초반에는 한 개 사는 것이 체감되고(아름다움 100 → 1.20배), 후반에는 완만해져서(10,000 → 3.0배) 인플레이션을 막는다.

수치

항목 위치
집 정원 3마리 (집마다 바꾸려면 capacity) Catalog.HOUSE_CAPACITY
시작 상태 항아리 1채 + 거품돌·구피 1마리씩 Catalog.STARTER_HOUSES / STARTER_FISH
물고기 공전 속도 0.5 rad/s (한 바퀴 약 12초) Aquarium.ORBIT_SPEED
시작 거품 20 (세 번째 거품돌 값) Catalog.STARTER_BUBBLES
업그레이드 생산 배율 6종, 다 사면 ×17.1 Catalog.UPGRADES
가격 성장률 ×1.15 — 생물도 집도 같다 Catalog.COST_GROWTH
아름다움 배율 계수 0.02 Catalog.BEAUTY_MULT_SCALE
오프라인 상한 8시간 SaveManager.OFFLINE_CAP_SECONDS
오프라인 효율 50% SaveManager.OFFLINE_RATE
자동 저장 30초 SaveManager.AUTOSAVE_INTERVAL
상점 공개 조건 해금된 구역 + 가격의 50%를 벌어본 적 있음 GameState.is_revealed()
구역 수 5 (구역당 생물 2 + 집 2) Catalog.ZONES
구역 해금 비용 30.0K / 3.00M / 400M / 40.0B Catalog.ZONES[i].unlock_cost
구역 해금 아름다움 앞 구역에 90 / 500 / 2.20K / 8.00K Catalog.ZONES[i].require_beauty
격자 구역당 9열 × 6행 (총 45×6 = 270칸) Catalog.ZONE_COLUMNS / GRID_ROWS
물체 높이 칸 높이의 0.75~1.95배 각 항목의 height
집 값 그 구역 싼 생물의 약 6배 각 집의 base_cost
헐거나 내보낼 때 환불 50% Catalog.REFUND_RATE
정보 바 자리 HUD가 재어 알려준 바 높이 + 6px HUDLayer.reserved_top()
스트립 높이 160 / 220 / 300px Main.HEIGHTS

생물 10종·집 10종을 구역당 2+2로 나눠 두었다. 집 값은 "그 구역에서 가장 싼 생물의 약 6배"라는 규칙으로 잡아, 집 한 채 → 생물 세 마리 → 다시 집 한 채로 리듬이 번갈아 오도록 했다. 집이 정원을 쥐게 되면서 곡선이 통째로 달라졌으므로, 실제 수치는 플레이해 보며 다시 맞춰야 한다. 조정할 것은 Catalogbase_cost / bps / beauty 세 숫자와 HOUSE_CAPACITY 하나다.

데이터 추가하는 법

autoload/catalog.gd의 배열에 딕셔너리 한 줄을 넣으면 상점 목록, 수조 렌더링, 저장 형식이 전부 따라온다. 다른 파일은 건드릴 필요가 없다.

{
    "id": "shrimp", "name": "청소새우", "zone": 0, "shape": "fish", "height": 0.8,
    "desc": "바닥을 훑고 다닌다.",
    "color": Color(0.9, 0.4, 0.4), "base_cost": 400.0, "bps": 3.0, "beauty": 5.0,
},

집은 HOUSES에 넣는다. 생물과 같은 항목을 쓰되 bps 대신 size(바닥에서 차지하는 칸, 기본 1×1)를 쓰고, 정원을 3에서 바꾸고 싶을 때만 capacity를 더한다.

{
    "id": "reef_cave", "name": "산호 동굴", "zone": 1, "shape": "rock",
    "size": Vector2i(2, 2), "height": 1.3,
    "desc": "틈마다 물고기가 숨는다.",
    "color": Color(0.9, 0.5, 0.5), "base_cost": 30000.0, "beauty": 70.0,
},

shape은 두 계통으로 갈라져 있다. 섞어 쓰면 화면에서 "건물"과 "사는 것"이 구분되지 않는다.

계통 움직임
생물 plant / rock 집 둘레 제자리에 붙어 선다
생물 fish / glow 집 둘레를 돌며 뒤로 돌아 들어갔다 나온다
vessel / wreck / pillar 바닥에 고정

scenes/aquarium/tile_item.gd가 형태별로 서 있는 모습을 직접 그린다. height는 칸 높이의 몇 배로 서는지다. zone은 어느 구역에 속하는지다(0 = 얕은 바다). 그 구역이 열려야 상점에 나타나고, 집은 그 구역 밖에 지을 수 없다(구역 경계를 걸치는 것도 안 된다). 생물은 자기 구역의 집에만 들어간다.

물빛 오버레이가 덮이면 형태가 바닥에 묻히므로, 모든 면에 어두운 테두리를 두른다 (_fill()). 테두리를 빼면 무엇인지 읽히지 않는다.

아트

지금은 스프라이트 없이 _draw()로 도형을 그린다. 색과 형태만으로 구분되는 자리 표시용이다.

집과 생물의 형태를 아예 갈라 두었다. 처음에는 집 10채 중 8채가 생물과 같은 rock이어서, 화면에서 항아리와 거품돌이 색만 다른 같은 덩어리로 보였다. 지금은 집이 vessel(배가 부르고 입구가 뚫린 그릇) / wreck(옆으로 누운 뼈대와 갈비뼈 사이의 빈틈) / pillar(이음매가 있는 기둥) 셋만 쓰고, 셋 다 물고기가 드나들 만한 틈이나 입구를 실루엣에 드러낸다. 생물은 이 셋을 쓰지 않는다. tests/smoke.gd의 "형태 계통"이 이 규칙이 깨지지 않는지 지킨다.

헤엄치는 생물은 집 둘레를 돈다. fish/glow는 제자리에 서지 않고 집을 중심으로 납작한 타원 궤도를 돈다(Aquarium._orbit_center / TileItem.set_orbit). 노드의 원점 자체가 궤도를 따라 움직이므로 Y 정렬이 앞뒤를 알아서 바꿔준다 — 뒤로 돌면 집에 가리고 앞으로 나오면 집 앞에 그려진다. 여기에 뒤쪽에서 크기를 0.72배, 투명도를 0.45로 낮춰 "집 안으로 들어갔다 나온다"로 읽히게 했다. 같은 집의 물고기는 정원 수만큼 위상을 나눠 갖고, 집마다 도는 방향이 반대다. 바닥에 붙는 plant/rock은 돌지 않는다 — 해초가 집 주위를 헤엄치면 이상하다. 실제 아트가 생기면 assets/art/에 넣고 tile_item.gdSprite2D 기반으로 바꾸면 된다(카탈로그에 texture 항목 추가).

바닥은 seabed.gd가 칸마다 그리고, 그 위에 water.gdshader가 물빛과 일렁이는 빛 그물을 덮는다. 깊이감은 이 오버레이의 murk가 만든다.

창 동작 (Windows)

  • 투명·테두리 없음: project.godotper_pixel_transparency/allowed + size/transparent
  • 도킹: screen_get_usable_rect()로 작업표시줄을 뺀 영역을 구해 폭 전체로 늘리고 위/아래 가장자리에 붙인다. 창을 끌어 옮기는 기능은 없다 — 위치는 계산되는 값이다.
  • 스트레치 모드는 disabled. 화면 폭이 얼마든 1:1 픽셀로 그려야 글씨가 흐려지지 않는다.
  • 셰이더는 화면에 닿는 쪽 모서리를 각지게, 바깥쪽만 둥글게 깎는다. 바탕화면에서 자라난 것처럼 보이게 하려는 것.
  • 클릭 통과: DisplayServer.window_set_mouse_passthrough(). 빈 배열이면 통과 해제, 폴리곤을 주면 그 안쪽만 입력을 받는다. 통과를 켜도 오른쪽 아래 모서리를 입력 영역으로 남겨 두어 되돌아올 길을 만든다.

아직 안 만든 것 (우선순위 순)

  1. 두 번째 화폐(진주) — 아름다움에 비례해 아주 천천히 쌓이고, 특별한 집만 지을 수 있게. 거품 인플레이션과 분리된 장기 목표가 생긴다.
  2. 환생/이사 — 더 큰 수조로 옮기면서 영구 배율을 얻는 장르 표준 루프.
  3. 소리 — 물소리와 거품 소리. 위젯이라 기본은 음소거가 맞을 듯.
  4. 트레이 아이콘 — Godot 기본 기능에 없어서 GDExtension이나 별도 런처가 필요하다. 당장은 작업표시줄 창으로 충분.
  5. 폭을 화면 일부만 쓰기 — 지금은 항상 폭 전체다. 왼쪽/가운데/오른쪽 정렬과 폭 비율 선택이 있으면 넓은 모니터에서 덜 부담스럽다.

한글 문장

구역 이름을 문장에 끼워 넣는 곳이 여럿이라 조사가 어긋나기 쉽다 ("켈프 숲를 더 꾸며야 해요"). scripts/korean.gd가 마지막 글자의 받침을 보고 을/를·이/가·은/는을 골라준다. 새 문장을 쓸 때도 이걸 거쳐야 한다.

저장 파일 보호

SaveManager.persistence_enabled를 끄면 저장/자동저장/종료 시 저장이 모두 멎는다. 스모크 테스트가 이걸 끈다. 끄지 않으면 테스트가 끝나면서 초기화된 상태를 실제 저장 파일에 덮어써 진행도가 날아간다.