Compare commits

...

2 Commits

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

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

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

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

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

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

스모크 테스트 159개.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uze4mF1NAGfFRio77cMFqg
2026-09-01 22:39:25 +09:00
ttipo b490e463b0 Godot 실행 스크립트가 설치 경로를 스스로 찾도록
run.ps1과 test.ps1이 %LOCALAPPDATA%\Programs\Godot 한 곳만 보고 있어서 다른 데
설치하면 "Godot을 찾을 수 없습니다"로 끝났다. find-godot.ps1을 두어 GODOT_BIN
환경 변수 → 알려진 설치 폴더들(바탕화면·다운로드 포함) → PATH 순으로 찾고,
필요한 판(콘솔/일반)을 가려 쓰게 했다. 이름으로 훑기 때문에 버전이 올라가도 잡힌다.

클론 직후에는 .godot이 없어 class_name이 등록되지 않는다. 그 상태로 실행하면
game_state.gd 파싱이 실패해 오토로드가 통째로 죽으므로, 두 스크립트가 먼저
--import를 한 번 돌린다.

세 파일 모두 UTF-8 BOM으로 저장했다. BOM이 없으면 Windows PowerShell 5.1이
cp949로 읽어 한글 메시지가 깨진다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uze4mF1NAGfFRio77cMFqg
2026-09-01 22:38:56 +09:00
14 changed files with 1630 additions and 584 deletions
+58 -25
View File
@@ -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`)
+95 -57
View File
@@ -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))
+332 -139
View File
@@ -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)
+11 -11
View File
@@ -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)))
+121 -28
View File
@@ -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. **폭을 화면 일부만 쓰기** — 지금은 항상 폭 전체다. 왼쪽/가운데/오른쪽 정렬과
폭 비율 선택이 있으면 넓은 모니터에서 덜 부담스럽다.
## 한글 문장
+48
View File
@@ -0,0 +1,48 @@
# Godot 4 실행 파일을 찾는다. 못 찾으면 아무것도 내놓지 않는다.
# .\find-godot.ps1 그냥 실행판 (창이 뜬다)
# .\find-godot.ps1 -Console 콘솔판 (--headless 용, 출력이 보인다)
# 찾는 순서: $env:GODOT_BIN > 아래 폴더들 > PATH 위의 godot
param([switch]$Console)
$dirs = @(
"$env:LOCALAPPDATA\Programs\Godot",
[Environment]::GetFolderPath('Desktop'),
"$env:USERPROFILE\Desktop",
"$env:OneDrive\Desktop",
"$env:USERPROFILE\Downloads",
"$env:ProgramFiles\Godot"
)
$found = $null
if ($env:GODOT_BIN -and (Test-Path $env:GODOT_BIN)) {
$found = $env:GODOT_BIN
}
if (-not $found) {
foreach ($d in $dirs) {
if (-not $d -or -not (Test-Path $d)) { continue }
# 버전이 올라가도 잡히도록 이름으로 훑고, 높은 버전을 먼저 쓴다
$hit = Get-ChildItem -Path $d -Filter "Godot_v4*_win64.exe" -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -notlike "*_console.exe" } |
Sort-Object Name -Descending | Select-Object -First 1
if ($hit) { $found = $hit.FullName; break }
}
}
if (-not $found) {
$cmd = Get-Command godot -ErrorAction SilentlyContinue
if ($cmd) { $found = $cmd.Source }
}
if (-not $found) { return }
# 찾은 것과 원하는 판이 다르면 짝이 되는 파일로 바꿔 준다
$isConsole = $found -like "*_console.exe"
if ($Console -ne $isConsole) {
if ($Console) { $alt = $found -replace '\.exe$', '_console.exe' }
else { $alt = $found -replace '_console\.exe$', '.exe' }
if (Test-Path $alt) { $found = $alt }
}
return $found
+17 -4
View File
@@ -1,7 +1,20 @@
# 에디터 없이 게임만 바로 실행합니다. 사용: 우클릭 > PowerShell로 실행, 또는 .\run.ps1
$godot = "$env:LOCALAPPDATA\Programs\Godot\Godot_v4.7.2-stable_win64.exe"
if (-not (Test-Path $godot)) {
Write-Error "Godot 찾을 수 없습니다: $godot"
# 에디터 없이 게임만 바로 실행합니다. 사용: 우클릭 > PowerShell로 실행, 또는 .\run.ps1
$godot = & "$PSScriptRoot\find-godot.ps1"
if (-not $godot) {
Write-Error "Godot 4를 찾을 수 없습니다. 실행 파일 경로를 GODOT_BIN 환경 변수에 넣어 주세요."
exit 1
}
# 클론 직후엔 .godot이 없다. 한 번 가져와야 class_name이 등록되고 스크립트가 뜬다.
if (-not (Test-Path "$PSScriptRoot\.godot")) {
Write-Host "첫 실행입니다. 프로젝트를 가져오는 중..."
$importer = & "$PSScriptRoot\find-godot.ps1" -Console
if (-not $importer) { $importer = $godot }
& $importer --headless --import --path $PSScriptRoot
if ($LASTEXITCODE -ne 0) {
Write-Error "가져오기에 실패했습니다."
exit 1
}
}
Start-Process -FilePath $godot -ArgumentList @("--path", $PSScriptRoot) -WorkingDirectory $PSScriptRoot
+294 -119
View File
@@ -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)
+158 -5
View File
@@ -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)
+38 -20
View File
@@ -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)
+17 -16
View File
@@ -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
+40 -26
View File
@@ -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)
+17 -2
View File
@@ -1,4 +1,19 @@
# 경제 로직 스모크 테스트를 돌립니다. 사용: .\test.ps1
$godot = "$env:LOCALAPPDATA\Programs\Godot\Godot_v4.7.2-stable_win64_console.exe"
# 경제 로직 스모크 테스트를 돌립니다. 사용: .\test.ps1
$godot = & "$PSScriptRoot\find-godot.ps1" -Console
if (-not $godot) {
Write-Error "Godot 4를 찾을 수 없습니다. 실행 파일 경로를 GODOT_BIN 환경 변수에 넣어 주세요."
exit 1
}
# 클론 직후엔 .godot이 없다. 한 번 가져와야 class_name이 등록되고 스크립트가 뜬다.
if (-not (Test-Path "$PSScriptRoot\.godot")) {
Write-Host "첫 실행입니다. 프로젝트를 가져오는 중..."
& $godot --headless --import --path $PSScriptRoot
if ($LASTEXITCODE -ne 0) {
Write-Error "가져오기에 실패했습니다."
exit 1
}
}
& $godot --headless --path $PSScriptRoot res://tests/smoke.tscn
exit $LASTEXITCODE
+384 -132
View File
@@ -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)