diff --git a/README.md b/README.md index c51e517..70f06fa 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,32 @@ # Blub Blub 화면 아래 가장자리에 가로로 깔리는 바닷속 방치형 스트립. -다른 일을 하는 동안 화면 밑에서 조용히 돌아간다. 거품을 모아 바다를 꾸미고, 꾸밀수록 거품이 더 빨리 모인다. +다른 일을 하는 동안 화면 밑에서 조용히 돌아간다. **집을 지어 자리를 만들고, 그 자리에 생물을 들이면** +생물이 알아서 거품을 모은다. 모은 거품으로 바다를 꾸미면 더 빨리 모인다. 창은 화면 폭 전체를 차지하고 높이만 160~300px이다. 작업표시줄은 덮지 않는다. ## 실행 -- 에디터: `%LOCALAPPDATA%\Programs\Godot\Godot_v4.7.2-stable_win64.exe` 를 실행해 이 폴더를 열거나, 이미 임포트되어 있으면 프로젝트 목록에서 **Blub Blub** 선택 - 게임만: `.\run.ps1` - 테스트: `.\test.ps1` +- 에디터: Godot 실행 파일로 이 폴더를 연다. 이미 임포트되어 있으면 프로젝트 목록에서 **Blub Blub** 선택 + +`run.ps1`과 `test.ps1`은 Godot을 `%LOCALAPPDATA%\Programs\Godot`, 바탕화면, 다운로드, +`%ProgramFiles%\Godot`, PATH 순으로 찾는다. 다른 데 있으면 `GODOT_BIN` 환경 변수에 경로를 넣으면 된다. +클론 직후처럼 `.godot`이 없으면 두 스크립트가 먼저 임포트를 한 번 돌린다 +(임포트 전에는 `class_name`이 등록되지 않아 스크립트가 통째로 안 뜬다). ## 조작 | 동작 | 방법 | | --- | --- | -| 거품 모으기 | 빈 곳 좌클릭 | -| 놓기 | 상점에서 고른 뒤 칸을 클릭 (우클릭·Esc로 취소) | -| 옮기기 | 놓인 것을 끌어다 다른 칸에 놓기 (공짜) | -| 치우기 | 끌어서 아래 휴지통에 떨구기 (절반 환불) | +| 물 만지기 | 빈 곳 좌클릭 (거품이 퍼진다 · 거품은 생물이 번다) | +| 집 짓기 | 상점 **집** 탭에서 고른 뒤 칸을 클릭 (우클릭·Esc로 취소) | +| 생물 들이기 | 상점 **생물** 탭에서 클릭 — 그 자리에서 바로 들어온다 (빈 자리 필요) | +| 집 옮기기 | 집을 끌어다 다른 칸에 놓기 (공짜 · 살던 생물도 따라간다) | +| 집 헐기 | 집을 끌어서 아래 휴지통에 (절반 환불 · 세입자가 갈 곳이 있어야 한다) | +| 생물 내보내기 | 생물을 끌어서 아래 휴지통에 (절반 환불 · 마지막 한 마리는 안 된다) | | 상점 열기 | 왼쪽 위 **상점** (패널은 오른쪽에 열린다) | | 메뉴 | **···** 버튼 또는 물 위 우클릭 | @@ -30,15 +38,17 @@ ``` project.godot 창 설정(투명·테두리 없음·항상 위·1:1 픽셀)과 오토로드 등록 +run.ps1 / test.ps1 게임 실행 / 스모크 테스트 (find-godot.ps1이 Godot을 찾아준다) autoload/ catalog.gd 밸런스 데이터 전부. 아이템 추가는 여기서만. - game_state.gd 보유 자원·구매 상태와 파생 수치(bps, 아름다움, 등급) + game_state.gd 보유 자원·배치 상태와 파생 수치(bps, 아름다움, 아름다움 배율) save_manager.gd 저장/불러오기, 30초 자동 저장, 오프라인 보상, 창 위치 scenes/ main.tscn/.gd 도킹(폭 전체·가장자리 스냅)·클릭 통과·메뉴와 화면 조립 aquarium/ 물빛 셰이더, 모래 바닥, 물속 오브젝트 ui/ 왼쪽 위 정보 바, 오른쪽 상점 패널 scripts/ + korean.gd 받침을 보고 을/를·이/가를 고른다 number_format.gd 1234 -> "1.23K", 경과 시간 표기 ui_style.gd UI 색과 스타일박스 tests/smoke.tscn/.gd 경제 로직 스모크 테스트 @@ -46,9 +56,11 @@ tests/smoke.tscn/.gd 경제 로직 스모크 테스트 ## 게임 루프 -1. 물 아무 곳이나 클릭해 **거품**을 모은다. -2. 거품으로 **생물**(해초, 물고기…)을 사면 초당 생산량과 **아름다움**이 오른다. -3. **장식**(난파선, 석상…)은 한 번만 살 수 있고, 아름다움이 크며 전체 생산에 곱연산 보너스를 준다. +1. 바다에 있는 **생물**이 시간에 따라 알아서 **거품**을 모은다. 클릭으로 버는 길은 없다. + 그래서 새로 시작하면 **항아리 집 한 채와 거품돌·구피 한 마리씩**이 이미 있고, 거품 20을 쥐고 시작한다. +2. 생물은 **집**에 산다. 한 채가 3마리를 들이므로, 더 키우려면 먼저 집을 지어야 한다. + 집은 칸을 차지하고 거품을 벌지 않는다. 자리와 **아름다움**을 준다. +3. 생물을 들이면 초당 생산량과 아름다움이 오른다. 비싼 생물일수록 한 마리가 더 번다. 4. 아름다움은 다시 전체 생산량에 `1 + sqrt(아름다움) × 0.02` 배율로 되돌아온다. → **꾸미는 것이 곧 성장이다.** 5. 앞 구역을 충분히 꾸미고 거품을 모으면 오른쪽 다음 구역이 열린다. @@ -57,22 +69,42 @@ tests/smoke.tscn/.gd 경제 로직 스모크 테스트 ## 격자와 배치 바다는 **3/4 부감 격자**다. 구역당 9열 × 6행, 전체 45열 × 6행 = 270칸. -바닥은 위에서 내려다보지만 그 위에 놓인 것은 **서 있는 모습**으로 그린다. +바닥은 위에서 내려다보지만 그 위에 선 것은 **서 있는 모습**으로 그린다. 러스티의 농장과 같은 방식이고, 완전한 탑다운과는 다르다. -산 것은 모두 플레이어가 고른 칸에 놓이고, **칸이 곧 한계다.** 거품이 아무리 많아도 -자리가 없으면 더 놓을 수 없다. 그래서 성장은 세 갈래로 갈린다. -1. 다음 구역을 열어 자리를 늘린다 -2. 싼 것을 치우고 비싼 것으로 갈아 끼운다 (치우면 절반을 돌려받는다) -3. 칸을 차지하지 않는 업그레이드를 산다 +격자 칸을 차지하는 것은 **집뿐이다.** 생물은 칸이 아니라 **집에 산다.** +한 채가 3마리를 들이므로, 한 구역에서 키울 수 있는 수는 그 구역에 지은 집 × 3이다. +그래서 한계가 두 겹으로 걸린다. -장식은 여러 칸을 차지한다. 난파선·잠수정·고래 뼈는 3×2, 석주·석상·굴뚝·수정은 2×2, -항아리와 닻은 2×1. 자리를 많이 먹는 만큼 아름다움이 크다. +``` +칸 → 집을 몇 채 지을 수 있는가 +집 → 생물을 몇 마리 키울 수 있는가 +생물 → 거품을 얼마나 버는가 +``` -**배치 방법**: 상점에서 고르면 상점이 잠시 닫히고 배치 모드가 된다. -놓을 칸을 클릭하면 그때 값이 지불된다. 우클릭이나 Esc로 취소. -이미 놓인 것은 끌어서 옮길 수 있고(공짜), 끄는 동안 아래에 나타나는 -휴지통에 떨구면 치우고 절반을 돌려받는다. +거품이 아무리 많아도 자리가 없으면 생물을 더 들일 수 없다. 성장은 세 갈래다. + +1. 집을 더 지어 자리를 늘린다 (칸을 먹는다) +2. 싼 생물을 내보내고 비싼 생물로 갈아 끼운다 (내보내면 절반을 돌려받는다) +3. 다음 구역을 열어 칸 자체를 늘린다 + +집은 여러 칸을 차지한다. 난파선·잠수정·고래 뼈는 3×2, 석주·석상·굴뚝·수정은 2×2, +항아리와 닻은 2×1, 유리병 편지는 1×1이다. **정원은 어느 집이든 3으로 같으므로**, +넓은 집은 아름다움이 크고 좁은 집은 칸을 아낀다. 그 사이에서 고르는 것이 배치의 고민이다. + +**집 짓는 법**: 상점 집 탭에서 고르면 상점이 잠시 닫히고 배치 모드가 된다. +지을 칸을 클릭하면 그때 값이 지불된다. 우클릭이나 Esc로 취소. +이미 지은 집은 끌어서 옮길 수 있고(공짜), 끄는 동안 아래에 나타나는 +휴지통에 떨구면 헐고 절반을 돌려받는다. 단 살고 있는 생물이 남는 집에 다 들어가야 한다. + +**생물 들이는 법**: 상점 생물 탭에서 클릭하면 끝이다. 자리를 고를 필요가 없고, +그 구역의 집에 알아서 배정된다. 내보낼 때만 끌어서 휴지통에 넣는다. + +**물고기는 집을 맴돈다.** 헤엄치는 생물(물고기·해파리)은 제 집을 중심으로 타원을 그리며 +돌고, 뒤로 돌아갈 때는 집에 가려 들어간 것처럼 보였다가 앞으로 다시 나온다. +해초나 거품돌처럼 바닥에 붙는 생물은 집 둘레 제자리에 선다. +집(`vessel`·`wreck`·`pillar`)과 생물(`plant`·`rock`·`fish`·`glow`)은 그림 형태가 아예 +갈라져 있어서, 어느 것이 건물이고 어느 것이 사는 것인지 한눈에 읽힌다. ## 구역 @@ -85,12 +117,13 @@ tests/smoke.tscn/.gd 경제 로직 스모크 테스트 1. **바로 앞 구역의 아름다움** — 앞바다를 대충 두고 다음으로 넘어갈 수 없다. 2. **해금 비용(거품)** -생물과 장식은 각자 자기 구역에 속한다. 그 구역이 열려야 상점에 나타나고, -그 구역 밖으로는 놓을 수 없다(구역 경계를 걸치는 것도 안 된다). +생물과 집은 각자 자기 구역에 속한다. 그 구역이 열려야 상점에 나타나고, +집은 그 구역 밖에 지을 수 없다(구역 경계를 걸치는 것도 안 된다). +생물도 자기 구역의 집에만 들어가므로, 얕은 바다에 빈 자리가 남아도 +산호초 물고기를 거기 들일 수는 없다. 모래색과 물빛은 구역 중심 색을 기준으로 이어 붙여서 경계가 보이지 않게 그린다. 자세한 수치와 앞으로 붙일 것들은 [docs/DESIGN.md](docs/DESIGN.md) 참고. - ## 저장 위치 `%APPDATA%\Godot\app_userdata\Blub Blub\save.json` (한 세대 전 백업은 `save.bak.json`) diff --git a/autoload/catalog.gd b/autoload/catalog.gd index c0ba70c..714cda1 100644 --- a/autoload/catalog.gd +++ b/autoload/catalog.gd @@ -44,11 +44,13 @@ const ZONES := [ }, ] -## 생물 — 한 칸을 차지한다. 칸이 곧 한계이므로 무한히 살 수는 없다. -## zone : 어느 구역에 사는지. 그 구역이 해금되어야 살 수 있고, 그 구역 안에만 놓을 수 있다. -## bps : 개당 초당 거품 생산량 -## shape : plant / rock(바닥에 고정) / fish / glow(제 자리 위를 떠다님) -## height: 칸 높이의 몇 배로 서 있는지. 1보다 크면 뒷줄을 가린다. +## 생물 — 일꾼이다. 거품을 버는 것은 오직 이들이고, 격자 칸은 차지하지 않는다. +## 대신 집에 산다. 한 구역에서 키울 수 있는 수는 그 구역에 지은 집의 정원 합이다. +## zone : 어느 구역에 사는지. 그 구역이 해금되고, 그 구역에 빈 자리가 있어야 살 수 있다. +## bps : 한 마리당 초당 거품 생산량 +## shape : plant / rock — 집 둘레 제자리에 붙어 선다 +## fish / glow — 집 둘레를 돌며 뒤로 돌아 들어갔다 나온다 +## height: 칸 높이의 몇 배로 서 있는지 const PRODUCERS := [ # --- 얕은 바다 --- { @@ -56,6 +58,11 @@ const PRODUCERS := [ "desc": "바닥에서 조용히 거품을 뿜는 돌.", "color": Color(0.62, 0.68, 0.72), "base_cost": 15.0, "bps": 0.2, "beauty": 1.0, }, + { + "id": "guppy", "name": "구피", "zone": 0, "shape": "fish", "height": 0.8, + "desc": "집 둘레를 쉬지 않고 맴돈다.", + "color": Color(0.98, 0.72, 0.32), "base_cost": 40.0, "bps": 0.5, "beauty": 2.0, + }, { "id": "seaweed", "name": "해초", "zone": 0, "shape": "plant", "height": 0.95, "desc": "물살에 흔들리는 초록 잎.", @@ -107,94 +114,121 @@ const PRODUCERS := [ }, ] -## 장식 — 구역당 두 개, 한 번만 살 수 있다. 여러 칸을 차지하는 대신 아름다움이 크다. -## size: 바닥에서 차지하는 칸 수 (열, 행) -const ORNAMENTS := [ +## 집 — 생물이 사는 곳. 격자 칸을 차지하는 것은 이쪽뿐이다. +## 한 채당 HOUSE_CAPACITY마리를 들이고, 같은 종류라도 몇 채든 지을 수 있다. +## 지을수록 값이 오르는 것은 생물과 같다(COST_GROWTH). +## 집 자체는 거품을 벌지 않는다. 자리와 아름다움을 준다. +## size : 바닥에서 차지하는 칸 수 (열, 행). 넓은 집일수록 아름다움이 크다. +## shape: vessel(항아리·병) / wreck(난파선·뼈대) / pillar(기둥·석상). +## 집은 이 셋만 쓴다. 생물의 형태(plant/rock/fish/glow)와 섞지 않아야 +## 한눈에 "건물"과 "사는 것"이 갈린다. +const HOUSES := [ # --- 얕은 바다 --- { - "id": "amphora", "name": "침몰한 항아리", "zone": 0, "shape": "rock", - "size": Vector2i(2, 1), "height": 0.9, - "desc": "누군가의 항해가 남긴 흔적.", - "color": Color(0.72, 0.55, 0.38), "cost": 800.0, "beauty": 25.0, "mult": 0.02, + "id": "amphora", "name": "침몰한 항아리", "zone": 0, "shape": "vessel", + "size": Vector2i(2, 1), "height": 1.2, + "desc": "누군가의 항해가 남긴 흔적. 작은 것들이 안에 깃든다.", + "color": Color(0.72, 0.55, 0.38), "base_cost": 90.0, "beauty": 12.0, }, { - "id": "bottle_letter", "name": "유리병 편지", "zone": 0, "shape": "glow", + "id": "bottle_letter", "name": "유리병 편지", "zone": 0, "shape": "vessel", "size": Vector2i(1, 1), "height": 0.85, - "desc": "아직 아무도 읽지 못한 문장.", - "color": Color(0.78, 0.92, 0.72), "cost": 4000.0, "beauty": 45.0, "mult": 0.02, + "desc": "한 칸만 쓰고도 세 자리를 낸다. 좁은 바다의 해답.", + "color": Color(0.78, 0.92, 0.72), "base_cost": 1200.0, "beauty": 20.0, }, # --- 산호초 --- { - "id": "anchor", "name": "낡은 닻", "zone": 1, "shape": "rock", + "id": "anchor", "name": "낡은 닻", "zone": 1, "shape": "wreck", "size": Vector2i(2, 1), "height": 1.15, - "desc": "이끼가 낀 무거운 쇠.", - "color": Color(0.48, 0.52, 0.56), "cost": 40000.0, "beauty": 90.0, "mult": 0.03, + "desc": "이끼가 낀 무거운 쇠. 물고기들이 그늘에 모인다.", + "color": Color(0.48, 0.52, 0.56), "base_cost": 9000.0, "beauty": 40.0, }, { - "id": "shipwreck", "name": "난파선", "zone": 1, "shape": "rock", + "id": "shipwreck", "name": "난파선", "zone": 1, "shape": "wreck", "size": Vector2i(3, 2), "height": 1.65, - "desc": "물고기들의 새 보금자리.", - "color": Color(0.42, 0.32, 0.24), "cost": 500000.0, "beauty": 200.0, "mult": 0.05, + "desc": "자리를 많이 먹지만 그만큼 바다가 화사해진다.", + "color": Color(0.42, 0.32, 0.24), "base_cost": 60000.0, "beauty": 110.0, }, # --- 켈프 숲 --- { - "id": "submarine", "name": "침몰한 잠수정", "zone": 2, "shape": "rock", + "id": "submarine", "name": "침몰한 잠수정", "zone": 2, "shape": "wreck", "size": Vector2i(3, 2), "height": 1.7, - "desc": "돌아오지 못한 탐사선.", - "color": Color(0.72, 0.66, 0.32), "cost": 6000000.0, "beauty": 450.0, "mult": 0.06, + "desc": "돌아오지 못한 탐사선이 보금자리가 되었다.", + "color": Color(0.72, 0.66, 0.32), "base_cost": 900000.0, "beauty": 240.0, }, { - "id": "stone_pillar", "name": "이끼 낀 석주", "zone": 2, "shape": "rock", + "id": "stone_pillar", "name": "이끼 낀 석주", "zone": 2, "shape": "pillar", "size": Vector2i(2, 2), "height": 1.95, "desc": "바다가 삼킨 어느 신전의 기둥.", - "color": Color(0.52, 0.60, 0.44), "cost": 80000000.0, "beauty": 900.0, "mult": 0.08, + "color": Color(0.52, 0.60, 0.44), "base_cost": 6000000.0, "beauty": 480.0, }, # --- 심해 --- { - "id": "whale_fall", "name": "고래 뼈", "zone": 3, "shape": "rock", + "id": "whale_fall", "name": "고래 뼈", "zone": 3, "shape": "wreck", "size": Vector2i(3, 2), "height": 1.5, "desc": "가라앉은 고래 한 마리가 수십 년을 먹여 살린다.", - "color": Color(0.88, 0.88, 0.84), "cost": 1.2e9, "beauty": 1800.0, "mult": 0.10, + "color": Color(0.88, 0.88, 0.84), "base_cost": 150000000.0, "beauty": 950.0, }, { - "id": "moai", "name": "해저 석상", "zone": 3, "shape": "rock", + "id": "moai", "name": "해저 석상", "zone": 3, "shape": "pillar", "size": Vector2i(2, 2), "height": 1.85, - "desc": "말없이 구역을 지켜본다.", - "color": Color(0.55, 0.54, 0.50), "cost": 1.6e10, "beauty": 3500.0, "mult": 0.12, + "desc": "말없이 세입자들을 지켜본다.", + "color": Color(0.55, 0.54, 0.50), "base_cost": 1.2e9, "beauty": 1900.0, }, # --- 열수구 --- { - "id": "vent_chimney", "name": "열수 굴뚝", "zone": 4, "shape": "rock", + "id": "vent_chimney", "name": "열수 굴뚝", "zone": 4, "shape": "pillar", "size": Vector2i(2, 2), "height": 1.95, - "desc": "검은 연기를 끝없이 뿜는다.", - "color": Color(0.30, 0.22, 0.24), "cost": 3e11, "beauty": 7000.0, "mult": 0.15, + "desc": "검은 연기를 끝없이 뿜는다. 그 열기에 기대어 산다.", + "color": Color(0.30, 0.22, 0.24), "base_cost": 4e10, "beauty": 3800.0, }, { - "id": "crystal", "name": "심해 수정", "zone": 4, "shape": "glow", + "id": "crystal", "name": "심해 수정", "zone": 4, "shape": "pillar", "size": Vector2i(2, 2), "height": 1.55, "desc": "빛이 닿지 않는 곳에서 스스로 빛난다.", - "color": Color(0.55, 0.90, 0.95), "cost": 6e12, "beauty": 15000.0, "mult": 0.20, + "color": Color(0.55, 0.90, 0.95), "base_cost": 5e11, "beauty": 8000.0, }, ] ## 업그레이드 — 칸을 차지하지 않고 전체에 적용된다. 한 번만 살 수 있다. -## kind: click_add(클릭당 가산) / click_mult(클릭 배율) / bps_mult(생산 배율) +## 거품은 오직 생물이 벌기 때문에 업그레이드도 전부 생산 배율(bps_mult)이다. const UPGRADES := [ - {"id": "u_touch1", "name": "부드러운 손짓", "kind": "click_add", "value": 1.0, - "cost": 100.0, "desc": "클릭당 거품 +1"}, - {"id": "u_touch2", "name": "물살 가르기", "kind": "click_add", "value": 8.0, - "cost": 5000.0, "desc": "클릭당 거품 +8"}, - {"id": "u_touch3", "name": "소용돌이 손", "kind": "click_mult", "value": 3.0, - "cost": 250000.0, "desc": "클릭 효율 3배"}, - {"id": "u_flow1", "name": "잔잔한 해류", "kind": "bps_mult", "value": 1.5, + {"id": "u_ripple", "name": "잔물결", "kind": "bps_mult", "value": 1.25, + "cost": 100.0, "desc": "전체 생산량 1.25배"}, + {"id": "u_plankton", "name": "플랑크톤 뿌리기", "kind": "bps_mult", "value": 1.5, "cost": 2000.0, "desc": "전체 생산량 1.5배"}, - {"id": "u_flow2", "name": "따뜻한 조류", "kind": "bps_mult", "value": 2.0, - "cost": 400000.0, "desc": "전체 생산량 2배"}, - {"id": "u_flow3", "name": "달의 인력", "kind": "bps_mult", "value": 3.0, - "cost": 90000000.0, "desc": "전체 생산량 3배"}, + {"id": "u_current1", "name": "잔잔한 해류", "kind": "bps_mult", "value": 1.35, + "cost": 5000.0, "desc": "전체 생산량 1.35배"}, + {"id": "u_silt", "name": "영양 갯벌", "kind": "bps_mult", "value": 1.8, + "cost": 250000.0, "desc": "전체 생산량 1.8배"}, + {"id": "u_current2", "name": "따뜻한 조류", "kind": "bps_mult", "value": 1.5, + "cost": 400000.0, "desc": "전체 생산량 1.5배"}, + {"id": "u_moon", "name": "달의 인력", "kind": "bps_mult", "value": 2.5, + "cost": 90000000.0, "desc": "전체 생산량 2.5배"}, ] -## 생물 가격 성장률. 칸이 한정되어 있어 한 종류를 30개쯤 놓으면 66배가 된다. + +## 집 한 채가 들이는 생물 수. 이것이 성장의 진짜 한계다. +## 거품이 아무리 많아도 자리가 없으면 생물을 더 들일 수 없고, +## 자리를 늘리려면 집을 짓거나(칸을 먹는다) 다음 구역을 열어야 한다. +const HOUSE_CAPACITY := 3 + +## 새로 시작할 때 이미 지어져 있는 집. 집이 없으면 생물을 한 마리도 들일 수 없다. +const STARTER_HOUSES := [ + {"id": "amphora", "col": 3, "row": 3}, +] + +## 새로 시작할 때 이미 살고 있는 생물. id -> 마리 수. +## 거품은 생물만 벌기 때문에 최소 한 마리는 처음부터 있어야 한다. +## 바닥에 붙는 것(거품돌)과 헤엄치는 것(구피)을 하나씩 두어, +## 켜자마자 "제자리에 서는 것"과 "집을 도는 것"이 둘 다 보이게 했다. +const STARTER_FISH := {"bubble_stone": 1, "guppy": 1} + +## 시작 거품. 두 번째 거품돌 값(ceil(15 × 1.15) = 18)보다 조금 넉넉하게 두어, +## 시작하자마자 남은 한 자리를 직접 채워보며 구조를 익히게 한다. +const STARTER_BUBBLES := 20.0 + +## 생물과 집 모두 살수록(지을수록) 값이 오르는 비율. const COST_GROWTH := 1.15 ## 아름다움 → 생산 배율 곡선. 제곱근이라 초반엔 크게, 후반엔 완만하게 오른다. @@ -204,15 +238,14 @@ const BEAUTY_MULT_SCALE := 0.02 const REFUND_RATE := 0.5 var _producers_by_id := {} -var _ornaments_by_id := {} +var _houses_by_id := {} var _upgrades_by_id := {} - func _ready() -> void: for d in PRODUCERS: _producers_by_id[d.id] = d - for d in ORNAMENTS: - _ornaments_by_id[d.id] = d + for d in HOUSES: + _houses_by_id[d.id] = d for d in UPGRADES: _upgrades_by_id[d.id] = d @@ -241,8 +274,8 @@ func producer(id: String) -> Dictionary: return _producers_by_id.get(id, {}) -func ornament(id: String) -> Dictionary: - return _ornaments_by_id.get(id, {}) +func house(id: String) -> Dictionary: + return _houses_by_id.get(id, {}) func upgrade(id: String) -> Dictionary: @@ -252,7 +285,7 @@ func upgrade(id: String) -> Dictionary: func item(kind: String, id: String) -> Dictionary: match kind: "producer": return producer(id) - "ornament": return ornament(id) + "house": return house(id) "upgrade": return upgrade(id) return {} @@ -260,11 +293,16 @@ func item(kind: String, id: String) -> Dictionary: func items_of(kind: String) -> Array: match kind: "producer": return PRODUCERS - "ornament": return ORNAMENTS + "house": return HOUSES "upgrade": return UPGRADES return [] +## 집 한 채가 들이는 생물 수. 집마다 다르게 두고 싶으면 dict에 capacity를 넣으면 된다. +func capacity_of(id: String) -> int: + return int(house(id).get("capacity", HOUSE_CAPACITY)) + + ## 업그레이드는 구역에 속하지 않으므로 항상 0번 구역으로 친다. func zone_of(kind: String, id: String) -> int: return int(item(kind, id).get("zone", 0)) diff --git a/autoload/game_state.gd b/autoload/game_state.gd index 1f76d13..b79aaf8 100644 --- a/autoload/game_state.gd +++ b/autoload/game_state.gd @@ -2,24 +2,32 @@ extends Node ## 게임의 진행 상태와 파생 수치를 들고 있는 단일 저장소. ## 저장/불러오기는 SaveManager가, 밸런스 수치는 Catalog가 담당한다. ## -## 바다는 탑다운 격자이고, 산 것은 모두 격자 위 특정 칸에 놓인다. -## 즉 "몇 개 샀는가"가 아니라 "어디에 무엇이 있는가"가 진짜 상태다. +## 두 가지가 서로 다른 규칙으로 움직인다. +## 집(house) — 격자 칸을 차지한다. "어디에 무엇을 지을까"가 진짜 상태다. +## 생물(producer) — 칸을 차지하지 않고 집에 산다. 몇 마리인지만 센다. +## +## 거품은 오직 생물이 시간에 따라 번다. 집은 거품을 벌지 않고 자리와 아름다움을 준다. +## 그래서 성장은 "집을 지어 자리를 늘리고 그 자리를 생물로 채운다"의 반복이다. +## +## 어느 생물이 어느 집에 사는지는 저장하지 않는다. 집은 정원만 주므로 어느 집에 +## 살든 결과가 같고, 화면에 그릴 때 구역마다 차례로 배정하면 그만이다. ## 구매·이동·업그레이드처럼 화면을 다시 그려야 하는 이산적인 변화. signal state_changed ## 새 구역이 열렸을 때. signal zone_unlocked(index: int) -## 배치가 통째로 바뀌어 다시 그려야 할 때(불러오기, 초기화, 구역 해금). +## 배치가 통째로 바뀌어 다시 그려야 할 때(불러오기, 초기화, 생물 증감, 집 철거). signal reloaded -## 새로 놓였을 때. 등장 연출에 쓴다. +## 집이 새로 지어졌을 때. 등장 연출에 쓴다. signal placed(index: int) # --- 저장되는 상태 --- var bubbles := 0.0 var total_bubbles := 0.0 -var clicks := 0 -## [{kind, id, col, row}, ...] — 놓인 순서대로. 인덱스가 곧 식별자다. -var placements: Array[Dictionary] = [] +## [{id, col, row}, ...] — 지은 순서대로. 인덱스가 곧 식별자다. +var houses: Array[Dictionary] = [] +## id -> 마리 수. 생물은 칸을 차지하지 않으므로 위치가 없다. +var fish := {} var upgrades := {} ## id -> true var unlocked_zones := 1 ## 왼쪽부터 몇 개 구역이 열려 있는지 (최소 1) var first_played_unix := 0 @@ -28,11 +36,13 @@ var first_played_unix := 0 var bps := 0.0 ## 초당 거품 생산량 (모든 보너스 반영) var beauty := 0.0 ## 총 아름다움 var zone_beauty: Array[float] = [] ## 구역별 아름다움 -var click_power := 1.0 ## 클릭 한 번당 거품 var beauty_mult := 1.0 +var fish_total := 0 ## 살고 있는 생물 수. 마지막 한 마리를 지키는 데 쓴다. -var _counts := {} ## id -> 놓인 개수 -var _occupancy := PackedInt32Array() ## 칸 -> placements 인덱스, 빈 칸은 -1 +var _house_counts := {} ## id -> 지은 채수 +var _zone_capacity: Array[int] = [] ## 구역별 정원 합 +var _zone_fish: Array[int] = [] ## 구역별 살고 있는 수 +var _occupancy := PackedInt32Array() ## 칸 -> houses 인덱스, 빈 칸은 -1 func _ready() -> void: @@ -50,14 +60,7 @@ func _earn(amount: float) -> void: total_bubbles += amount -## 물을 클릭했을 때. 실제로 얻은 양을 돌려준다(연출용). -func click() -> float: - clicks += 1 - _earn(click_power) - return click_power - - -# --- 격자 --- +# --- 격자 (집만 쓴다) --- func columns() -> int: return Catalog.total_columns() @@ -75,16 +78,16 @@ func _cell(col: int, row: int) -> int: return row * columns() + col -## 그 칸에 놓인 것의 placements 인덱스. 비었으면 -1. -func placement_at(col: int, row: int) -> int: +## 그 칸에 선 집의 houses 인덱스. 비었으면 -1. +func house_at(col: int, row: int) -> int: if not in_grid(col, row): return -1 return _occupancy[_cell(col, row)] -## 아이템이 (col,row)에 놓였을 때 덮는 칸들. 격자를 벗어나면 빈 배열. -func footprint(kind: String, id: String, col: int, row: int) -> Array[Vector2i]: - var s := Catalog.size_of(kind, id) +## 집이 (col,row)에 섰을 때 덮는 칸들. 격자를 벗어나면 빈 배열. +func footprint(id: String, col: int, row: int) -> Array[Vector2i]: + var s := Catalog.size_of("house", id) var cells: Array[Vector2i] = [] for dx in s.x: for dy in s.y: @@ -94,78 +97,121 @@ func footprint(kind: String, id: String, col: int, row: int) -> Array[Vector2i]: return cells -## 여기에 놓을 수 있는지. ignore_index는 자기 자신을 옮길 때 자기 칸을 비어 있다고 치기 위한 것. -func can_place_at(kind: String, id: String, col: int, row: int, ignore_index := -1) -> bool: - var item_zone := Catalog.zone_of(kind, id) +## 여기에 지을 수 있는지. ignore_index는 자기 집을 옮길 때 제 칸을 비었다고 치기 위한 것. +func can_build_at(id: String, col: int, row: int, ignore_index := -1) -> bool: + var item_zone := Catalog.zone_of("house", id) if not is_zone_unlocked(item_zone): return false - var cells := footprint(kind, id, col, row) + var cells := footprint(id, col, row) if cells.is_empty(): return false for c in cells: - # 아이템은 자기 구역을 벗어날 수 없다. 구역 경계를 걸치는 것도 안 된다. + # 집은 자기 구역을 벗어날 수 없다. 구역 경계를 걸치는 것도 안 된다. if Catalog.zone_of_column(c.x) != item_zone: return false - var occupant := placement_at(c.x, c.y) + var occupant := house_at(c.x, c.y) if occupant != -1 and occupant != ignore_index: return false return true -## 그 구역에 남은 빈 칸 수. 상점에서 "자리 없음"을 알려주는 데 쓴다. +## 그 구역에 남은 빈 칸 수. 집을 더 지을 수 있는지 알려준다. func free_cells_in_zone(zone_index: int) -> int: var free := 0 for col in range(zone_index * Catalog.ZONE_COLUMNS, (zone_index + 1) * Catalog.ZONE_COLUMNS): for row in rows(): - if placement_at(col, row) == -1: + if house_at(col, row) == -1: free += 1 return free -## 그 아이템을 놓을 수 있는 첫 칸. 없으면 null 대신 Vector2i(-1, -1). -func first_free_spot(kind: String, id: String) -> Vector2i: - var z := Catalog.zone_of(kind, id) +## 그 집을 지을 수 있는 첫 칸. 없으면 Vector2i(-1, -1). +## 아래 줄부터 훑는다. 게임이 알아서 세우는 집은 앞줄(화면 아래쪽)에 서야 눈에 띄고, +## 뒷줄은 플레이어가 직접 고르도록 남겨두는 편이 낫다. +func first_free_spot(id: String) -> Vector2i: + var z := Catalog.zone_of("house", id) for col in range(z * Catalog.ZONE_COLUMNS, (z + 1) * Catalog.ZONE_COLUMNS): - for row in rows(): - if can_place_at(kind, id, col, row): + for row in range(rows() - 1, -1, -1): + if can_build_at(id, col, row): return Vector2i(col, row) return Vector2i(-1, -1) +# --- 정원 (생물이 실제로 부딪히는 한계) --- + +func capacity_in_zone(zone_index: int) -> int: + if zone_index < 0 or zone_index >= _zone_capacity.size(): + return 0 + return _zone_capacity[zone_index] + + +func fish_in_zone(zone_index: int) -> int: + if zone_index < 0 or zone_index >= _zone_fish.size(): + return 0 + return _zone_fish[zone_index] + + +## 그 구역에 남은 자리. 옛 저장본을 옮겨오면 음수가 될 수 있다(정원을 넘겨 살고 있다). +func free_slots_in_zone(zone_index: int) -> int: + return capacity_in_zone(zone_index) - fish_in_zone(zone_index) + + +func capacity_total() -> int: + var total := 0 + for c in _zone_capacity: + total += c + return total + + # --- 조회 --- -func count_of(id: String) -> int: - return _counts.get(id, 0) +func house_count(id: String) -> int: + return int(_house_counts.get(id, 0)) -func has_ornament(id: String) -> bool: - return count_of(id) > 0 +func fish_count(id: String) -> int: + return int(fish.get(id, 0)) + + +func count_of(kind: String, id: String) -> int: + match kind: + "producer": return fish_count(id) + "house": return house_count(id) + "upgrade": return 1 if has_upgrade(id) else 0 + return 0 func has_upgrade(id: String) -> bool: return upgrades.has(id) -func producer_cost(id: String) -> float: - var d := Catalog.producer(id) +## 생물과 집은 늘어날수록 값이 오른다. 업그레이드는 한 번뿐이라 정가다. +func cost_of(kind: String, id: String) -> float: + var d := Catalog.item(kind, id) if d.is_empty(): return INF - return ceil(float(d.base_cost) * pow(Catalog.COST_GROWTH, count_of(id))) + if kind == "upgrade": + return float(d.get("cost", INF)) + return _grown_cost(float(d.get("base_cost", INF)), count_of(kind, id)) -func cost_of(kind: String, id: String) -> float: - match kind: - "producer": return producer_cost(id) - "ornament": return Catalog.ornament(id).get("cost", INF) - "upgrade": return Catalog.upgrade(id).get("cost", INF) - return INF +func _grown_cost(base: float, owned_count: int) -> float: + if not is_finite(base): + return INF + return ceil(base * pow(Catalog.COST_GROWTH, owned_count)) +## 치울 때 기준이 되는 값 — 지금 값이 아니라 마지막으로 산 그 하나의 값이다. +func _last_paid(kind: String, id: String) -> float: + var d := Catalog.item(kind, id) + if d.is_empty(): + return 0.0 + return _grown_cost(float(d.get("base_cost", 0.0)), maxi(count_of(kind, id) - 1, 0)) + + +## 한 번만 살 수 있는 것(업그레이드)을 이미 가졌는지. func owned(kind: String, id: String) -> bool: - match kind: - "ornament": return has_ornament(id) - "upgrade": return has_upgrade(id) - return false + return kind == "upgrade" and has_upgrade(id) func is_zone_unlocked(index: int) -> bool: @@ -173,7 +219,7 @@ func is_zone_unlocked(index: int) -> bool: ## 상점에 노출할지 여부. -## 아직 열리지 않은 구역의 물건은 숨기고, 열린 구역 안에서는 +## 아직 열리지 않은 구역의 것은 숨기고, 열린 구역 안에서는 ## 가격의 절반까지 벌어본 적이 있으면 보여준다(점진 공개). func is_revealed(kind: String, id: String) -> bool: if not is_zone_unlocked(Catalog.zone_of(kind, id)): @@ -181,60 +227,127 @@ func is_revealed(kind: String, id: String) -> bool: # 구역을 막 열었을 때 목록이 비어 보이지 않도록 그 구역의 첫 항목은 항상 보인다. if id == Catalog.first_id_of_zone(kind, Catalog.zone_of(kind, id)): return true - if kind == "producer" and count_of(id) > 0: - return true - if owned(kind, id): + if count_of(kind, id) > 0: return true return total_bubbles >= cost_of(kind, id) * 0.5 -# --- 배치 --- +# --- 집 짓기 --- -## 사서 그 칸에 놓는다. 성공하면 placements 인덱스, 실패하면 -1. -func place(kind: String, id: String, col: int, row: int) -> int: - if kind == "ornament" and owned(kind, id): +## 사서 그 칸에 짓는다. 성공하면 houses 인덱스, 실패하면 -1. +func build_house(id: String, col: int, row: int) -> int: + if Catalog.house(id).is_empty(): return -1 - if not can_place_at(kind, id, col, row): + if not can_build_at(id, col, row): return -1 - var cost := cost_of(kind, id) + var cost := cost_of("house", id) if not is_finite(cost) or bubbles < cost: return -1 bubbles -= cost - placements.append({"kind": kind, "id": id, "col": col, "row": row}) + houses.append({"id": id, "col": col, "row": row}) recalc() - placed.emit(placements.size() - 1) + placed.emit(houses.size() - 1) state_changed.emit() - return placements.size() - 1 + return houses.size() - 1 -## 이미 놓인 것을 다른 칸으로 옮긴다. 공짜다. -func move(index: int, col: int, row: int) -> bool: - if index < 0 or index >= placements.size(): +## 이미 지은 집을 다른 칸으로 옮긴다. 공짜다. 살던 생물도 같이 따라간다. +func move_house(index: int, col: int, row: int) -> bool: + if index < 0 or index >= houses.size(): return false - var p := placements[index] - if not can_place_at(p.kind, p.id, col, row, index): + var h := houses[index] + if not can_build_at(String(h.id), col, row, index): return false - p.col = col - p.row = row + h.col = col + h.row = row recalc() state_changed.emit() return true -## 치우고 절반을 돌려받는다. 돌려받은 양을 반환한다. -func remove(index: int) -> float: - if index < 0 or index >= placements.size(): - return 0.0 - var p := placements[index] - # 생물은 지금 가격이 아니라 "마지막으로 산 그 한 마리" 값을 기준으로 돌려준다. - var value := cost_of(p.kind, p.id) - if p.kind == "producer": - var d := Catalog.producer(p.id) - value = ceil(float(d.base_cost) * pow(Catalog.COST_GROWTH, maxi(count_of(p.id) - 1, 0))) - var refund: float = floor(value * Catalog.REFUND_RATE) +## 헐 수 있는지. 살고 있는 생물이 남는 집에 다 들어가야 한다. +func can_remove_house(index: int) -> bool: + if index < 0 or index >= houses.size(): + return false + var id := String(houses[index].id) + var z := Catalog.zone_of("house", id) + return fish_in_zone(z) <= capacity_in_zone(z) - Catalog.capacity_of(id) - placements.remove_at(index) + +## 헐고 절반을 돌려받는다. 돌려받은 양을 반환한다. 헐 수 없으면 0. +func remove_house(index: int) -> float: + if not can_remove_house(index): + return 0.0 + var id := String(houses[index].id) + var refund: float = floor(_last_paid("house", id) * Catalog.REFUND_RATE) + + houses.remove_at(index) + bubbles += refund + recalc() + reloaded.emit() + state_changed.emit() + return refund + + +# --- 생물 들이기 --- + +## 그 생물을 들일 수 있는지. 구역이 열려 있고, 그 구역에 빈 자리가 있어야 한다. +func can_take_fish(id: String) -> bool: + var d := Catalog.producer(id) + if d.is_empty(): + return false + var z := int(d.get("zone", 0)) + return is_zone_unlocked(z) and free_slots_in_zone(z) > 0 + + +## 왜 못 들이는지. 상점과 알림에 그대로 쓴다. +func fish_blocker(id: String) -> String: + var d := Catalog.producer(id) + if d.is_empty(): + return "그런 생물은 없어요" + var z := int(d.get("zone", 0)) + if not is_zone_unlocked(z): + return "%s 아직 잠겨 있어요" % Korean.i(String(Catalog.zone(z).name)) + if free_slots_in_zone(z) <= 0: + return "%s에 빈 자리가 없어요 · 집을 더 지으세요" % Catalog.zone(z).name + if bubbles < cost_of("producer", id): + return "거품이 모자라요" + return "" + + +## 한 마리 들인다. +func buy_fish(id: String) -> bool: + if not can_take_fish(id): + return false + var cost := cost_of("producer", id) + if not is_finite(cost) or bubbles < cost: + return false + + bubbles -= cost + fish[id] = fish_count(id) + 1 + recalc() + reloaded.emit() + state_changed.emit() + return true + + +## 마지막 한 마리는 내보낼 수 없다. 생물이 0이 되면 거품이 영영 늘지 않기 때문이다. +func can_release_fish(id: String) -> bool: + return fish_count(id) > 0 and fish_total > 1 + + +## 한 마리 내보내고 절반을 돌려받는다. 돌려받은 양을 반환한다. +func release_fish(id: String) -> float: + if not can_release_fish(id): + return 0.0 + var refund: float = floor(_last_paid("producer", id) * Catalog.REFUND_RATE) + + var left := fish_count(id) - 1 + if left > 0: + fish[id] = left + else: + fish.erase(id) bubbles += refund recalc() reloaded.emit() @@ -321,42 +434,54 @@ func unlock_next_zone() -> bool: # --- 파생 값 재계산 --- func recalc() -> void: - _counts.clear() + var zones := Catalog.zone_count() + _house_counts.clear() _occupancy.resize(columns() * rows()) _occupancy.fill(-1) zone_beauty.clear() - zone_beauty.resize(Catalog.zone_count()) + zone_beauty.resize(zones) + _zone_capacity.clear() + _zone_capacity.resize(zones) + _zone_fish.clear() + _zone_fish.resize(zones) - var raw_bps := 0.0 - var ornament_mult := 1.0 - - for i in placements.size(): - var p := placements[i] - var d := Catalog.item(p.kind, p.id) + # 집 — 칸을 차지하고, 정원과 아름다움을 낸다. 거품은 벌지 않는다. + for i in houses.size(): + var id := String(houses[i].id) + var d := Catalog.house(id) if d.is_empty(): continue - _counts[p.id] = int(_counts.get(p.id, 0)) + 1 - for c in footprint(p.kind, p.id, p.col, p.row): + _house_counts[id] = house_count(id) + 1 + for c in footprint(id, int(houses[i].col), int(houses[i].row)): _occupancy[_cell(c.x, c.y)] = i + var z := int(d.get("zone", 0)) + _add_beauty(z, float(d.beauty)) + _add_capacity(z, Catalog.capacity_of(id)) - _add_zone_beauty(int(d.get("zone", 0)), float(d.beauty)) - if p.kind == "producer": - raw_bps += float(d.bps) - else: - ornament_mult += float(d.mult) + # 생물 — 거품을 버는 것은 이쪽뿐이다. + fish_total = 0 + var raw_bps := 0.0 + for key in fish: + var id := String(key) + var d := Catalog.producer(id) + var n := fish_count(id) + if d.is_empty() or n <= 0: + continue + fish_total += n + raw_bps += float(d.bps) * n + var z := int(d.get("zone", 0)) + _add_beauty(z, float(d.beauty) * n) + _add_fish(z, n) + # 업그레이드는 전부 전체 생산 배율이다. 클릭으로 버는 길은 없다. var upgrade_bps_mult := 1.0 - var click_add := 1.0 - var click_mult := 1.0 - for id in upgrades: - var u := Catalog.upgrade(id) + for key in upgrades: + var u := Catalog.upgrade(String(key)) if u.is_empty(): continue - match u.kind: - "click_add": click_add += float(u.value) - "click_mult": click_mult *= float(u.value) - "bps_mult": upgrade_bps_mult *= float(u.value) + if String(u.kind) == "bps_mult": + upgrade_bps_mult *= float(u.value) beauty = 0.0 for b in zone_beauty: @@ -364,26 +489,35 @@ func recalc() -> void: # 꾸밀수록 생산이 빨라지도록. 제곱근이라 초반 보상이 크고 후반은 완만하다. beauty_mult = 1.0 + sqrt(beauty) * Catalog.BEAUTY_MULT_SCALE - bps = raw_bps * ornament_mult * upgrade_bps_mult * beauty_mult - click_power = click_add * click_mult * beauty_mult + bps = raw_bps * upgrade_bps_mult * beauty_mult -func _add_zone_beauty(index: int, amount: float) -> void: +func _add_beauty(index: int, amount: float) -> void: if index >= 0 and index < zone_beauty.size(): zone_beauty[index] += amount +func _add_capacity(index: int, amount: int) -> void: + if index >= 0 and index < _zone_capacity.size(): + _zone_capacity[index] += amount + + +func _add_fish(index: int, amount: int) -> void: + if index >= 0 and index < _zone_fish.size(): + _zone_fish[index] += amount + + # --- 직렬화 --- func to_dict() -> Dictionary: var rows_out: Array = [] - for p in placements: - rows_out.append({"kind": p.kind, "id": p.id, "col": p.col, "row": p.row}) + for h in houses: + rows_out.append({"id": h.id, "col": h.col, "row": h.row}) return { "bubbles": bubbles, "total_bubbles": total_bubbles, - "clicks": clicks, - "placements": rows_out, + "houses": rows_out, + "fish": fish.duplicate(), "upgrades": upgrades.duplicate(), "unlocked_zones": unlocked_zones, "first_played_unix": first_played_unix, @@ -393,7 +527,6 @@ func to_dict() -> Dictionary: func from_dict(d: Dictionary) -> void: bubbles = float(d.get("bubbles", 0.0)) total_bubbles = float(d.get("total_bubbles", bubbles)) - clicks = int(d.get("clicks", 0)) unlocked_zones = clampi(int(d.get("unlocked_zones", 1)), 1, Catalog.zone_count()) first_played_unix = int(d.get("first_played_unix", Time.get_unix_time_from_system())) @@ -401,57 +534,117 @@ func from_dict(d: Dictionary) -> void: for id in d.get("upgrades", {}): upgrades[id] = true - placements.clear() + houses.clear() + fish.clear() recalc() - if d.has("placements"): - for raw in d["placements"]: - _restore(String(raw.get("kind", "producer")), String(raw.get("id", "")), + + if d.has("houses") or d.has("fish"): + for raw in d.get("houses", []): + _restore_house(String(raw.get("id", "")), int(raw.get("col", 0)), int(raw.get("row", 0))) + for id in d.get("fish", {}): + _restore_fish(String(id), int(d["fish"][id])) else: - _migrate_from_counts(d) + _migrate_old_save(d) + + # 집이나 생물이 하나도 없으면 아무 일도 일어나지 않는다. 시작 상태를 채워준다. + if houses.is_empty(): + _grant_starter_houses() + if fish_total == 0: + _grant_starter_fish() + _grant_homes_for_overflow() recalc() reloaded.emit() state_changed.emit() -## 저장된 칸이 비어 있으면 그대로, 겹치거나 규칙에 어긋나면 빈 칸을 찾아 옮겨 놓는다. +## 저장된 칸이 비어 있으면 그대로, 겹치거나 규칙에 어긋나면 빈 칸을 찾아 옮겨 짓는다. ## 격자 크기나 구역 구성이 바뀌어도 진행도를 잃지 않기 위한 것. -func _restore(kind: String, id: String, col: int, row: int) -> void: - if Catalog.item(kind, id).is_empty(): +func _restore_house(id: String, col: int, row: int) -> void: + if Catalog.house(id).is_empty(): return - if not can_place_at(kind, id, col, row): - var spot := first_free_spot(kind, id) + if not can_build_at(id, col, row): + var spot := first_free_spot(id) if spot.x < 0: return col = spot.x row = spot.y - placements.append({"kind": kind, "id": id, "col": col, "row": row}) + houses.append({"id": id, "col": col, "row": row}) recalc() -## 격자가 없던 시절의 저장본(개수만 있음)을 빈 칸에 차례로 놓아 되살린다. -func _migrate_from_counts(d: Dictionary) -> void: +## 정원을 넘겨도 이미 살던 생물을 내쫓지는 않는다. 자리가 빌 때까지 더 못 들일 뿐이다. +func _restore_fish(id: String, count: int) -> void: + if Catalog.producer(id).is_empty() or count <= 0: + return + fish[id] = fish_count(id) + count + recalc() + + +## 생물도 칸을 차지하던 시절의 저장본을 옮겨온다. +## 그때의 장식(ornament)이 지금의 집이고, 생물은 위치를 버리고 마릿수만 남긴다. +func _migrate_old_save(d: Dictionary) -> void: + if d.has("placements"): + for raw in d["placements"]: + var kind := String(raw.get("kind", "producer")) + var id := String(raw.get("id", "")) + if kind == "ornament" or kind == "house": + _restore_house(id, int(raw.get("col", 0)), int(raw.get("row", 0))) + else: + _restore_fish(id, 1) + return + # 격자가 없던 더 옛날 저장본. 개수만 있다. for id in d.get("producers", {}): - for i in int(d["producers"][id]): - var spot := first_free_spot("producer", String(id)) - if spot.x < 0: - break - _restore("producer", String(id), spot.x, spot.y) + _restore_fish(String(id), int(d["producers"][id])) for id in d.get("ornaments", {}): - var spot := first_free_spot("ornament", String(id)) + var spot := first_free_spot(String(id)) if spot.x >= 0: - _restore("ornament", String(id), spot.x, spot.y) + _restore_house(String(id), spot.x, spot.y) -func reset() -> void: +## 처음부터 다시. with_starter를 끄면 집도 생물도 없는 빈 바다가 된다(테스트용). +func reset(with_starter := true) -> void: bubbles = 0.0 total_bubbles = 0.0 - clicks = 0 - placements.clear() + houses.clear() + fish.clear() upgrades.clear() unlocked_zones = 1 first_played_unix = int(Time.get_unix_time_from_system()) recalc() + if with_starter: + _grant_starter_houses() + _grant_starter_fish() + bubbles = Catalog.STARTER_BUBBLES + total_bubbles = Catalog.STARTER_BUBBLES + recalc() reloaded.emit() state_changed.emit() + + +## 시작 집을 값 없이 짓는다. 정해 둔 칸이 차 있으면 빈 칸을 찾는다. +func _grant_starter_houses() -> void: + for s in Catalog.STARTER_HOUSES: + _restore_house(String(s.id), int(s.col), int(s.row)) + + +## 시작 생물을 값 없이 들인다. +func _grant_starter_fish() -> void: + for id in Catalog.STARTER_FISH: + _restore_fish(String(id), int(Catalog.STARTER_FISH[id])) + + +## 옛 저장본에서 옮겨온 생물이 정원을 넘길 수 있다(그때는 생물이 칸을 직접 차지했다). +## 그러면 집을 공짜로 세워 모두 들어가게 해준다. 살 곳 없는 생물은 화면에도 +## 그려지지 않으므로, 보이지 않는 생물을 남기지 않으려는 것이다. +func _grant_homes_for_overflow() -> void: + for z in Catalog.zone_count(): + var id := Catalog.first_id_of_zone("house", z) + if id.is_empty(): + continue + while fish_in_zone(z) > capacity_in_zone(z): + var spot := first_free_spot(id) + if spot.x < 0: + break # 칸이 다 찼다. 더는 해줄 수 있는 게 없다. + _restore_house(id, spot.x, spot.y) diff --git a/autoload/save_manager.gd b/autoload/save_manager.gd index c81cdd7..d364283 100644 --- a/autoload/save_manager.gd +++ b/autoload/save_manager.gd @@ -16,13 +16,10 @@ const OFFLINE_RATE := 0.5 ## 이보다 짧게 비운 건 "오프라인"으로 안 친다(창을 잠깐 껐다 켠 경우). const OFFLINE_MIN_SECONDS := 60 -## 창 위치·표시 옵션. main.gd가 읽고 쓴다. -var window_state := { - "x": -1, - "y": -1, - "always_on_top": true, - "click_through": false, -} +## 창 표시 옵션. main.gd가 _remember_window()로 채우고 _ready()에서 읽는다. +## 기본값은 여기가 아니라 main.gd의 get(키, 기본값)에만 둔다. 두 군데 두면 어긋난다. +## 지금 들어오는 키: always_on_top / click_through / edge_bottom / height / screen +var window_state := {} ## 테스트처럼 실제 저장 파일을 건드리면 안 되는 상황에서 끈다. ## 끄지 않으면 스모크 테스트가 종료될 때 초기화된 상태를 저장해 진행도를 날린다. @@ -86,14 +83,17 @@ func load_game() -> void: if data.is_empty(): data = _read(BACKUP_PATH) if data.is_empty(): + # 저장본이 없다 = 이 컴퓨터에서 처음 켰다. 시작 집과 생물을 놓아준다. + # 이게 없으면 빈 바다에서 시작해 아무 일도 일어나지 않는다. + GameState.reset() return GameState.from_dict(data.get("state", {})) - var win: Dictionary = data.get("window", {}) - for key in window_state: - if win.has(key): - window_state[key] = win[key] + # 저장본에 있는 창 설정을 전부 실어 온다. 예전에는 window_state에 미리 + # 들어 있던 키만 복사해서, 높이·도킹·모니터 설정이 조용히 사라졌다. + for key in data.get("window", {}): + window_state[key] = data["window"][key] _grant_offline(int(data.get("saved_at", 0))) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 9ef5f19..4fa1a3e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -8,11 +8,65 @@ ## 핵심 설계 판단 -**칸이 곧 한계다.** 클리커 장르는 보통 한 종류를 무한히 사서 가격 곡선으로만 +**클릭으로 벌지 않는다.** 이건 클리커가 아니라 방치형 위젯이다. 거품은 오직 바다에 +놓인 생물이 시간에 따라 번다. 화면 가장자리에 깔아 두고 다른 일을 하는 물건인데 +클릭이 주된 수입원이면 위젯을 쳐다보며 연타하는 것이 최적 플레이가 되어, 형태와 어긋난다. +물을 만지면 거품이 퍼지지만 그건 연출일 뿐 얻는 것은 없다. + +여기서 두 가지가 따라온다. 첫째, **새로 시작하면 항아리 집 한 채와 거품돌·구피 한 마리씩이 +이미 있다**(`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 스트립에서는 격자가 @@ -37,7 +91,7 @@ **꾸미기와 성장을 하나로 묶었다.** 방치형 게임에서 꾸미기는 보통 "번 돈을 쓰는 곳"에 그치고, 그러면 꾸미기가 성장의 경쟁 상대가 되어 아무도 꾸미지 않는다. -그래서 모든 구매물이 **아름다움(beauty)** 수치를 가지고, 아름다움이 다시 전체 생산량에 +그래서 생물도 집도 모두 **아름다움(beauty)** 수치를 가지고, 아름다움이 다시 전체 생산량에 곱해지도록 했다. 꾸미는 것과 최적으로 플레이하는 것이 같은 방향을 가리킨다. ``` @@ -51,26 +105,32 @@ | 항목 | 값 | 위치 | | --- | --- | --- | -| 생물 가격 성장률 | ×1.15 (장르 표준) | `Catalog.COST_GROWTH` | +| 집 정원 | 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` | +| 구역 수 | 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` | -| 치울 때 환불 | 50% | `Catalog.REFUND_RATE` | -| 정보 바 자리 | 위쪽 30px는 격자에서 뺌 | `Aquarium.GRID_TOP` | +| 물체 높이 | 칸 높이의 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로 나눠 두었다. 한 구역을 70%쯤 채우면 -다음 구역 해금 비용이 대략 20~90분치 생산량이 되도록 잡아 두었지만, -칸 제한이 들어오면서 곡선이 크게 달라졌으므로 실제로 플레이해 보며 다시 맞춰야 한다. -현재 밸런스는 첫 몇 시간을 촘촘하게 만드는 데 맞춰져 있고, 실제로 플레이해 보며 -`Catalog`의 `base_cost` / `bps` / `beauty` 세 숫자만 조정하면 된다. +생물 10종·집 10종을 구역당 2+2로 나눠 두었다. 집 값은 "그 구역에서 가장 싼 생물의 +약 6배"라는 규칙으로 잡아, 집 한 채 → 생물 세 마리 → 다시 집 한 채로 리듬이 번갈아 +오도록 했다. 집이 정원을 쥐게 되면서 곡선이 통째로 달라졌으므로, 실제 수치는 +플레이해 보며 다시 맞춰야 한다. 조정할 것은 `Catalog`의 `base_cost` / `bps` / `beauty` +세 숫자와 `HOUSE_CAPACITY` 하나다. ## 데이터 추가하는 법 @@ -79,15 +139,37 @@ ```gdscript { - "id": "shrimp", "name": "청소새우", "shape": "fish", + "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, }, ``` -`shape`은 `plant` / `rock`(바닥 고정) / `fish` / `glow`(제자리 위를 떠다님) 중 하나이고, +집은 `HOUSES`에 넣는다. 생물과 같은 항목을 쓰되 `bps` 대신 `size`(바닥에서 차지하는 +칸, 기본 1×1)를 쓰고, 정원을 3에서 바꾸고 싶을 때만 `capacity`를 더한다. + +```gdscript +{ + "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`가 형태별로 서 있는 모습을 직접 그린다. -`size`는 바닥에서 차지하는 칸(기본 1×1), `height`는 칸 높이의 몇 배로 서는지다. +`height`는 칸 높이의 몇 배로 서는지다. +`zone`은 어느 구역에 속하는지다(0 = 얕은 바다). 그 구역이 열려야 상점에 나타나고, +집은 그 구역 밖에 지을 수 없다(구역 경계를 걸치는 것도 안 된다). +생물은 자기 구역의 집에만 들어간다. 물빛 오버레이가 덮이면 형태가 바닥에 묻히므로, 모든 면에 어두운 테두리를 두른다 (`_fill()`). 테두리를 빼면 무엇인지 읽히지 않는다. @@ -95,6 +177,21 @@ ## 아트 지금은 스프라이트 없이 `_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.gd`를 `Sprite2D` 기반으로 바꾸면 된다(카탈로그에 `texture` 항목 추가). @@ -115,17 +212,13 @@ ## 아직 안 만든 것 (우선순위 순) -1. **꾸미기 배치 자유도** — 지금은 산 물건이 자기 구역 안 랜덤 위치에 놓인다. - 드래그로 옮기고 위치를 저장하면 "내 바다"라는 느낌이 훨씬 강해진다. - `DecorSprite`에 위치를 저장하고 `GameState.producers`를 개수 대신 - 인스턴스 배열로 바꾸는 작업이 필요하다. -2. **두 번째 화폐(진주)** — 아름다움에 비례해 아주 천천히 쌓이고, - 특별한 장식만 살 수 있게. 거품 인플레이션과 분리된 장기 목표가 생긴다. -3. **환생/이사** — 더 큰 수조로 옮기면서 영구 배율을 얻는 장르 표준 루프. -4. **소리** — 물소리와 거품 소리. 위젯이라 기본은 음소거가 맞을 듯. -5. **트레이 아이콘** — Godot 기본 기능에 없어서 GDExtension이나 별도 런처가 필요하다. +1. **두 번째 화폐(진주)** — 아름다움에 비례해 아주 천천히 쌓이고, + 특별한 집만 지을 수 있게. 거품 인플레이션과 분리된 장기 목표가 생긴다. +2. **환생/이사** — 더 큰 수조로 옮기면서 영구 배율을 얻는 장르 표준 루프. +3. **소리** — 물소리와 거품 소리. 위젯이라 기본은 음소거가 맞을 듯. +4. **트레이 아이콘** — Godot 기본 기능에 없어서 GDExtension이나 별도 런처가 필요하다. 당장은 작업표시줄 창으로 충분. -6. **폭을 화면 일부만 쓰기** — 지금은 항상 폭 전체다. 왼쪽/가운데/오른쪽 정렬과 +5. **폭을 화면 일부만 쓰기** — 지금은 항상 폭 전체다. 왼쪽/가운데/오른쪽 정렬과 폭 비율 선택이 있으면 넓은 모니터에서 덜 부담스럽다. ## 한글 문장 diff --git a/scenes/aquarium/aquarium.gd b/scenes/aquarium/aquarium.gd index 4d39f3a..6878a35 100644 --- a/scenes/aquarium/aquarium.gd +++ b/scenes/aquarium/aquarium.gd @@ -2,14 +2,16 @@ extends Control class_name Aquarium ## 화면 하단에 깔리는 탑다운 바다. ## 가로를 구역 수만큼 나눠, 왼쪽(얕은 바다)에서 오른쪽(열수구)으로 갈수록 깊어진다. -## 구역마다 격자가 있고, 산 것은 플레이어가 고른 칸에 놓인다. +## +## 격자 칸을 차지하는 것은 집뿐이다. 생물은 칸이 아니라 집에 살기 때문에 +## 그릴 때마다 구역별로 집에 차례로 배정해서 그 집 둘레에 세운다. -## 알림을 띄워야 할 때 (해금 실패, 배치 실패, 환불 등). main이 받아 HUD로 넘긴다. +## 알림을 띄워야 할 때 (해금 실패, 건축 실패, 환불 등). main이 받아 HUD로 넘긴다. signal message(text: String) -## 빈 곳을 눌렀을 때. 거품 클릭 연출에 쓴다. +## 빈 곳을 눌렀을 때. 거품이 퍼지는 연출에만 쓴다 — 거품은 생물이 번다. signal clicked(local_position: Vector2) -## 배치 모드가 켜지거나 꺼졌을 때. 상점이 선택 상태를 표시하는 데 쓴다. -signal placement_changed(active: bool, kind: String, id: String) +## 집 짓기 모드가 켜지거나 꺼졌을 때. 상점이 선택 상태를 표시하는 데 쓴다. +signal build_mode_changed(active: bool, id: String) ## 이미 놓인 것을 집었거나 놓았을 때. HUD가 휴지통을 보였다 감추는 데 쓴다. signal drag_changed(dragging: bool) ## 물 위에서 우클릭. main이 메뉴를 연다. @@ -29,21 +31,33 @@ var trash_rect := Rect2() ## 위쪽 이만큼은 정보 바가 앉는 자리라 격자에서 뺀다. ## 여기를 비워두지 않으면 첫 줄 칸이 바에 가려 보이지도, 눌리지도 않는다. -const GRID_TOP := 30.0 +## 바 높이는 글꼴과 담긴 내용에 따라 달라지므로 main이 HUD에서 재어 넣어준다. +var grid_top := 30.0: + set(value): + if is_equal_approx(grid_top, value): + return + grid_top = value + if is_node_ready(): + _on_layout_changed() + const DRAG_THRESHOLD := 5.0 -## _press_index에 쓰는 표식. 이번 누름은 클릭이 아니라 드래그였다는 뜻. -const PRESS_WAS_DRAG := -2 +## 물고기가 집을 한 바퀴 도는 속도(라디안/초). 위젯이라 눈에 거슬리지 않게 느리다. +const ORBIT_SPEED := 0.5 var _rng := RandomNumberGenerator.new() var _lock_reasons := {} ## 구역 인덱스 -> 사유 Label var _base_material: ShaderMaterial -var _placing_kind := "" -var _placing_id := "" +## 지금 짓는 중인 집의 id. 비어 있으면 짓는 중이 아니다. +var _building_id := "" var _hover := Vector2i(-1, -1) var _press_position := Vector2.ZERO -var _press_index := -1 -var _drag_index := -1 +## 누르거나 끌고 있는 대상. {} 이면 없음. TileItem의 ref_* 를 그대로 담는다. +var _press_ref := {} +var _drag_ref := {} +var _drag_node: TileItem = null +## 이번 누름이 클릭이 아니라 드래그였다는 표식. +var _press_was_drag := false func _ready() -> void: @@ -72,7 +86,9 @@ func _process(_delta: float) -> void: ## 격자가 실제로 깔리는 영역. 위쪽 정보 바 자리는 뺀다. func grid_rect() -> Rect2: - return Rect2(Vector2(0.0, GRID_TOP), Vector2(size.x, maxf(size.y - GRID_TOP, 1.0))) + # 스트립이 낮을 때 바가 격자를 다 먹어버리지 않도록 위쪽 절반은 넘기지 않는다. + var top := clampf(grid_top, 0.0, size.y * 0.45) + return Rect2(Vector2(0.0, top), Vector2(size.x, maxf(size.y - top, 1.0))) func cell_size() -> Vector2: @@ -82,13 +98,14 @@ func cell_size() -> Vector2: func cell_at(pos: Vector2) -> Vector2i: var c := cell_size() - return Vector2i(int(floor(pos.x / c.x)), int(floor((pos.y - GRID_TOP) / c.y))) + var top := grid_rect().position.y + return Vector2i(int(floor(pos.x / c.x)), int(floor((pos.y - top) / c.y))) func cell_rect(col: int, row: int, span := Vector2i.ONE) -> Rect2: var c := cell_size() return Rect2( - Vector2(col * c.x, GRID_TOP + row * c.y), + Vector2(col * c.x, grid_rect().position.y + row * c.y), Vector2(c.x * span.x, c.y * span.y)) @@ -99,9 +116,9 @@ func zone_rect(index: int) -> Rect2: Vector2(Catalog.ZONE_COLUMNS * c.x, size.y)) -## 커서가 가리키는 칸을 기준으로 발자국을 가운데 맞춘 왼쪽 위 칸. -func _anchor_for(kind: String, id: String, hover: Vector2i) -> Vector2i: - var s := Catalog.size_of(kind, id) +## 커서가 가리키는 칸을 기준으로 집 발자국을 가운데 맞춘 왼쪽 위 칸. +func _anchor_for(id: String, hover: Vector2i) -> Vector2i: + var s := Catalog.size_of("house", id) return hover - Vector2i((s.x - 1) / 2, (s.y - 1) / 2) @@ -111,7 +128,7 @@ func _anchor_for(kind: String, id: String, hover: Vector2i) -> Vector2i: ## 스트립은 자주 바뀌지 않으므로 부분 갱신보다 통째로 다시 만드는 쪽이 단순하다. func _on_layout_changed() -> void: ground.rect_size = size - ground.grid_top = GRID_TOP + ground.grid_top = grid_rect().position.y ground.queue_redraw() _build_water() @@ -259,7 +276,9 @@ func _on_zone_unlocked(index: int) -> void: _on_layout_changed() -# --- 놓인 것들 --- + + +# --- 집과 생물 그리기 --- func _setup_bubbles() -> void: bubbles.emitting = true @@ -277,37 +296,153 @@ func _setup_bubbles() -> void: func _rebuild_items() -> void: for child in items.get_children(): child.queue_free() - for i in GameState.placements.size(): - _make_item(i) + for i in GameState.houses.size(): + _make_house(i) + for entry in _assign_fish(): + _make_fish(entry) -func _make_item(index: int) -> TileItem: - var p := GameState.placements[index] - var d := Catalog.item(p.kind, p.id) +func _house_rect(index: int) -> Rect2: + var h := GameState.houses[index] + return cell_rect(int(h.col), int(h.row), Catalog.size_of("house", String(h.id))) + + +func _make_house(index: int) -> TileItem: + var id := String(GameState.houses[index].id) + var d := Catalog.house(id) if d.is_empty(): return null var node := TileItem.new() items.add_child(node) - node.setup(d, p.kind, index, cell_rect(p.col, p.row, Catalog.size_of(p.kind, p.id)), - cell_size(), _rng) + node.setup(d, "house", index, _house_rect(index), cell_size(), _rng) return node +func _make_fish(entry: Dictionary) -> void: + var d := Catalog.producer(String(entry.id)) + if d.is_empty(): + return + var home := _house_rect(int(entry.house)) + var cell := cell_size() + var slot := int(entry.slot) + + var node := TileItem.new() + items.add_child(node) + node.setup(d, "producer", -1, _fish_area(home, cell, slot), cell, _rng) + + # 헤엄치는 것만 집 둘레를 돈다. 해초나 거품돌은 제자리에 붙어 있어야 한다. + var shape := String(d.get("shape", "rock")) + if shape != "fish" and shape != "glow": + return + var house_index := int(entry.house) + var cap: int = maxi(Catalog.capacity_of(String(GameState.houses[house_index].id)), 1) + # 집마다 도는 방향을 바꿔 여러 채가 한 화면에 있어도 단조롭지 않게 한다. + var dir := 1.0 if house_index % 2 == 0 else -1.0 + node.set_orbit(_orbit_center(home, cell), _orbit_radius(home, cell), + TAU * float(slot) / float(cap), ORBIT_SPEED * dir) + + +## 어느 생물이 어느 집에 사는지 그릴 때마다 정한다. 집은 정원만 주므로 배정을 +## 저장할 필요가 없다. 구역마다 집을 격자 순서로, 생물을 카탈로그 순서로 세워 채운다. +## 돌려주는 것: [{id, house, slot}, ...]. 정원을 넘겨 사는 것들은 빠진다. +func _assign_fish() -> Array: + var out: Array = [] + for z in Catalog.zone_count(): + var homes := _homes_in_zone(z) + if homes.is_empty(): + continue + var hi := 0 + var used := 0 + for d in Catalog.PRODUCERS: + if int(d.get("zone", 0)) != z: + continue + for n in GameState.fish_count(String(d.id)): + while hi < homes.size() \ + and used >= Catalog.capacity_of(String(GameState.houses[homes[hi]].id)): + hi += 1 + used = 0 + if hi >= homes.size(): + break + out.append({"id": String(d.id), "house": homes[hi], "slot": used}) + used += 1 + return out + + +## 그 구역의 집들을 격자 순서(왼쪽 위부터)로. 배정이 매번 같아야 그림이 안 튄다. +func _homes_in_zone(zone_index: int) -> Array[int]: + var homes: Array[int] = [] + for i in GameState.houses.size(): + if Catalog.zone_of_column(int(GameState.houses[i].col)) == zone_index: + homes.append(i) + homes.sort_custom(func(a: int, b: int) -> bool: + var ha := GameState.houses[a] + var hb := GameState.houses[b] + if int(ha.col) != int(hb.col): + return int(ha.col) < int(hb.col) + return int(ha.row) < int(hb.row)) + return homes + + +## 물고기가 도는 궤도의 반지름. 집을 감싸되 3/4 부감이라 세로로 납작하다. +func _orbit_radius(home: Rect2, cell: Vector2) -> Vector2: + return Vector2(home.size.x * 0.5 + cell.x * 0.55, cell.y * 0.42) + + +## 궤도의 중심. 집 발치에 두되, 궤도가 통째로 바다 안에 들어오도록 밀어 넣는다. +## 중심을 옮기지 않고 매 프레임 위치만 자르면 물고기가 벽에 붙어 멈춘 것처럼 보인다. +func _orbit_center(home: Rect2, cell: Vector2) -> Vector2: + var r := _orbit_radius(home, cell) + var g := grid_rect() + return Vector2( + _squeeze(home.position.x + home.size.x * 0.5, g.position.x + r.x, g.end.x - r.x), + _squeeze(home.end.y - cell.y * 0.10, + g.position.y + cell.y * 0.9 + r.y, g.end.y - r.y)) + + +## 범위가 뒤집힐 만큼 좁으면(작은 창) 가운데로 보낸다. clampf는 그때 엉뚱한 값을 준다. +func _squeeze(value: float, lo: float, hi: float) -> float: + if lo > hi: + return (lo + hi) * 0.5 + return clampf(value, lo, hi) + + +## 집 둘레의 붙박이 자리 하나. 해초·거품돌처럼 바닥에 서는 것이 쓴다. +## 왼쪽 → 오른쪽 → 앞 순으로 돌고, 정원이 더 크면 한 겹 더 벌어진다. +func _fish_area(home: Rect2, cell: Vector2, slot: int) -> Rect2: + # 앞자리는 살짝 오른쪽으로 밀어 둔다. 바다 왼쪽 끝에 붙은 집은 왼쪽 자리가 + # 안으로 밀려 들어오는데, 그때 앞자리와 겹쳐 한 마리처럼 보이기 때문이다. + const SPOTS := [Vector2(-0.5, -0.1), Vector2(0.5, -0.1), Vector2(0.12, 0.62)] + var spot: Vector2 = SPOTS[slot % SPOTS.size()] + var ring := float(slot / SPOTS.size()) + + var cx := home.position.x + home.size.x * 0.5 + var x := cx + spot.x * (home.size.x + cell.x * 0.9) + ring * cell.x * 0.28 + var base := home.end.y + spot.y * cell.y + ring * cell.y * 0.22 + + # 바다 밖으로 새어 나가지 않게 잡아둔다. + var g := grid_rect() + x = clampf(x, g.position.x + cell.x * 0.5, g.end.x - cell.x * 0.5) + base = clampf(base, g.position.y + cell.y * 0.6, g.end.y) + return Rect2(Vector2(x - cell.x * 0.5, base - cell.y), cell) + + func _on_placed(index: int) -> void: - var node := _make_item(index) + # 집이 하나 늘면 생물 배정도 달라지므로 통째로 다시 그린다. + _rebuild_items() + var node := _node_for_house(index) if node: - # 새로 놓은 것은 살짝 부풀며 등장한다. + # 새로 지은 집은 살짝 부풀며 등장한다. node.scale = Vector2.ZERO var t := create_tween() t.tween_property(node, "scale", Vector2.ONE, 0.28) \ .set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) -## 커서 아래에 있는 놓인 것. 그려진 모양으로 먼저 찾는다. +## 커서 아래에 있는 것. 그려진 모양으로 먼저 찾는다. ## 키 큰 것은 제 칸보다 위쪽에 그려지므로, 보이는 자리를 눌러야 집히는 게 자연스럽다. ## 앞줄(화면 아래쪽)에 있는 것이 이긴다. -func _pick_at(pos: Vector2) -> int: - var best := -1 +func _pick_at(pos: Vector2) -> Dictionary: + var best: TileItem = null var best_y := -INF for child in items.get_children(): var it := child as TileItem @@ -317,90 +452,102 @@ func _pick_at(pos: Vector2) -> int: r.position += it.position if r.has_point(pos) and it.position.y > best_y: best_y = it.position.y - best = it.placement_index - if best >= 0: - return best - # 그림에 안 걸렸으면 발자국 칸으로 한 번 더 본다. - var cell := cell_at(pos) - return GameState.placement_at(cell.x, cell.y) + best = it + if best == null: + # 그림에 안 걸렸으면 발자국 칸으로 한 번 더 본다. 칸을 가진 것은 집뿐이다. + var cell := cell_at(pos) + var index := GameState.house_at(cell.x, cell.y) + if index < 0: + return {} + return {"kind": "house", "index": index, "id": String(GameState.houses[index].id)} + return {"kind": best.ref_kind, "index": best.ref_index, "id": best.ref_id} -func _item_node(index: int) -> TileItem: +func _node_for_house(index: int) -> TileItem: for child in items.get_children(): var it := child as TileItem - if it and it.placement_index == index: + if it and it.ref_kind == "house" and it.ref_index == index: return it return null -# --- 배치 모드 --- +func _node_for(ref: Dictionary) -> TileItem: + if ref.is_empty(): + return null + if String(ref.kind) == "house": + return _node_for_house(int(ref.index)) + for child in items.get_children(): + var it := child as TileItem + if it and it.ref_kind == "producer" and it.ref_id == String(ref.id): + return it + return null -func begin_placement(kind: String, id: String) -> void: - if kind == "upgrade": + +# --- 집 짓기 모드 --- + +func begin_build(id: String) -> void: + if Catalog.house(id).is_empty(): return _cancel_drag() - _placing_kind = kind - _placing_id = id - placement_changed.emit(true, kind, id) + _building_id = id + build_mode_changed.emit(true, id) _update_ghost() -func cancel_placement() -> void: - if _placing_id.is_empty(): +func cancel_build() -> void: + if _building_id.is_empty(): return - _placing_kind = "" - _placing_id = "" + _building_id = "" ghost.hide() - placement_changed.emit(false, "", "") + build_mode_changed.emit(false, "") -func is_placing() -> bool: - return not _placing_id.is_empty() +func is_building() -> bool: + return not _building_id.is_empty() func _update_ghost() -> void: - if not is_placing() or _hover.x < 0: + if not is_building() or _hover.x < 0: ghost.hide() return - var anchor := _anchor_for(_placing_kind, _placing_id, _hover) - var span := Catalog.size_of(_placing_kind, _placing_id) - var ok := GameState.can_place_at(_placing_kind, _placing_id, anchor.x, anchor.y) \ - and GameState.bubbles >= GameState.cost_of(_placing_kind, _placing_id) + var anchor := _anchor_for(_building_id, _hover) + var span := Catalog.size_of("house", _building_id) + var ok := GameState.can_build_at(_building_id, anchor.x, anchor.y) \ + and GameState.bubbles >= GameState.cost_of("house", _building_id) ghost.show_at(cell_rect(anchor.x, anchor.y, span), ok) -func _try_place() -> void: - var kind := _placing_kind - var id := _placing_id - var anchor := _anchor_for(kind, id, _hover) +func _try_build() -> void: + var id := _building_id + var anchor := _anchor_for(id, _hover) - if GameState.bubbles < GameState.cost_of(kind, id): + if GameState.bubbles < GameState.cost_of("house", id): message.emit("거품이 모자라요") - cancel_placement() + cancel_build() return - if not GameState.can_place_at(kind, id, anchor.x, anchor.y): - message.emit(_why_not(kind, id, anchor)) + if not GameState.can_build_at(id, anchor.x, anchor.y): + message.emit(_why_not(id, anchor)) return - GameState.place(kind, id, anchor.x, anchor.y) - # 계속 놓을 수 있으면 모드를 유지한다. 같은 걸 여러 개 깔 때 편하다. - if kind == "ornament" or GameState.bubbles < GameState.cost_of(kind, id): - cancel_placement() + GameState.build_house(id, anchor.x, anchor.y) + # 계속 지을 수 있으면 모드를 유지한다. 같은 집을 여러 채 깔 때 편하다. + if GameState.bubbles < GameState.cost_of("house", id): + cancel_build() else: _update_ghost() -func _why_not(kind: String, id: String, anchor: Vector2i) -> String: - var item_zone := Catalog.zone_of(kind, id) +func _why_not(id: String, anchor: Vector2i) -> String: + var item_zone := Catalog.zone_of("house", id) if not GameState.is_zone_unlocked(item_zone): return "%s 아직 잠겨 있어요" % Korean.i(String(Catalog.zone(item_zone).name)) - var cells := GameState.footprint(kind, id, anchor.x, anchor.y) + var cells := GameState.footprint(id, anchor.x, anchor.y) if cells.is_empty(): return "바다 밖으로 나가요" for c in cells: if Catalog.zone_of_column(c.x) != item_zone: - return "%s 안에만 놓을 수 있어요" % Catalog.zone(item_zone).name - return "이미 무언가 놓여 있어요" + return "%s 안에만 지을 수 있어요" % Catalog.zone(item_zone).name + return "이미 무언가 서 있어요" # --- 입력 --- @@ -408,11 +555,12 @@ func _why_not(kind: String, id: String, anchor: Vector2i) -> String: func _gui_input(event: InputEvent) -> void: if event is InputEventMouseMotion: _hover = cell_at(event.position) - if _drag_index >= 0: + if not _drag_ref.is_empty(): _update_drag(event.position) - elif is_placing(): + elif is_building(): _update_ghost() - elif _press_index >= 0 and event.position.distance_to(_press_position) > DRAG_THRESHOLD: + elif not _press_ref.is_empty() and not _press_was_drag \ + and event.position.distance_to(_press_position) > DRAG_THRESHOLD: _begin_drag() return @@ -420,9 +568,9 @@ func _gui_input(event: InputEvent) -> void: return if event.button_index == MOUSE_BUTTON_RIGHT and event.pressed: - if is_placing(): - cancel_placement() - elif _drag_index >= 0: + if is_building(): + cancel_build() + elif not _drag_ref.is_empty(): _cancel_drag() else: context_menu_requested.emit() @@ -433,80 +581,107 @@ func _gui_input(event: InputEvent) -> void: if event.pressed: _hover = cell_at(event.position) - if is_placing(): - _try_place() + if is_building(): + _try_build() return _press_position = event.position - _press_index = _pick_at(event.position) + _press_ref = _pick_at(event.position) + _press_was_drag = false else: - if _drag_index >= 0: + if not _drag_ref.is_empty(): _drop(event.position) - elif _press_index != PRESS_WAS_DRAG: - # 끌지 않고 뗐으면 그냥 물을 클릭한 것으로 친다. + elif not _press_was_drag: + # 끌지 않고 뗐으면 물을 만진 것으로 친다. clicked.emit(event.position) - _press_index = -1 + _press_ref = {} + _press_was_drag = false func _begin_drag() -> void: - if _press_index < 0: + if _press_ref.is_empty(): return - _drag_index = _press_index - _press_index = PRESS_WAS_DRAG + _drag_ref = _press_ref + _drag_node = _node_for(_drag_ref) + # 끄는 동안에는 궤도를 멈춘다. 안 그러면 손을 따라오지 않고 제 길을 간다. + if _drag_node: + _drag_node.orbit_enabled = false + _press_was_drag = true drag_changed.emit(true) func _update_drag(pos: Vector2) -> void: - var node := _item_node(_drag_index) - if node: - node.position = pos + if _drag_node and is_instance_valid(_drag_node): + _drag_node.position = pos - if trash_rect.has_point(pos): + # 생물은 칸을 차지하지 않으므로 놓일 자리를 미리 보여줄 것이 없다. + if trash_rect.has_point(pos) or String(_drag_ref.kind) != "house": ghost.hide() return - var p := GameState.placements[_drag_index] - var anchor := _anchor_for(p.kind, p.id, _hover) - var span := Catalog.size_of(p.kind, p.id) - var ok := GameState.can_place_at(p.kind, p.id, anchor.x, anchor.y, _drag_index) - ghost.show_at(cell_rect(anchor.x, anchor.y, span), ok) + var id := String(_drag_ref.id) + var anchor := _anchor_for(id, _hover) + var ok := GameState.can_build_at(id, anchor.x, anchor.y, int(_drag_ref.index)) + ghost.show_at(cell_rect(anchor.x, anchor.y, Catalog.size_of("house", id)), ok) func _drop(pos: Vector2) -> void: - var index := _drag_index - _drag_index = -1 + var ref := _drag_ref + _drag_ref = {} + _drag_node = null ghost.hide() drag_changed.emit(false) - if index < 0 or index >= GameState.placements.size(): + if trash_rect.has_point(pos): + if String(ref.kind) == "house": + _trash_house(int(ref.index)) + else: + _release_fish(String(ref.id)) + return + + # 집은 끌어다 옮길 수 있다. 생물은 자기 집을 따라다니므로 제자리로 돌아간다. + if String(ref.kind) == "house": + var anchor := _anchor_for(String(ref.id), _hover) + if not GameState.move_house(int(ref.index), anchor.x, anchor.y): + message.emit("여기에는 놓을 수 없어요") + _rebuild_items() + + +func _trash_house(index: int) -> void: + if index < 0 or index >= GameState.houses.size(): _rebuild_items() return - - if trash_rect.has_point(pos): - var p := GameState.placements[index] - var item_name := String(Catalog.item(p.kind, p.id).get("name", "")) - var refund := GameState.remove(index) - message.emit("%s 치우고 거품 %s를 돌려받았어요" % [ - Korean.eul(item_name), NumberFormat.short(refund)]) + if not GameState.can_remove_house(index): + message.emit("살고 있는 생물이 갈 곳이 없어요") + _rebuild_items() return + var house_name := String(Catalog.house(String(GameState.houses[index].id)).get("name", "")) + var refund := GameState.remove_house(index) + message.emit("%s 헐고 거품 %s를 돌려받았어요" % [ + Korean.eul(house_name), NumberFormat.short(refund)]) - var placement := GameState.placements[index] - var anchor := _anchor_for(placement.kind, placement.id, _hover) - if not GameState.move(index, anchor.x, anchor.y): - message.emit("여기에는 놓을 수 없어요") - # 성공이든 실패든 제자리를 다시 잡아준다. - _rebuild_items() + +func _release_fish(id: String) -> void: + if not GameState.can_release_fish(id): + message.emit("마지막 생물은 내보낼 수 없어요") + _rebuild_items() + return + var fish_name := String(Catalog.producer(id).get("name", "")) + var refund := GameState.release_fish(id) + message.emit("%s 내보내고 거품 %s를 돌려받았어요" % [ + Korean.eul(fish_name), NumberFormat.short(refund)]) func _cancel_drag() -> void: - if _drag_index < 0: + if _drag_ref.is_empty(): return - _drag_index = -1 + _drag_ref = {} + _drag_node = null ghost.hide() drag_changed.emit(false) _rebuild_items() -## 클릭 지점에서 거품이 터지는 연출. +## 만진 자리에서 거품이 퍼지는 연출. func pop_at(pos: Vector2) -> void: var burst := CPUParticles2D.new() add_child(burst) diff --git a/scenes/aquarium/tile_item.gd b/scenes/aquarium/tile_item.gd index 84eacab..ede9aa3 100644 --- a/scenes/aquarium/tile_item.gd +++ b/scenes/aquarium/tile_item.gd @@ -8,8 +8,12 @@ class_name TileItem ## 아직 아트 에셋이 없어서 형태별로 직접 그린다. ## assets/art/ 에 스프라이트가 생기면 Sprite2D + offset 기반으로 바꾸면 된다. -## placements 배열에서의 인덱스. 클릭·드래그로 무엇을 집었는지 알아내는 데 쓴다. -var placement_index := -1 +## 이 그림이 가리키는 것. 클릭·드래그로 무엇을 집었는지 알아내는 데 쓴다. +## 집이면 ref_kind "house" + ref_index (houses 배열 인덱스) +## 생물이면 ref_kind "producer" + ref_id (종류). 생물은 개체를 구분하지 않는다. +var ref_kind := "" +var ref_id := "" +var ref_index := -1 var data: Dictionary = {} var shape := "rock" @@ -21,6 +25,13 @@ var body_height := 32.0 ## 바닥에서 얼마나 떠 있는지(픽셀). 헤엄치는 것만 0보다 크다. var hover := 0.0 +## 집 둘레를 도는 것(fish/glow)만 궤도를 갖는다. 끌고 있는 동안에는 멈춘다. +var orbit_enabled := true +var _orbit_center := Vector2.ZERO +var _orbit_radius := Vector2.ZERO +var _orbit_speed := 0.0 +var _orbit_phase := 0.0 + var _t := 0.0 var _phase := 0.0 var _swim := Vector2.ZERO @@ -32,7 +43,9 @@ var _shape_seed := 0.0 func setup(item_data: Dictionary, kind: String, index: int, area: Rect2, cell: Vector2, rng: RandomNumberGenerator) -> void: data = item_data - placement_index = index + ref_kind = kind + ref_index = index + ref_id = String(item_data.get("id", "")) shape = String(item_data.get("shape", "rock")) color = item_data.get("color", Color.WHITE) _phase = rng.randf() * TAU @@ -62,6 +75,37 @@ func set_area(area: Rect2, cell: Vector2, height_tiles: float) -> void: queue_redraw() +## 집 둘레를 도는 궤도에 올린다. +## 원점이 궤도를 따라 움직이므로 Y 정렬이 알아서 앞뒤를 바꿔준다 — +## 뒤쪽으로 돌면 집에 가려 "들어간" 것처럼 보이고, 앞으로 나오면 다시 드러난다. +func set_orbit(center: Vector2, radius: Vector2, phase: float, speed: float) -> void: + _orbit_center = center + _orbit_radius = radius + _orbit_phase = phase + _orbit_speed = speed + # 궤도가 큰 움직임을 맡으므로 제자리 흔들림은 줄인다. 안 그러면 서로 싸운다. + _swim_range *= 0.35 + _apply_orbit(0.0) + + +func is_orbiting() -> bool: + return orbit_enabled and _orbit_radius != Vector2.ZERO + + +func _apply_orbit(t: float) -> void: + var angle := _orbit_phase + t * _orbit_speed + var next := _orbit_center + Vector2( + cos(angle) * _orbit_radius.x, sin(angle) * _orbit_radius.y) + # 나아가는 쪽으로 몸을 돌린다. + if absf(next.x - position.x) > 0.01: + _facing = signf(next.x - position.x) + position = next + # 뒤(위쪽)로 돌수록 작고 흐려져 집 안으로 들어간 것처럼 보인다. + var depth := (sin(angle) + 1.0) * 0.5 + scale = Vector2.ONE * lerpf(0.72, 1.0, depth) + modulate.a = lerpf(0.45, 1.0, depth) + + ## 그려지는 대략의 범위. 클릭으로 집을 때 쓴다. func visual_rect() -> Rect2: var top := -body_height - hover - _swim_range.y @@ -72,10 +116,12 @@ func visual_rect() -> Rect2: func _process(delta: float) -> void: _t += delta + if is_orbiting(): + _apply_orbit(_t) if _swim_range != Vector2.ZERO: var next_x := sin(_t * 0.55 + _phase) * _swim_range.x - # 방향을 튼 순간 몸도 돌아본다. - if absf(next_x - _swim.x) > 0.001: + # 방향을 튼 순간 몸도 돌아본다. 궤도를 돌 때는 궤도가 방향을 정한다. + if not is_orbiting() and absf(next_x - _swim.x) > 0.001: _facing = signf(next_x - _swim.x) _swim = Vector2(next_x, sin(_t * 0.9 + _phase * 1.7) * _swim_range.y) queue_redraw() @@ -96,6 +142,9 @@ func _draw() -> void: "plant": _draw_plant() "fish": _draw_fish() "glow": _draw_glow() + "vessel": _draw_vessel() + "wreck": _draw_wreck() + "pillar": _draw_pillar() _: _draw_rock() draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) @@ -251,3 +300,107 @@ func _fill(points: PackedVector2Array, fill: Color, outline := true) -> void: var loop := points.duplicate() loop.append(points[0]) draw_polyline(loop, fill.darkened(0.55), maxf(span.x * 0.04, 1.0), true) + + +# --- 집 전용 형태 --- +# 생물과 같은 도형을 쓰면 "건물"과 "사는 것"이 구분되지 않는다. +# 그래서 집은 아래 셋만 쓰고, 셋 다 물고기가 드나들 만한 틈이나 입구를 드러낸다. + + +## 항아리·유리병. 배가 부르고 목이 좁으며, 꼭대기에 어두운 입구가 뚫려 있다. +func _draw_vessel() -> void: + # 발자국 너비를 다 쓰면 항아리가 납작한 돌처럼 보인다. 좁혀야 그릇으로 읽힌다. + var w := span.x * 0.29 + var h := body_height + var steps := 14 + + var body := PackedVector2Array() + for i in steps + 1: + var f := float(i) / steps + body.append(Vector2(-w * _vessel_profile(f), -h * f)) + for i in range(steps, -1, -1): + var f := float(i) / steps + body.append(Vector2(w * _vessel_profile(f), -h * f)) + _fill(body, color.darkened(0.18)) + + # 빛을 받는 왼쪽 배. 몸통보다 좁게 얹어야 둥글어 보인다. + var lit := PackedVector2Array() + for p in body: + lit.append(Vector2(p.x * 0.52 - w * 0.20, p.y)) + _fill(lit, color, false) + + # 입구. 물고기가 드나드는 곳이라 확실히 어둡게 파 둔다. + var mouth := w * _vessel_profile(1.0) + draw_set_transform(Vector2(0, -h), 0.0, Vector2(1.0, 0.38)) + draw_circle(Vector2.ZERO, mouth * 1.05, color.darkened(0.72)) + draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) + + +## 밑동은 좁고, 중간이 부르고, 목에서 다시 좁아진다. +func _vessel_profile(f: float) -> float: + return 0.55 + sin(clampf(f, 0.0, 1.0) * PI) * 0.50 - f * 0.16 + + +## 난파선·잠수정·고래 뼈. 옆으로 길게 누운 뼈대와 그 사이의 빈틈. +## 기울여 그려야 "가라앉은 것"으로 읽힌다. +func _draw_wreck() -> void: + var w := span.x * 0.44 + var h := body_height + var lean := h * 0.13 # 오른쪽으로 기운 정도 + var deck := -h * 0.52 + + # 선체. 아래는 둥글고 위는 갑판으로 열려 있다. + var hull := PackedVector2Array() + var steps := 12 + for i in steps + 1: + var a := PI + PI * float(i) / steps + hull.append(Vector2(cos(a) * w, sin(a) * h * 0.46)) + hull.append(Vector2(w + lean, deck)) + hull.append(Vector2(-w + lean * 0.4, deck * 0.72)) + _fill(hull, color) + + # 갈비뼈. 사이의 빈틈이 물고기가 숨는 자리로 읽힌다. + var rib := color.darkened(0.48) + var thick := maxf(span.x * 0.035, 1.2) + for i in 3: + var x := (float(i) - 1.0) * w * 0.52 + var top := deck * (0.78 + float(i) * 0.14) + draw_line(Vector2(x, -h * 0.06), Vector2(x + lean * 0.8, top), rib, thick) + + # 부러진 돛대 하나. 실루엣에 세로선이 하나 있어야 배로 읽힌다. + draw_line(Vector2(-w * 0.30, deck * 0.85), + Vector2(-w * 0.30 + lean * 1.6, -h * 1.0), color.darkened(0.32), thick * 1.3) + + +## 석주·석상·굴뚝·수정. 위로 곧게 선 기둥. 이음매를 그어 돌덩이로 읽히게 한다. +func _draw_pillar() -> void: + var w := span.x * 0.22 + var h := body_height + + var body := PackedVector2Array([ + Vector2(-w * 1.30, 0.0), + Vector2(-w * 0.92, -h * 0.90), + Vector2(-w * 1.02, -h), + Vector2(w * 1.02, -h), + Vector2(w * 0.92, -h * 0.90), + Vector2(w * 1.30, 0.0), + ]) + _fill(body, color.darkened(0.26)) + + # 빛을 받는 왼쪽 면. + var lit := PackedVector2Array() + for p in body: + lit.append(Vector2(p.x * 0.46 - w * 0.34, p.y)) + _fill(lit, color, false) + + # 돌을 쌓아 올린 이음매. + var seam := color.darkened(0.52) + for i in 3: + var y := -h * (0.26 + float(i) * 0.23) + var half := w * lerpf(1.24, 1.0, float(i) / 3.0) + draw_line(Vector2(-half, y), Vector2(half, y), seam, maxf(span.x * 0.028, 1.0)) + + # 꼭대기 면. 부감이라 윗면이 살짝 보인다. + draw_set_transform(Vector2(0, -h), 0.0, Vector2(1.0, 0.34)) + draw_circle(Vector2.ZERO, w * 1.02, color.lightened(0.18)) + draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) diff --git a/scenes/main.gd b/scenes/main.gd index 438ffc7..433751b 100644 --- a/scenes/main.gd +++ b/scenes/main.gd @@ -57,13 +57,17 @@ func _ready() -> void: shop.hide() shop.closed.connect(_close_shop) shop.item_selected.connect(_on_shop_item_selected) + # 격자는 정보 바 아래에서 시작해야 한다. 바 높이는 HUD가 재어서 알려준다. + hud.reserved_top_changed.connect(func(px: float): aquarium.grid_top = px) + aquarium.grid_top = hud.reserved_top() + hud.shop_pressed.connect(_toggle_shop) hud.menu_pressed.connect(_open_menu) aquarium.message.connect(hud.show_toast) - aquarium.clicked.connect(_on_water_clicked) + aquarium.clicked.connect(_on_water_touched) aquarium.context_menu_requested.connect(_open_menu) - aquarium.placement_changed.connect(_on_placement_changed) + aquarium.build_mode_changed.connect(_on_build_mode_changed) aquarium.drag_changed.connect(_on_drag_changed) _build_menu() @@ -128,46 +132,61 @@ func _apply_passthrough() -> void: # --- 게임 입력 --- func _unhandled_input(event: InputEvent) -> void: - if event.is_action_pressed("ui_cancel") and aquarium.is_placing(): - aquarium.cancel_placement() + if event.is_action_pressed("ui_cancel") and aquarium.is_building(): + aquarium.cancel_build() get_viewport().set_input_as_handled() -func _on_water_clicked(pos: Vector2) -> void: - var gained := GameState.click() +## 물을 만지면 거품이 퍼진다. 거품은 생물이 버는 것이라 여기서 얻는 것은 없다. +func _on_water_touched(pos: Vector2) -> void: aquarium.pop_at(pos) - hud.float_number(pos, gained) func _toggle_shop() -> void: if shop.visible: _close_shop() else: - aquarium.cancel_placement() + aquarium.cancel_build() shop.show() func _close_shop() -> void: shop.hide() - aquarium.cancel_placement() + aquarium.cancel_build() +## 상점에서 무언가를 골랐을 때. +## 집만 바다에 자리를 잡아야 하므로 배치 모드로 들어가고, +## 생물과 업그레이드는 자리가 필요 없어서 그 자리에서 바로 사진다. func _on_shop_item_selected(kind: String, id: String) -> void: - if kind == "upgrade": - if GameState.buy_upgrade(id): - hud.show_toast("%s 켰어요" % Korean.eul(String(Catalog.upgrade(id).name))) + match kind: + "upgrade": + if GameState.buy_upgrade(id): + hud.show_toast("%s 켰어요" % Korean.eul(String(Catalog.upgrade(id).name))) + "producer": + _take_fish(id) + "house": + aquarium.begin_build(id) + + +func _take_fish(id: String) -> void: + var fish_name := String(Catalog.producer(id).get("name", "")) + if GameState.buy_fish(id): + var z := Catalog.zone_of("producer", id) + hud.show_toast("%s 들였어요 · %s 남은 자리 %d" % [ + Korean.eul(fish_name), Catalog.zone(z).name, GameState.free_slots_in_zone(z)]) return - aquarium.begin_placement(kind, id) + var why := GameState.fish_blocker(id) + hud.show_toast(why if not why.is_empty() else "지금은 들일 수 없어요", 3.0) -## 배치 중에는 상점을 잠시 치운다. 상점이 바다 오른쪽을 가리고 있어서 -## 열어 둔 채로는 뒤쪽 구역에 놓을 수가 없다. -func _on_placement_changed(active: bool, kind: String, id: String) -> void: - shop.set_selected(active, kind, id) +## 집을 짓는 동안에는 상점을 잠시 치운다. 상점이 바다 오른쪽을 가리고 있어서 +## 열어 둔 채로는 뒤쪽 구역에 지을 수가 없다. +func _on_build_mode_changed(active: bool, id: String) -> void: + shop.set_selected(active, id) shop.visible = not active if active: - hud.show_toast("놓을 칸을 클릭하세요 · 우클릭이나 Esc로 취소", 3.0) - + hud.show_toast("집을 지을 칸을 클릭하세요 · 우클릭이나 Esc로 취소", 3.0) func _on_drag_changed(dragging: bool) -> void: hud.set_trash_visible(dragging) @@ -270,7 +289,6 @@ func _confirm_reset() -> void: add_child(dialog) dialog.confirmed.connect(func(): SaveManager.wipe() - aquarium.rebuild() hud.show_toast("수조를 비웠어요") ) dialog.close_requested.connect(dialog.queue_free) diff --git a/scenes/ui/hud.gd b/scenes/ui/hud.gd index d85db0e..42c2822 100644 --- a/scenes/ui/hud.gd +++ b/scenes/ui/hud.gd @@ -5,6 +5,12 @@ class_name HUDLayer signal shop_pressed signal menu_pressed +## 정보 바가 위에서 자리를 얼마나 먹는지. 바 높이는 글꼴과 내용에 따라 달라지므로 +## 재어서 알려주고, main이 받아 수조 격자를 그만큼 아래로 내린다. +signal reserved_top_changed(px: float) + +## 바 아래로 이만큼은 띄워야 첫 줄 칸이 바에 닿지 않는다. +const BAR_CLEARANCE := 6.0 @onready var control_bar: PanelContainer = $ControlBar @onready var bubbles_label: Label = %BubblesLabel @@ -38,6 +44,8 @@ func _ready() -> void: shop_button.pressed.connect(func(): shop_pressed.emit()) menu_button.pressed.connect(func(): menu_pressed.emit()) + control_bar.resized.connect(func(): reserved_top_changed.emit(reserved_top())) + GameState.zone_unlocked.connect(func(_i: int): _refresh_zone_label()) GameState.state_changed.connect(_refresh_zone_label) GameState.reloaded.connect(_refresh_zone_label) @@ -47,7 +55,10 @@ func _ready() -> void: func _process(_delta: float) -> void: bubbles_label.text = NumberFormat.short(GameState.bubbles) - bps_label.text = "초당 %s" % NumberFormat.short(GameState.bps) + # 정원이 곧 성장의 한계라 초당 생산량 옆에 늘 같이 둔다. + bps_label.text = "초당 %s · 생물 %d/%d" % [ + NumberFormat.short(GameState.bps), GameState.fish_total, GameState.capacity_total() + ] beauty_label.text = "아름다움 %s · x%.2f" % [ NumberFormat.short(GameState.beauty), GameState.beauty_mult ] @@ -82,21 +93,6 @@ func show_toast(text: String, duration := 2.0) -> void: _toast_tween.tween_property(toast, "modulate:a", 0.0, 0.5) -## 클릭했을 때 얻은 양이 위로 떠오르는 숫자. -func float_number(at: Vector2, amount: float) -> void: - var l := Label.new() - add_child(l) - l.text = "+%s" % NumberFormat.short(amount) - UIStyle.label(l, 15, UIStyle.ACCENT) - l.position = at - Vector2(14, 10) - l.mouse_filter = Control.MOUSE_FILTER_IGNORE - var t := create_tween().set_parallel(true) - t.tween_property(l, "position:y", l.position.y - 42.0, 0.9) \ - .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) - t.tween_property(l, "modulate:a", 0.0, 0.9) - t.chain().tween_callback(l.queue_free) - - ## 무언가를 집어 든 동안만 휴지통을 보여준다. 평소에는 자리를 차지하지 않는다. func set_trash_visible(on: bool) -> void: trash.visible = on @@ -105,3 +101,8 @@ func set_trash_visible(on: bool) -> void: ## 휴지통의 사각형. HUD와 바다가 같은 좌표계(Main의 전체 영역)를 쓰므로 그대로 넘길 수 있다. func trash_area() -> Rect2: return Rect2(trash.position, trash.size) + + +## 정보 바가 먹는 세로 자리. 격자는 이 아래에서 시작해야 한다. +func reserved_top() -> float: + return control_bar.position.y + control_bar.size.y + BAR_CLEARANCE diff --git a/scenes/ui/shop_panel.gd b/scenes/ui/shop_panel.gd index 6bc4623..07ecf2a 100644 --- a/scenes/ui/shop_panel.gd +++ b/scenes/ui/shop_panel.gd @@ -1,18 +1,18 @@ extends PanelContainer class_name ShopPanel -## 상점. 생물 / 장식 / 업그레이드 세 탭. +## 상점. 생물 / 집 / 업그레이드 세 탭. ## -## 생물과 장식은 여기서 바로 사지지 않는다. 고르면 배치 모드로 들어가고, -## 실제 구매는 바다에서 칸을 클릭할 때 일어난다. 업그레이드만 즉시 구매다. +## 집만 바다에 자리를 잡아야 하므로, 고르면 배치 모드로 들어가고 +## 실제 값은 칸을 클릭할 때 치러진다. 생물과 업그레이드는 여기서 바로 사진다. ## 목록은 Catalog에서 바로 만들어지므로, 아이템을 추가할 때 이 파일은 건드릴 필요가 없다. signal closed -## 고른 아이템. kind가 "upgrade"면 즉시 구매, 아니면 배치 모드로 들어간다. +## 고른 것. "house"면 배치 모드로 들어가고, 나머지는 즉시 구매다. signal item_selected(kind: String, id: String) const TABS := [ {"kind": "producer", "label": "생물"}, - {"kind": "ornament", "label": "장식"}, + {"kind": "house", "label": "집"}, {"kind": "upgrade", "label": "업그레이드"}, ] @@ -54,8 +54,8 @@ func _process(_delta: float) -> void: _update_rows() -## 배치 모드가 켜지고 꺼질 때 main이 알려준다. 고른 행을 강조해 둔다. -func set_selected(active: bool, _kind: String, id: String) -> void: +## 집 짓기 모드가 켜지고 꺼질 때 main이 알려준다. 고른 행을 강조해 둔다. +func set_selected(active: bool, id: String) -> void: _selected_id = id if active else "" _update_rows() @@ -77,8 +77,8 @@ func _visible_ids() -> Array[String]: for d in _current_items(): if not GameState.is_revealed(kind, d.id): continue - # 한 번만 살 수 있는 것은 이미 가지고 있으면 목록에서 빠진다. - if kind != "producer" and GameState.owned(kind, d.id): + # 업그레이드만 한 번뿐이다. 생물과 집은 얼마든지 더 들일 수 있다. + if GameState.owned(kind, String(d.id)): continue ids.append(String(d.id)) return ids @@ -192,16 +192,16 @@ func _name_text(kind: String, d: Dictionary) -> String: func _effect_text(kind: String, d: Dictionary) -> String: + var zone_name := String(Catalog.zone(int(d.get("zone", 0))).name) match kind: "producer": return "%s · 초당 +%s · 아름다움 +%s" % [ - Catalog.zone(int(d.get("zone", 0))).name, - NumberFormat.short(d.bps), NumberFormat.short(d.beauty) + zone_name, NumberFormat.short(d.bps), NumberFormat.short(d.beauty) ] - "ornament": - return "%s · 아름다움 +%s · 생산 +%d%%" % [ - Catalog.zone(int(d.get("zone", 0))).name, - NumberFormat.short(d.beauty), int(round(float(d.mult) * 100.0)) + "house": + return "%s · 생물 %d마리 · 아름다움 +%s" % [ + zone_name, Catalog.capacity_of(String(d.id)), + NumberFormat.short(d.beauty) ] "upgrade": return String(d.desc) @@ -214,28 +214,42 @@ func _update_rows() -> void: var kind: String = r.kind var cost := GameState.cost_of(kind, id) var affordable := GameState.bubbles >= cost + # 생물은 값을 치를 수 있어도 살 집이 없으면 못 들인다. + var has_room: bool = kind != "producer" or GameState.can_take_fish(String(id)) r.cost.text = NumberFormat.short(cost) r.cost.modulate = Color.WHITE if affordable else Color(1, 1, 1, 0.45) - r.button.disabled = not affordable - # 고른 것은 밝게 남겨 두어 지금 무엇을 놓는 중인지 보이게 한다. + r.button.disabled = not affordable or not has_room + # 고른 것은 밝게 남겨 두어 지금 무엇을 짓는 중인지 보이게 한다. r.button.modulate = Color(1.0, 1.25, 1.35) if id == _selected_id else Color.WHITE - if kind == "producer": - var owned := GameState.count_of(id) - r.owned.text = "놓임 %d" % owned if owned > 0 else "" - else: - r.owned.text = "" + var owned := GameState.count_of(kind, String(id)) + match kind: + "producer": + if not has_room and affordable: + r.owned.text = "자리 없음" + else: + r.owned.text = "%d마리" % owned if owned > 0 else "" + "house": + r.owned.text = "%d채" % owned if owned > 0 else "" + _: + r.owned.text = "" _update_space_label() -## 구역별로 빈 칸이 얼마나 남았는지. 칸이 곧 한계이므로 늘 보이게 둔다. +## 구역별로 생물 자리가 얼마나 남았는지. 이것이 성장의 진짜 한계다. +## 집 탭에서는 집을 세울 빈 칸을 대신 보여준다. func _update_space_label() -> void: if TABS[_tab].kind == "upgrade": - space_label.text = "칸을 차지하지 않아요" + space_label.text = "자리를 차지하지 않아요" return + var parts := PackedStringArray() + var is_house_tab: bool = TABS[_tab].kind == "house" for i in GameState.unlocked_zones: - parts.append("%s %d" % [Catalog.zone(i).name, GameState.free_cells_in_zone(i)]) - space_label.text = "남은 칸 · " + " ".join(parts) + var left := GameState.free_cells_in_zone(i) if is_house_tab \ + else maxi(GameState.free_slots_in_zone(i), 0) + parts.append("%s %d" % [Catalog.zone(i).name, left]) + var label := "빈 칸 · " if is_house_tab else "남은 자리 · " + space_label.text = label + " ".join(parts) diff --git a/tests/smoke.gd b/tests/smoke.gd index ccbcb30..67135e2 100644 --- a/tests/smoke.gd +++ b/tests/smoke.gd @@ -9,15 +9,18 @@ func _ready() -> void: # 이 테스트는 실제 저장 파일을 건드리면 안 된다. # 끄지 않으면 종료할 때 초기화된 상태가 저장되어 진행도가 날아간다. SaveManager.persistence_enabled = false - GameState.reset() + GameState.reset(false) - _test_click() - _test_producer_cost_curve() - _test_place() - _test_footprint_and_multi_tile() + _test_starting_state() + _test_auto_income() + _test_cost_curves() + _test_build_house() + _test_house_footprint() _test_zone_boundaries() - _test_move() - _test_remove_refund() + _test_capacity() + _test_move_house() + _test_remove_house() + _test_release_fish() _test_space_is_the_limit() _test_zone_gating() _test_zone_unlock() @@ -27,6 +30,8 @@ func _ready() -> void: _test_legacy_save_migration() _test_number_format() _test_korean_particles() + _test_shape_families() + _test_upgrades_are_all_production() if _failures == 0: print("모든 검사 통과") @@ -43,174 +48,326 @@ func check(label: String, condition: bool, detail := "") -> void: print(" FAIL ", label, " ", detail) -## 모든 구역을 열고 거품을 넉넉히 준 상태에서 시작한다. +## 모든 구역을 열고 거품을 넉넉히 준 빈 바다에서 시작한다. func _rich_and_open() -> void: - GameState.reset() + GameState.reset(false) GameState.unlocked_zones = Catalog.zone_count() GameState.bubbles = 1e15 -func _test_click() -> void: - print("클릭") +## 그 구역에 집을 한 채 세워 자리를 만든다. 세운 집의 인덱스를 돌려준다. +func _house_in(zone_index: int, id := "") -> int: + if id.is_empty(): + id = Catalog.first_id_of_zone("house", zone_index) + var spot := GameState.first_free_spot(id) + return GameState.build_house(id, spot.x, spot.y) + + +func _test_starting_state() -> void: + print("시작 상태") + GameState.reset(false) + check("빈 바다로도 시작할 수 있다(테스트용)", + GameState.houses.is_empty() and GameState.fish_total == 0) + GameState.reset() - var gained := GameState.click() - check("클릭 한 번에 기본 1 거품", is_equal_approx(gained, 1.0), str(gained)) - check("잔액과 누적이 함께 오른다", - is_equal_approx(GameState.bubbles, 1.0) and is_equal_approx(GameState.total_bubbles, 1.0)) + check("시작하자마자 집이 서 있다", GameState.houses.size() > 0, + str(GameState.houses.size())) + check("시작하자마자 생물이 살고 있다", GameState.fish_total > 0, + str(GameState.fish_total)) + check("시작하자마자 거품이 늘어난다", GameState.bps > 0.0, str(GameState.bps)) + check("시작 거품을 쥐고 시작한다", + is_equal_approx(GameState.bubbles, Catalog.STARTER_BUBBLES), str(GameState.bubbles)) + check("정원이 생물보다 넉넉하다", GameState.free_slots_in_zone(0) > 0, + "%d / %d" % [GameState.fish_total, GameState.capacity_total()]) + check("시작 거품으로 한 마리를 더 들일 수 있다", + GameState.bubbles >= GameState.cost_of("producer", "bubble_stone") + and GameState.can_take_fish("bubble_stone")) + + var s: Dictionary = Catalog.STARTER_HOUSES[0] + check("집이 정해 둔 칸에 선다", + GameState.house_at(int(s.col), int(s.row)) >= 0) -func _test_producer_cost_curve() -> void: +func _test_auto_income() -> void: + print("자동 수익") + _rich_and_open() + check("아무것도 없으면 초당 생산이 0", is_equal_approx(GameState.bps, 0.0), + str(GameState.bps)) + + _house_in(0) + check("집만으로는 거품이 나지 않는다", is_equal_approx(GameState.bps, 0.0), + str(GameState.bps)) + + GameState.buy_fish("seaweed") + check("생물이 들어와야 거품이 난다", GameState.bps > 0.0, str(GameState.bps)) + + var before_bps := GameState.bps + GameState.buy_fish("seaweed") + check("생물이 늘면 초당 생산도 는다", GameState.bps > before_bps, + "%f -> %f" % [before_bps, GameState.bps]) + + # 1초가 흐른 것으로 친다. 실제 게임에서도 _process가 이 계산을 한다. + var before := GameState.bubbles + var before_total := GameState.total_bubbles + var expected := GameState.bps + GameState._process(1.0) + check("1초에 초당 생산량만큼 늘어난다", + is_equal_approx(GameState.bubbles, before + expected), + "%f vs %f" % [GameState.bubbles, before + expected]) + check("누적도 같이 오른다", + is_equal_approx(GameState.total_bubbles, before_total + expected)) + check("클릭으로 버는 길은 없다", not GameState.has_method("click")) + + +func _test_cost_curves() -> void: print("가격 곡선") - GameState.reset() - GameState.bubbles = 1000.0 - var first := GameState.producer_cost("bubble_stone") - check("첫 거품돌은 15", is_equal_approx(first, 15.0), str(first)) - GameState.place("producer", "bubble_stone", 0, 0) - var second := GameState.producer_cost("bubble_stone") - check("놓을수록 비싸진다 (x1.15)", second > first and second <= ceil(15.0 * 1.15) + 0.001, - "%f -> %f" % [first, second]) + _rich_and_open() + check("첫 거품돌은 15", + is_equal_approx(GameState.cost_of("producer", "bubble_stone"), 15.0), + str(GameState.cost_of("producer", "bubble_stone"))) + check("첫 항아리는 90", + is_equal_approx(GameState.cost_of("house", "amphora"), 90.0), + str(GameState.cost_of("house", "amphora"))) + + _house_in(0) + var house_second := GameState.cost_of("house", "amphora") + check("집도 지을수록 비싸진다 (x1.15)", + is_equal_approx(house_second, ceil(90.0 * 1.15)), str(house_second)) + + GameState.buy_fish("bubble_stone") + var fish_second := GameState.cost_of("producer", "bubble_stone") + check("생물도 들일수록 비싸진다 (x1.15)", + is_equal_approx(fish_second, ceil(15.0 * 1.15)), str(fish_second)) + check("서로 값을 간섭하지 않는다", + is_equal_approx(GameState.cost_of("house", "amphora"), house_second)) -func _test_place() -> void: - print("배치") - GameState.reset() - GameState.bubbles = 1000.0 +func _test_build_house() -> void: + print("집 짓기") + _rich_and_open() var before := GameState.bubbles - var index := GameState.place("producer", "seaweed", 2, 3) - check("빈 칸에 놓인다", index == 0, str(index)) - check("가격만큼 빠진다", is_equal_approx(GameState.bubbles, before - 110.0), str(GameState.bubbles)) - check("그 칸이 자기 것이 된다", GameState.placement_at(2, 3) == 0) - check("옆 칸은 여전히 빈다", GameState.placement_at(3, 3) == -1) - check("아름다움이 붙는다", is_equal_approx(GameState.beauty, 3.0), str(GameState.beauty)) + var index := GameState.build_house("amphora", 2, 3) + check("빈 칸에 선다", index == 0, str(index)) + check("가격만큼 빠진다", is_equal_approx(GameState.bubbles, before - 90.0), + str(GameState.bubbles)) + check("그 칸이 자기 것이 된다", GameState.house_at(2, 3) == 0) + check("항아리는 2x1이라 옆 칸도 먹는다", GameState.house_at(3, 3) == 0) + check("그 옆은 여전히 빈다", GameState.house_at(4, 3) == -1) + check("아름다움이 붙는다", is_equal_approx(GameState.beauty, 12.0), str(GameState.beauty)) + check("정원이 생긴다", GameState.capacity_in_zone(0) == Catalog.HOUSE_CAPACITY, + str(GameState.capacity_in_zone(0))) - check("같은 칸에 또 놓을 수 없다", GameState.place("producer", "seaweed", 2, 3) == -1) - check("격자 밖에는 놓을 수 없다", GameState.place("producer", "seaweed", 2, 99) == -1) + check("같은 칸에 또 지을 수 없다", GameState.build_house("amphora", 2, 3) == -1) + check("겹치는 칸에도 못 짓는다", GameState.build_house("amphora", 3, 3) == -1) + check("격자 밖에는 못 짓는다", GameState.build_house("amphora", 2, 99) == -1) GameState.bubbles = 5.0 - check("잔액이 모자라면 못 놓는다", GameState.place("producer", "bubble_stone", 5, 0) == -1) + check("잔액이 모자라면 못 짓는다", GameState.build_house("amphora", 5, 0) == -1) check("실패했으면 잔액 그대로", is_equal_approx(GameState.bubbles, 5.0)) -func _test_footprint_and_multi_tile() -> void: - print("여러 칸짜리") +func _test_house_footprint() -> void: + print("여러 칸짜리 집") _rich_and_open() - var span := Catalog.size_of("ornament", "shipwreck") + var span := Catalog.size_of("house", "shipwreck") check("난파선은 3x2", span == Vector2i(3, 2), str(span)) - # 산호초(1번 구역)의 두 번째 열에 난파선(3x2)을 놓으면 세 열 두 행을 덮는다. var left := Catalog.ZONE_COLUMNS + 1 - var index := GameState.place("ornament", "shipwreck", left, 0) - check("놓인다", index >= 0, str(index)) + var index := GameState.build_house("shipwreck", left, 0) + check("선다", index >= 0, str(index)) var covered := 0 for col in range(left, left + 3): for row in 2: - if GameState.placement_at(col, row) == index: + if GameState.house_at(col, row) == index: covered += 1 check("여섯 칸을 모두 차지한다", covered == 6, str(covered)) - check("발자국 안쪽에는 겹쳐 놓을 수 없다", - GameState.place("producer", "clownfish", left + 1, 1) == -1) - check("발자국 바로 옆에는 놓인다", - GameState.place("producer", "clownfish", left + 3, 1) >= 0) + check("발자국 안쪽에는 겹쳐 못 짓는다", + GameState.build_house("anchor", left + 1, 1) == -1) + check("발자국 바로 옆에는 선다", + GameState.build_house("anchor", left + 3, 1) >= 0) + check("큰 집도 정원은 같다", + Catalog.capacity_of("shipwreck") == Catalog.capacity_of("anchor")) func _test_zone_boundaries() -> void: print("구역 경계") _rich_and_open() - # 얕은 바다는 0번 구역. 해초는 그 구역 것이라 다음 구역 첫 열에는 못 간다. var last := Catalog.ZONE_COLUMNS - 1 - check("자기 구역 안에는 놓인다", GameState.can_place_at("producer", "seaweed", last, 0)) - check("남의 구역에는 못 놓는다", - not GameState.can_place_at("producer", "seaweed", last + 1, 0)) + check("자기 구역 안에는 선다", GameState.can_build_at("bottle_letter", last, 0)) + check("남의 구역에는 못 짓는다", + not GameState.can_build_at("bottle_letter", last + 1, 0)) # 항아리(2x1)를 마지막 열에 놓으면 다음 구역까지 걸친다. - check("구역을 걸치면 못 놓는다", - not GameState.can_place_at("ornament", "amphora", last, 0)) - check("한 구역 안에 들어가면 놓인다", - GameState.can_place_at("ornament", "amphora", last - 1, 0)) + check("구역을 걸치면 못 짓는다", not GameState.can_build_at("amphora", last, 0)) + check("한 구역 안에 들어가면 선다", GameState.can_build_at("amphora", last - 1, 0)) -func _test_move() -> void: - print("옮기기") +func _test_capacity() -> void: + print("정원") _rich_and_open() - var index := GameState.place("producer", "seaweed", 1, 1) + check("집이 없으면 한 마리도 못 들인다", not GameState.can_take_fish("bubble_stone")) + check("사유를 알려준다", GameState.fish_blocker("bubble_stone").contains("빈 자리"), + GameState.fish_blocker("bubble_stone")) + check("거품이 넘쳐도 못 들인다", not GameState.buy_fish("bubble_stone")) + + _house_in(0) + check("집을 지으면 정원이 생긴다", + GameState.free_slots_in_zone(0) == Catalog.HOUSE_CAPACITY, + str(GameState.free_slots_in_zone(0))) + + for i in Catalog.HOUSE_CAPACITY: + check("%d번째 자리가 찬다" % (i + 1), GameState.buy_fish("bubble_stone")) + check("정원이 다 찼다", GameState.free_slots_in_zone(0) == 0, + str(GameState.free_slots_in_zone(0))) + check("정원이 차면 더 못 들인다", not GameState.buy_fish("bubble_stone")) + check("종류를 바꿔도 마찬가지", not GameState.buy_fish("seaweed")) + + _house_in(0) + check("집을 더 지으면 다시 들일 수 있다", GameState.buy_fish("seaweed")) + check("정원은 집 수 x %d" % Catalog.HOUSE_CAPACITY, + GameState.capacity_in_zone(0) == 2 * Catalog.HOUSE_CAPACITY, + str(GameState.capacity_in_zone(0))) + + # 구역마다 따로 센다. 얕은 바다의 빈 자리로 산호초 물고기를 들일 수는 없다. + check("다른 구역 정원은 따로다", GameState.capacity_in_zone(1) == 0) + check("자리가 없는 구역의 생물은 못 들인다", not GameState.can_take_fish("clownfish")) + _house_in(1) + check("그 구역에 집을 지어야 들인다", GameState.buy_fish("clownfish")) + + +func _test_move_house() -> void: + print("집 옮기기") + _rich_and_open() + var index := GameState.build_house("amphora", 1, 1) var spent := GameState.bubbles - check("빈 칸으로 옮겨진다", GameState.move(index, 4, 2)) + check("빈 칸으로 옮겨진다", GameState.move_house(index, 4, 2)) check("옮기는 건 공짜", is_equal_approx(GameState.bubbles, spent), str(GameState.bubbles)) - check("옛 칸이 비었다", GameState.placement_at(1, 1) == -1) - check("새 칸이 자기 것", GameState.placement_at(4, 2) == index) + check("옛 칸이 비었다", GameState.house_at(1, 1) == -1) + check("새 칸이 자기 것", GameState.house_at(4, 2) == index) - GameState.place("producer", "seaweed", 0, 0) - check("남이 있는 칸으로는 못 옮긴다", not GameState.move(index, 0, 0)) - check("실패했으면 제자리", GameState.placement_at(4, 2) == index) - check("제자리에 그대로 놓는 건 된다", GameState.move(index, 4, 2)) + GameState.build_house("amphora", 0, 0) + check("남이 선 칸으로는 못 옮긴다", not GameState.move_house(index, 0, 0)) + check("실패했으면 제자리", GameState.house_at(4, 2) == index) + check("제자리에 그대로 놓는 건 된다", GameState.move_house(index, 4, 2)) + + GameState.buy_fish("bubble_stone") + check("살던 생물은 그대로 따라간다", GameState.move_house(index, 6, 4) + and GameState.fish_total == 1) -func _test_remove_refund() -> void: - print("치우기와 환불") +func _test_remove_house() -> void: + print("집 헐기") _rich_and_open() - GameState.place("producer", "seaweed", 0, 0) - var index := GameState.place("producer", "seaweed", 1, 0) + GameState.build_house("amphora", 0, 0) + var second := GameState.build_house("amphora", 2, 0) var before := GameState.bubbles - # 두 번째 해초 가격은 올림(110 * 1.15). 그 절반이 돌아온다. - var expected: float = floor(ceil(110.0 * 1.15) * Catalog.REFUND_RATE) + # 두 번째 항아리 값은 올림(90 * 1.15). 그 절반이 돌아온다. + var expected: float = floor(ceil(90.0 * 1.15) * Catalog.REFUND_RATE) - var refund := GameState.remove(index) + var refund := GameState.remove_house(second) check("절반을 돌려받는다", is_equal_approx(refund, expected), "%f vs %f" % [refund, expected]) check("잔액에 더해진다", is_equal_approx(GameState.bubbles, before + expected)) - check("칸이 비었다", GameState.placement_at(1, 0) == -1) - check("개수가 줄었다", GameState.count_of("seaweed") == 1) + check("칸이 비었다", GameState.house_at(2, 0) == -1) + check("채수가 줄었다", GameState.house_count("amphora") == 1, + str(GameState.house_count("amphora"))) + check("정원도 줄었다", GameState.capacity_in_zone(0) == Catalog.HOUSE_CAPACITY, + str(GameState.capacity_in_zone(0))) - var oi := GameState.place("ornament", "amphora", 2, 0) - check("장식은 하나만", GameState.place("ornament", "amphora", 4, 0) == -1) - GameState.remove(oi) - check("치우면 다시 살 수 있다", GameState.place("ornament", "amphora", 4, 0) >= 0) + # 살고 있는 생물이 갈 곳이 없으면 헐 수 없다. + for i in Catalog.HOUSE_CAPACITY: + GameState.buy_fish("bubble_stone") + check("세입자가 꽉 찬 마지막 집은 못 헌다", not GameState.can_remove_house(0)) + check("헐려고 해도 아무것도 돌아오지 않는다", + is_equal_approx(GameState.remove_house(0), 0.0)) + check("집이 그대로 남는다", GameState.houses.size() == 1, str(GameState.houses.size())) + + GameState.build_house("amphora", 4, 0) + check("자리가 남는 집이 생기면 헐 수 있다", GameState.can_remove_house(0)) + check("실제로 헐린다", GameState.remove_house(0) > 0.0) + check("생물은 그대로 산다", GameState.fish_total == Catalog.HOUSE_CAPACITY, + str(GameState.fish_total)) + + +func _test_release_fish() -> void: + print("생물 내보내기") + _rich_and_open() + _house_in(0) + GameState.buy_fish("seaweed") + var second_cost := GameState.cost_of("producer", "seaweed") + GameState.buy_fish("seaweed") + var before := GameState.bubbles + var expected: float = floor(second_cost * Catalog.REFUND_RATE) + + var refund := GameState.release_fish("seaweed") + check("절반을 돌려받는다", is_equal_approx(refund, expected), + "%f vs %f" % [refund, expected]) + check("잔액에 더해진다", is_equal_approx(GameState.bubbles, before + expected)) + check("마릿수가 줄었다", GameState.fish_count("seaweed") == 1) + check("자리가 다시 빈다", + GameState.free_slots_in_zone(0) == Catalog.HOUSE_CAPACITY - 1, + str(GameState.free_slots_in_zone(0))) + + check("마지막 한 마리는 못 내보낸다", not GameState.can_release_fish("seaweed")) + check("내보내려 해도 아무것도 돌아오지 않다", + is_equal_approx(GameState.release_fish("seaweed"), 0.0)) + check("그대로 남는다", GameState.fish_total == 1, str(GameState.fish_total)) + + GameState.buy_fish("bubble_stone") + check("둘이 되면 내보낼 수 있다", GameState.can_release_fish("seaweed")) + check("실제로 나간다", GameState.release_fish("seaweed") > 0.0) + check("한 마리가 남는다", GameState.fish_total == 1, str(GameState.fish_total)) + check("없는 종류는 못 내보낸다", not GameState.can_release_fish("seaweed")) func _test_space_is_the_limit() -> void: print("공간이 곧 한계") _rich_and_open() - var total := Catalog.GRID_ROWS * Catalog.ZONE_COLUMNS - check("구역 하나는 %d칸" % total, GameState.free_cells_in_zone(0) == total, + var cells := Catalog.GRID_ROWS * Catalog.ZONE_COLUMNS + check("구역 하나는 %d칸" % cells, GameState.free_cells_in_zone(0) == cells, str(GameState.free_cells_in_zone(0))) - var placed := 0 + # 유리병 편지는 1x1이라 한 구역을 가장 촘촘하게 채운다. + var built := 0 while true: - var spot := GameState.first_free_spot("producer", "seaweed") - if spot.x < 0: + var spot := GameState.first_free_spot("bottle_letter") + if spot.x < 0 or GameState.build_house("bottle_letter", spot.x, spot.y) < 0: break - if GameState.place("producer", "seaweed", spot.x, spot.y) < 0: - break - placed += 1 + built += 1 - check("칸이 차면 더 못 놓는다", placed == total, str(placed)) + check("칸이 차면 더 못 짓는다", built == cells, str(built)) check("남은 칸 0", GameState.free_cells_in_zone(0) == 0) - check("거품이 남아도 못 놓는다", GameState.place("producer", "seaweed", 0, 0) == -1) + check("거품이 남아도 못 짓는다", GameState.build_house("bottle_letter", 0, 0) == -1) + check("한 구역의 정원 상한은 칸 수 x %d" % Catalog.HOUSE_CAPACITY, + GameState.capacity_in_zone(0) == cells * Catalog.HOUSE_CAPACITY, + str(GameState.capacity_in_zone(0))) check("다른 구역은 아직 비어 있다", - GameState.free_cells_in_zone(1) == total, str(GameState.free_cells_in_zone(1))) + GameState.free_cells_in_zone(1) == cells, str(GameState.free_cells_in_zone(1))) func _test_zone_gating() -> void: print("구역 잠금") - GameState.reset() + GameState.reset(false) GameState.bubbles = 1e15 check("시작은 1구역만 열림", GameState.unlocked_zones == 1) - check("0구역 물건은 보인다", GameState.is_revealed("producer", "bubble_stone")) - check("1구역 물건은 안 보인다", not GameState.is_revealed("producer", "clownfish")) - check("돈이 넘쳐도 잠긴 구역에는 못 놓는다", - GameState.place("producer", "clownfish", Catalog.ZONE_COLUMNS, 0) == -1) + check("0구역 물건은 보인다", GameState.is_revealed("producer", "bubble_stone") + and GameState.is_revealed("house", "amphora")) + check("1구역 물건은 안 보인다", not GameState.is_revealed("producer", "clownfish") + and not GameState.is_revealed("house", "anchor")) + check("돈이 넘쳐도 잠긴 구역에는 못 짓는다", + GameState.build_house("anchor", Catalog.ZONE_COLUMNS, 0) == -1) check("실패했으면 잔액 그대로", is_equal_approx(GameState.bubbles, 1e15)) GameState.unlocked_zones = 2 - check("열린 뒤에는 보인다", GameState.is_revealed("producer", "clownfish")) - check("열린 뒤에는 놓인다", - GameState.place("producer", "clownfish", Catalog.ZONE_COLUMNS, 0) >= 0) + check("열린 뒤에는 보인다", GameState.is_revealed("house", "anchor")) + check("열린 뒤에는 지어진다", + GameState.build_house("anchor", Catalog.ZONE_COLUMNS, 0) >= 0) func _test_zone_unlock() -> void: print("구역 해금") - GameState.reset() + GameState.reset(false) var need_beauty := GameState.next_zone_required_beauty() var cost := GameState.next_zone_cost() check("2구역 해금 조건이 정의되어 있다", need_beauty > 0.0 and cost > 0.0, @@ -221,10 +378,13 @@ func _test_zone_unlock() -> void: check("사유가 아름다움 부족이라고 나온다", GameState.next_zone_blocker().contains("꾸며야"), GameState.next_zone_blocker()) - # 앞 구역(얕은 바다)을 해초로 채워 아름다움을 올린다. 한 포기당 3. + # 집을 짓고 그 정원을 해초로 채우면 아름다움이 오른다. while GameState.beauty_in_zone(0) < need_beauty: - var spot := GameState.first_free_spot("producer", "seaweed") - if spot.x < 0 or GameState.place("producer", "seaweed", spot.x, spot.y) < 0: + if GameState.free_slots_in_zone(0) <= 0: + var spot := GameState.first_free_spot("amphora") + if spot.x < 0 or GameState.build_house("amphora", spot.x, spot.y) < 0: + break + elif not GameState.buy_fish("seaweed"): break check("칸 안에서 조건을 채울 수 있다", GameState.beauty_in_zone(0) >= need_beauty, "%f / %f" % [GameState.beauty_in_zone(0), need_beauty]) @@ -239,14 +399,16 @@ func _test_zone_unlock() -> void: func _test_zone_beauty_is_per_zone() -> void: print("구역별 아름다움") _rich_and_open() - GameState.place("producer", "seaweed", 0, 0) # 0구역, 아름다움 3 - GameState.place("producer", "clownfish", Catalog.ZONE_COLUMNS, 0) # 1구역, 아름다움 8 + _house_in(0) # 항아리, 아름다움 12 + GameState.buy_fish("seaweed") # 0구역, 아름다움 3 + _house_in(1) # 낡은 닻, 아름다움 40 + GameState.buy_fish("clownfish") # 1구역, 아름다움 8 - check("0구역에 3", is_equal_approx(GameState.beauty_in_zone(0), 3.0), + check("0구역에 15", is_equal_approx(GameState.beauty_in_zone(0), 15.0), str(GameState.beauty_in_zone(0))) - check("1구역에 8", is_equal_approx(GameState.beauty_in_zone(1), 8.0), + check("1구역에 48", is_equal_approx(GameState.beauty_in_zone(1), 48.0), str(GameState.beauty_in_zone(1))) - check("총합은 11", is_equal_approx(GameState.beauty, 11.0), str(GameState.beauty)) + check("총합은 63", is_equal_approx(GameState.beauty, 63.0), str(GameState.beauty)) check("건드리지 않은 구역은 0", is_equal_approx(GameState.beauty_in_zone(4), 0.0)) @@ -279,30 +441,34 @@ func _test_ramp_continuity() -> void: func _test_serialization_round_trip() -> void: print("저장 직렬화") _rich_and_open() - GameState.place("producer", "seaweed", 1, 1) - GameState.place("producer", "seaweed", 2, 1) - GameState.place("ornament", "shipwreck", Catalog.ZONE_COLUMNS + 1, 2) - GameState.upgrades["u_touch1"] = true - GameState.clicks = 42 - # 배치가 끝난 뒤에 잔액을 맞춘다. 배치는 값을 치르기 때문에 먼저 정하면 어긋난다. + GameState.build_house("amphora", 1, 1) + GameState.build_house("shipwreck", Catalog.ZONE_COLUMNS + 1, 2) + GameState.buy_fish("seaweed") + GameState.buy_fish("seaweed") + GameState.buy_fish("clownfish") + GameState.upgrades["u_ripple"] = true + # 배치가 끝난 뒤에 잔액을 맞춘다. 짓고 들이는 데 값이 들기 때문이다. GameState.bubbles = 12345.0 GameState.recalc() var bps_before := GameState.bps # 실제 저장 경로와 똑같이 JSON 문자열을 거쳐서 되돌린다. var text := JSON.stringify(GameState.to_dict()) - GameState.reset() + GameState.reset(false) GameState.from_dict(JSON.parse_string(text)) check("보유량 복원", is_equal_approx(GameState.bubbles, 12345.0), str(GameState.bubbles)) - check("배치 개수 복원", GameState.placements.size() == 3, str(GameState.placements.size())) + check("집 수 복원", GameState.houses.size() == 2, str(GameState.houses.size())) var wreck := Catalog.ZONE_COLUMNS + 1 - check("배치된 칸까지 그대로", - GameState.placement_at(2, 1) >= 0 and GameState.placement_at(wreck, 2) >= 0) + check("집이 선 칸까지 그대로", + GameState.house_at(1, 1) >= 0 and GameState.house_at(wreck, 2) >= 0) check("난파선 발자국도 복원", - GameState.placement_at(wreck + 2, 3) == GameState.placement_at(wreck, 2)) - check("업그레이드 복원", GameState.has_upgrade("u_touch1")) - check("클릭 수 복원", GameState.clicks == 42) + GameState.house_at(wreck + 2, 3) == GameState.house_at(wreck, 2)) + check("생물 마릿수 복원", GameState.fish_count("seaweed") == 2 + and GameState.fish_count("clownfish") == 1) + check("정원도 그대로", GameState.capacity_in_zone(0) == Catalog.HOUSE_CAPACITY, + str(GameState.capacity_in_zone(0))) + check("업그레이드 복원", GameState.has_upgrade("u_ripple")) check("열린 구역 수 복원", GameState.unlocked_zones == Catalog.zone_count(), str(GameState.unlocked_zones)) check("초당 생산량이 그대로", is_equal_approx(GameState.bps, bps_before), @@ -311,26 +477,48 @@ func _test_serialization_round_trip() -> void: func _test_legacy_save_migration() -> void: print("옛 저장본 이전") - GameState.reset() - # 격자가 없던 시절의 저장본. 개수만 있고 위치가 없다. + # 생물도 칸을 차지하던 시절. 장식이 지금의 집이 되고, 생물은 마릿수만 남는다. + GameState.reset(false) GameState.from_dict({ "bubbles": 500.0, "unlocked_zones": 2, - "producers": {"seaweed": 4, "clownfish": 2}, + "placements": [ + {"kind": "producer", "id": "seaweed", "col": 0, "row": 0}, + {"kind": "producer", "id": "seaweed", "col": 1, "row": 0}, + {"kind": "ornament", "id": "amphora", "col": 3, "row": 0}, + {"kind": "producer", "id": "clownfish", "col": Catalog.ZONE_COLUMNS, "row": 0}, + {"kind": "ornament", "id": "anchor", "col": Catalog.ZONE_COLUMNS + 2, "row": 0}, + ], + }) + check("장식이 집이 된다", GameState.houses.size() == 2, str(GameState.houses.size())) + check("집이 선 칸은 그대로", GameState.house_at(3, 0) >= 0) + check("생물은 마릿수로 남는다", GameState.fish_count("seaweed") == 2, + str(GameState.fish_count("seaweed"))) + check("생물은 칸을 놓아준다", GameState.house_at(0, 0) == -1) + check("각자 제 구역에 남는다", GameState.fish_in_zone(0) == 2 + and GameState.fish_in_zone(1) == 1) + check("보유량은 그대로", is_equal_approx(GameState.bubbles, 500.0)) + + # 정원보다 많이 살고 있으면 집을 세워 다 들어가게 해준다. + GameState.reset(false) + GameState.from_dict({ + "bubbles": 1e9, + "producers": {"seaweed": 10}, "ornaments": {"amphora": true}, }) - check("개수만큼 칸에 놓인다", GameState.placements.size() == 7, - str(GameState.placements.size())) - check("해초 4포기", GameState.count_of("seaweed") == 4, str(GameState.count_of("seaweed"))) - check("장식도 옮겨온다", GameState.has_ornament("amphora")) - var all_in_zone := true - for p in GameState.placements: - if Catalog.zone_of_column(p.col) != Catalog.zone_of(p.kind, p.id): - all_in_zone = false - check("각자 제 구역에 놓인다", all_in_zone) + check("옛 개수 저장본도 옮겨온다", GameState.fish_count("seaweed") == 10, + str(GameState.fish_count("seaweed"))) + check("한 마리도 내쫓지 않는다", GameState.fish_total == 10, str(GameState.fish_total)) + check("정원이 모자라면 집을 더 세워준다", GameState.capacity_in_zone(0) >= 10, + "정원 %d / 생물 %d" % [GameState.capacity_in_zone(0), GameState.fish_total]) + check("보이지 않는 생물이 남지 않는다", GameState.free_slots_in_zone(0) >= 0, + str(GameState.free_slots_in_zone(0))) + # 집도 생물도 없는 저장본은 시작 상태를 채워준다. GameState.from_dict({"bubbles": 1.0}) check("구역 개념이 없던 저장본은 1구역으로", GameState.unlocked_zones == 1) + check("빈 저장본에는 집을 세워준다", GameState.houses.size() > 0) + check("빈 저장본에는 생물도 넣어준다", GameState.fish_total > 0) func _test_number_format() -> void: @@ -355,3 +543,67 @@ func _test_korean_particles() -> void: if not Korean.eul(String(z.name)).begins_with(String(z.name)): ok = false check("구역 이름 전부 처리된다", ok) + + +func _test_shape_families() -> void: + print("형태 계통") + # 집과 생물이 같은 도형을 쓰면 화면에서 "건물"과 "사는 것"이 구분되지 않는다. + # 그래서 두 계통을 아예 겹치지 않게 갈라 두고, 그것을 여기서 지킨다. + const CREATURE_SHAPES := ["plant", "rock", "fish", "glow"] + const HOUSE_SHAPES := ["vessel", "wreck", "pillar"] + + var bad_creature := "" + for d in Catalog.PRODUCERS: + if not String(d.shape) in CREATURE_SHAPES: + bad_creature = "%s: %s" % [d.name, d.shape] + check("생물은 생물 형태만 쓴다", bad_creature.is_empty(), bad_creature) + + var bad_house := "" + for d in Catalog.HOUSES: + if not String(d.shape) in HOUSE_SHAPES: + bad_house = "%s: %s" % [d.name, d.shape] + check("집은 집 형태만 쓴다", bad_house.is_empty(), bad_house) + + var overlap := false + for s in HOUSE_SHAPES: + if s in CREATURE_SHAPES: + overlap = true + check("두 계통이 겹치지 않는다", not overlap) + + # 헤엄치는 것이 하나도 없는 구역이 있으면 "집을 도는" 움직임을 볼 수 없다. + var swimmerless := "" + for z in Catalog.zone_count(): + var has_swimmer := false + for d in Catalog.PRODUCERS: + if int(d.get("zone", 0)) == z and String(d.shape) in ["fish", "glow"]: + has_swimmer = true + if not has_swimmer: + swimmerless = String(Catalog.zone(z).name) + check("구역마다 헤엄치는 생물이 있다", swimmerless.is_empty(), swimmerless) + + var starter_swims := false + for id in Catalog.STARTER_FISH: + if String(Catalog.producer(String(id)).shape) in ["fish", "glow"]: + starter_swims = true + check("시작 생물에도 헤엄치는 것이 있다", starter_swims) + + +func _test_upgrades_are_all_production() -> void: + print("업그레이드") + var all_bps := true + for u in Catalog.UPGRADES: + if String(u.kind) != "bps_mult": + all_bps = false + check("업그레이드는 전부 생산 배율", all_bps) + + _rich_and_open() + _house_in(0) + GameState.buy_fish("seaweed") + var before := GameState.bps + var id := String(Catalog.UPGRADES[0].id) + check("살 수 있다", GameState.buy_upgrade(id)) + check("초당 생산량이 오른다", GameState.bps > before, + "%f -> %f" % [before, GameState.bps]) + check("같은 것을 두 번은 못 산다", not GameState.buy_upgrade(id)) + check("업그레이드는 칸도 자리도 안 먹는다", + GameState.free_cells_in_zone(0) > 0 and GameState.capacity_in_zone(0) > 0)