Files
ttipo 868ce08f90 헤엄치는 것을 바닥에서 띄워 물속에 있게
바닥은 부감, 물고기는 측면이라 물고기가 모래를 기어다니는 것처럼 보였다.
물고기를 설치물과 똑같이 바닥에 붙여 놨으니 당연한 결과였다.

- position은 "모래 위 어디", hover는 "거기서 물기둥 얼마나 위"로 분리.
  그림자는 바닥에 남고 몸만 뜬다. 이 간격이 물속으로 읽히게 한다
- 물고기 1~2.9칸, 해파리 1.8~3.6칸, 해마 0.5~1.7칸, 게·새우는 거의 0
- 이동을 시작할 때마다 높이를 새로 뽑아 천천히 오르내린다
- 뜰 자리를 위해 헤엄치는 것의 바닥 범위를 앞쪽으로. 뒷줄일수록 덜 뜬다
- 뜬 높이만큼 그림자를 넓고 옅게

draw_set_transform()이 쌓이지 않고 갈아치우는 탓에, 형태 함수가 좌우를 뒤집으며
Vector2.ZERO로 다시 부를 때 hover가 통째로 날아가고 있었다. 오프셋을
_body_offset에 담고 _xform()을 거치도록 고쳤다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 16:03:28 +09:00

32 KiB
Raw Permalink 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
격자 구역당 16열 × 6행 (총 80×6 = 480칸) Catalog.ZONE_COLUMNS / GRID_ROWS
칸 가로:세로 1.15 Aquarium.TILE_ASPECT
물체 높이 칸 높이의 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가 만든다.

먼 바다 배경

3/4 부감에서 화면 위쪽은 "더 먼 곳"이다. 거기까지 모래를 깔면 바다가 아니라 사막처럼 보였다. 그래서 격자 위쪽 띠를 backdrop.gd가 물로 채운다.

  • 먼 물 — 위로 갈수록 어두워지는 세로 그라디언트. 열마다 사각형을 그리고 정점 색을 먹여 가로(구역)·세로(거리) 양쪽으로 동시에 번지게 한다.
  • 능선 — 격자 바로 위에 물결치는 먼 바닥. 이 선이 수평선 노릇을 한다.
  • 먼 실루엣 — 능선 위의 낮고 넓은 덩어리들. 무엇인지 알아볼 필요는 없고 "저 너머에도 뭔가 있다"는 신호만 주면 된다. 가늘고 높게 그리면 비석처럼 서 버려서 먼 풍경으로 안 읽힌다.
  • 공기원근seabed.gd가 뒷줄(위쪽) 칸일수록 색을 물빛 쪽으로 22%까지 당겨서 멀어 보이게 한다.
  • 빛줄기water.gdshader가 비스듬히 내려오는 빛을 얹는다. 세기는 murk의 반대(_ray_strength())라 얕은 바다에서만 뚜렷하고 심해에서는 사라진다.

띠의 높이는 HUD가 reserved_top()으로 알려주는 값이다. 정보 바가 그 안에 앉고, BAR_CLEARANCE만큼 남는 자리에 배경이 드러난다. 바를 키우면 배경이 가려지고 격자가 좁아지므로, 이 셋은 같이 조정해야 한다.

창 동작 (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를 끄면 저장/자동저장/종료 시 저장이 모두 멎는다. 스모크 테스트가 이걸 끈다. 끄지 않으면 테스트가 끝나면서 초기화된 상태를 실제 저장 파일에 덮어써 진행도가 날아간다.

화면보다 넓은 바다

칸 크기를 화면 너비에서 나누지 않고 행 높이에서 정한다(cell_size()). 세로는 격자 높이 ÷ 행 수, 가로는 거기에 TILE_ASPECT를 곱한 값이다. 그래서 구역의 열을 늘리면 칸이 작아지는 대신 바다가 화면 밖으로 넓어진다.

좌표계가 둘로 갈린다.

  • 화면 좌표 — 입력(event.position), 휴지통 영역
  • 바다 좌표 — 격자·집·생물·배경이 사는 곳

World 노드 하나가 이 둘을 잇는다. scroll_x만큼 World.position.x를 음수로 밀어 두고, 입력이 들어오면 to_world()로 되돌린다. 바다에 속한 것은 전부 World 아래에 있으므로 스크롤에 자동으로 따라온다 — 배경, 바닥, 집, 생물, 물빛, 구분선, 구역 이름, 잠금 베일, 배치 미리보기, 물 만짐 연출까지.

둘러보는 법: 빈 바닥을 끌거나(_begin_pan()) 마우스 휠. 무언가를 누르고 끌면 그것을 옮기는 것이고, 빈 곳을 누르고 끌면 바다를 미는 것이다 — _press_ref가 비었는지로 갈린다.

자동으로 따라가는 곳: 구역이 새로 열렸을 때와, 상점에서 집을 골랐을 때. 후자가 없으면 화면 밖 구역에는 지을 자리를 찍을 수가 없다.

보던 위치는 window_state["scroll_x"]에 저장한다. 격자 크기가 정해지기 전에 복원값이 들어올 수 있어서, 클램프 전의 요청값을 _wanted_scroll에 남겨 두었다가 _on_layout_changed()에서 다시 적용한다.

하루의 빛

autoload/day_cycle.gd실제 시각에 맞춰 바다의 빛을 바꾼다. 게임 안의 시간을 따로 돌리지 않은 이유는 이것이 하루 종일 화면 아래에 켜져 있는 물건이기 때문이다. 새벽에 일하는데 바다가 한낮이면 창밖처럼 읽히지 않는다.

이름은 끊고 빛은 잇는다. 새벽/아침/낮/저녁/밤은 시각으로 구간을 나누지만 (PHASES), 색과 밝기는 LIGHT_KEYS를 계속 보간한다. 해가 지는 동안 서서히 물들어야지, 19시가 되는 순간 화면이 딸깍 바뀌면 안 된다.

  • tint — 바다에 곱하는 색. 밤에는 전체가 어두워지고 저녁에는 주황이 돈다.
  • light — 수면에서 오는 빛의 양. 물결 무늬와 빛줄기(sun 유니폼)의 세기가 여기 붙는다.

Aquarium._apply_daylight()_daylit에 담긴 층에만 modulate를 건다. 배치 미리보기(Ghost)는 일부러 뺐다 — 밤이라고 초록/빨강 판정이 흐려지면 집을 어디에 놓을지 알 수 없다. HUD와 상점도 World 밖이라 늘 또렷하다.

밤의 발광: 전체에 어둠이 곱해지므로 스스로 빛나는 것(glow)은 그대로 두면 같이 묻힌다. _draw_glow()1 - light만큼 색을 끌어올리고 후광을 넓혀, 어두울수록 오히려 도드라지게 했다. 심해 해파리가 밤에 살아나는 것이 이 때문이다.

게임 안에서 정하기: ··· 메뉴의 시간 하위 메뉴에 세 갈래가 있다.

  • Mode.REAL — 바깥 시계 그대로. 기본값이다.
  • Mode.FIXED — 한 시각에 멈춘다. 밤바다만 보고 싶을 때. 메뉴에 올리는 프리셋은 FIXED_PRESETS에 이름과 시각을 같이 둬서 메뉴 항목과 실제 시각이 어긋나지 않게 했다.
  • Mode.FAST — 하루를 FAST_DAY_MINUTES(5분)로 압축해 돌린다. 압축한 시계는 매 틀 굴리고 화면 갱신만 뜸하게 한다.

모드를 바꿀 때 지금 보이던 시각을 먼저 잡아 둔다. mode를 먼저 바꾼 뒤 hour()를 부르면 이미 새 모드의 시계를 읽어 0시로 튄다 -- 빠르게로 넘어갈 때마다 한밤중으로 뚝 떨어지는 버그였다.

고른 값은 window_state["time_mode"]/["time_hour"]에 저장된다. 명령줄 --hour=21은 FIXED 모드의 지름길이고, 테스트도 같은 문을 쓴다.

감상 모드 (전체화면)

F11이나 메뉴로 창을 화면 전체로 키운다. 그냥 창만 키우면 안 되는 이유가 있다 — 칸 크기가 격자 높이에서 나오기 때문에, 화면이 높아질수록 타일만 거대해지고 보이는 열은 오히려 줄어든다. 감상하려고 켰는데 볼 수 있는 바다가 좁아진다.

그래서 세로로 남는 공간을 격자가 아니라 물기둥에 준다. Aquarium.grid_rect()가 감상 모드에서는 아래쪽 SHOWCASE_GRID_RATIO(0.45)만 격자로 쓰고, 나머지 위쪽은 Backdrop이 먼 바다로 채운다. 바닥은 크게 보이면서 위로는 빛줄기가 내려오는 깊은 물이 열린다.

물기둥을 비어 보이지 않게 하는 것들

  • 빛줄기를 더하는 방향으로 바꿨다. 전에는 알파를 낮춰 물을 걷어냈는데, 그러면 바닥만 밝아지고 물기둥은 텅 빈 채로 남는다. 이제는 알파를 올려 물 속에 떠 있는 밝은 기둥으로 그린다.
  • 빛줄기 무늬의 y 계수를 줄였다. 크게 두면 화면이 높을 때 무늬가 가로로 누워 빛줄기로 안 보인다.
  • 부유물은 바다 전체가 아니라 지금 보이는 곳에만 뿌린다(_follow_view()). 넓은 바다에 고르게 뿌리면 화면당 밀도가 낮아진다. local_coords = false라 이미 나온 것은 그 자리에 머물러, 화면을 밀어도 물이 같이 끌려오지 않는다.
  • 격자선은 옅게 누른다. 지금은 놓는 시간이 아니라 보는 시간이다.

창 크기의 함정: 창을 화면과 픽셀까지 똑같이 맞추면 Windows가 이를 독점 전체화면(MODE_EXCLUSIVE_FULLSCREEN)으로 승격시켜 버리고, 그 뒤로는 size를 넣어도 먹지 않아 감상 모드에서 빠져나올 수 없다. 그래서 감상 모드도 작업표시줄을 뺀 usable_rect까지만 쓴다. 보기에는 거의 같고 복귀는 확실하다.

칸 크기가 통째로 달라지므로 스크롤 픽셀값을 그대로 두면 엉뚱한 곳으로 튄다. 보고 있던 열을 기억했다가(_center_column()) 그 자리로 되돌린다(look_at_column()).

층 (한 화면에 한 지역)

예전에는 다섯 구역이 옆으로 이어져 있고 가로 스크롤로 훑었다. 지금은 한 화면이 한 층이고, 층은 위에서 아래로 얕은 바다 → 열수구 순으로 쌓인다.

구현은 여전히 가로다. 층 n은 열 n * ZONE_COLUMNS부터 시작하고, 화면은 그중 한 구간만 비춘다(layer_offset()). 저장 형식(전역 열 번호)을 건드리지 않아도 되고, 격자 계산도 그대로 쓸 수 있다.

보이는 것만 세로다. 층을 갈아탈 때 _slide_to_layer()가 가로 위치는 즉시 새 층으로 옮기고 세로만 화면 높이만큼 밀어 넣었다가 0으로 당긴다. 아래층으로 가면 아래에서 올라오고, 위층으로 가면 위에서 내려온다. 깊이가 세로로 읽히는 것은 이 연출뿐이지만, 그것으로 충분하다.

칸 크기가 뒤집혔다. 예전에는 높이에서 칸을 정하고 넘치는 만큼 옆으로 스크롤했다. 이제는 한 층이 화면을 꽉 채워야 하므로 가로에서 정한다cell_w = 화면너비 / ZONE_COLUMNS, cell_h = cell_w / TILE_ASPECT. 격자는 언제나 화면 아래쪽에 붙고, 남는 위쪽은 물기둥이 된다. 스트립처럼 세로가 모자라면 cell_h를 눌러 넣는다.

ZONE_COLUMNS는 이 식 때문에 36으로 올렸다. 너무 적으면 스트립(가로로 길고 세로로 짧은 창)에서 칸이 3:1로 납작해진다. 대신 층당 칸이 96 → 216으로 늘었으므로 정원과 해금 곡선은 다시 봐야 한다.

색은 층마다 평평하다. 예전에는 구역 경계에서 색을 섞어 이음매를 감췄지만, 이제 한 화면에 한 층만 보이므로 섞을 이유가 없다. 섞으면 오히려 한 화면 안에서 좌우로 색이 흐르는데, 깊이는 더 이상 가로가 아니다. Catalog.ramp_color()가 그 자리의 층 색을 그대로 돌려준다.

층 선택기(scenes/ui/layer_bar.gd)는 왼쪽 아래에 세로로 선다. 단추마다 그 층의 물빛을 입혀서, 목록만 봐도 위에서 아래로 어두워지는 것이 보인다. 잠긴 층도 눌러서 해금 조건을 보러 갈 수 있다.

함께 사라진 것: 가로 패닝, 구역 구분선(한 화면에 둘이 안 보이므로 무의미), window_state["scroll_x"](→ ["layer"]).

사는 것과 사는 곳

처음에는 거품돌·해초·산호·켈프·관벌레가 생물 쪽에 있었다. 하지만 이것들은 물고기가 깃드는 자리지 그 자체가 일꾼은 아니다. 실제로 물고기는 바위 틈과 해초 사이를 보금자리로 삼는다. 그래서 전부 으로 옮겼다.

  • 생물 — 물고기·게·해마·새우처럼 움직이는 해양동물만. 거품을 버는 것은 이쪽뿐이다.
  • — 자연 서식지(rock/plant)와 지어 올린 것(vessel/wreck/pillar). 칸을 차지하고 정원과 아름다움을 준다.

집이 두 갈래가 되면서 정원에 폭이 생겼다. capacity를 아이템별로 둘 수 있게 해서 자연 서식지는 싸지만 적게 들이고(거품돌 1, 해초 2, 산호 2), 지어 올린 것은 비싼 대신 표준 정원(3)을 준다. 켈프와 관벌레는 4까지 들인다.

형태 계통이 그대로 경계가 된다. 생물은 fish/glow/crab/seahorse/shrimp, 집은 rock/plant/vessel/wreck/pillar. 두 집합이 겹치지 않는 것을 테스트가 지킨다. 겹치면 화면에서 "사는 것"과 "사는 곳"이 구분되지 않는다.

옛 저장본: 예전에 생물로 사 둔 돌·해초는 _restore_fish()가 같은 id의 집으로 그만큼 지어 준다. 그냥 버리면 플레이어가 사 둔 것이 조용히 사라진다.

이때 드러난 버그

생물의 자리를 grid_rect()(화면 테두리)로 잘라내고 있었다. 가로로 이어 스크롤하던 시절에는 화면 = 바다여서 맞았지만, 층이 옆으로 늘어선 지금은 뒤쪽 층의 생물이 전부 첫 층 오른쪽 끝으로 끌려와 제 층에서 사라졌다. 집은 멀쩡히 보이는데 생물만 없어서 한참 헤맸다. _home_bounds()가 그 집이 속한 층의 테두리를 돌려주고, 자를 때 그것을 쓴다.

_make_fish()fishglow만 궤도에 올리고 있었다. 고착생물이 생물 쪽에 있던 시절의 가드인데, 새로 넣은 게·해마·새우가 여기 걸려 제자리에 굳어 있었다. 고착생물이 전부 집으로 갔으므로 가드 자체를 걷어냈다.

생물의 움직임

처음에는 생물이 제 집 둘레를 타원으로 돌았다. 집과 생물의 관계는 분명해졌지만 꾸미기 게임에는 어울리지 않았다 — 물고기가 말뚝에 묶인 것처럼 보였고, 집을 여러 채 지으면 화면이 회전목마 여러 대가 된다.

지금은 층 전체를 자유롭게 돌아다닌다. 집은 정원을 세는 단위로만 남고, 헤엄치는 범위는 그 층의 격자 전체다(_roam_bounds). 제 집 앞에서 시작해 거기서부터 어디로든 간다.

알고리즘은 방향 흔들기(wander)다. 목표 지점을 따로 두지 않고 진행 각도만 매 틀 조금씩 흔든다. 그것만으로 충분히 어슬렁거리는 것처럼 보이고, 목표를 찍는 방식보다 훨씬 싸다. 테두리는 닿기 전에 미리 각도를 반사시킨다 -- 벽에 붙어 비비는 것보다 자연스럽다.

종류마다 다니는 방식이 다르다. 같은 식으로 움직이면 여러 마리가 한 화면에 있을 때 움직임이 무늬가 되어 버린다.

속도 노는 높이 특징
물고기 빠름 물속 전체 크게 돌아다닌다
보통 바닥께 세로 성분을 12%로 눌러 바닥을 짚고 걷는다
해마 느림 위아래 넓게 가로 성분을 35%로 눌러 거의 제자리에서 오르내린다
새우 느림 + 돌진 바닥께 1~3초마다 0.35초 동안 3.2배로 튄다
해파리 아주 느림 위쪽 물살에 밀리듯

앞뒤 단서: 화면 위쪽(뒷줄)일수록 조금 작고 흐리게 그린다(_apply_depth). 3/4 시점에서 깊이를 읽게 해주는 유일한 신호라 없으면 평면처럼 보인다.

헤엄치는 리듬

돌아다니는 생물은 두 상태를 오간다 — 한동안 나아가다가(CRUISE) 잠시 멈춘다(REST). 방향은 매 틀 흔들지 않고, 나아갈 때마다 목표 각도를 하나 정해 그쪽으로 부드럽게 돌아선다.

매 틀 각도를 무작위로 흔드는 방식을 먼저 써 봤는데 두 가지가 어색했다. 하나는 속도가 늘 일정해서 쉬는 틈이 없었고, 다른 하나는 층이 가로로 아주 길고 세로로 짧다 보니 무작위 방향이 위아래 벽에 계속 부딪혀 통통 튀는 것처럼 보였다. 그래서 종류마다 기본으로 삼는 축(물고기·게는 좌우, 해마·해파리는 위아래)을 두고 거기서 조금만 벗어나게 했고, 벽 앞에서는 튕기는 대신 미리 안쪽으로 돌아서게 했다.

속도는 전반적으로 느리게 잡았다. 방치형이라 늘 눈에 들어와 있는 화면이고, 빠른 움직임은 몇 분만 지나도 산만해진다. 물고기가 벗어나는 각도도 6도 남짓으로 좁혀 두었다 -- 실제로 물고기가 수직으로 오르내리는 일은 드물다. 대신 열 번에 한 번쯤은 크게 틀어 수심을 바꾸게 해서, 모두가 제 높이에 줄지어 있지 않도록 했다.

한동안은 "몇 초 동안 나아간다"로 잡았는데, 시간이 좁은 범위 안에서만 흔들리고 속도가 고정이라 결국 매번 비슷한 거리를 갔다. 짜인 것처럼 보였다. 지금은 나아갈 때마다 거리와 속도를 각각 새로 뽑는다. 거리는 층 높이의 0.36배(대략 122칸) 사이에서 뽑되 짧은 쪽으로 치우치게 해서(pow(randf(), 1.6)), 몇 칸만 가는 일이 잦고 화면을 가로지르는 일은 가끔 있게 했다. 방향도 좌우 어느 쪽인지 매번 새로 던진다 -- 가던 쪽을 이어 가게 했더니 오히려 한 방향으로 흐르는 것처럼 보였다.

물기둥 높이 (hover)

바닥은 위에서 내려다보고 물고기는 옆에서 본 모습으로 그리다 보니, 물고기가 모래 위를 기어다니는 것처럼 보였다. 원인은 물고기를 설치물과 똑같은 규칙으로 -- 원점을 바닥에 닿는 지점에 두고 -- 놓았기 때문이다. 바닥에 붙어 있으면 바닥에 붙은 것으로 읽히는 게 당연하다.

그래서 헤엄치는 것에는 높이를 하나 더 뒀다. position은 여전히 "모래 위 어디"를 가리키고 그림자도 거기 남지만, 몸은 hover만큼 위에 그린다. 이 간격이 벌어질수록 물속에 떠 있는 것으로 읽힌다. 물고기는 칸 높이의 1~2.9배를 뜨고, 이동할 때마다 그 높이를 새로 뽑아 천천히 오르내린다. 게와 새우는 거의 뜨지 않고 해파리는 가장 높이 뜬다. 뜬 높이만큼 그림자는 넓고 옅게 번진다.

뜰 자리를 확보하려고 헤엄치는 것들의 바닥 범위를 앞쪽(화면 아래)으로 옮겼다. 뒷줄에 있으면 화면 위 여유가 없어 높이 뜰 수 없는데, 이건 원근으로도 맞는 얘기라 _hover_ceiling()으로 자리에 따라 알아서 낮아지게 뒀다.

주의: draw_set_transform()은 쌓이지 않고 통째로 갈아치운다. 형태 함수 안에서 좌우를 뒤집을 때 Vector2.ZERO로 다시 부르면 hover 오프셋이 통째로 날아간다. 실제로 이 때문에 물고기·게·해마·새우가 전부 바닥에 붙어 있었다. 그래서 오프셋을 _body_offset에 담아 두고 _xform()을 거치도록 했다 -- 형태 함수에서 직접 draw_set_transform을 부르면 안 된다.