클리커에서 집·일꾼 방치형 구조로 전환

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

격자 칸을 차지하는 것은 집뿐이고, 생물은 칸이 아니라 집에 산다(한 채당 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
This commit is contained in:
2026-09-01 22:39:25 +09:00
parent b490e463b0
commit 89069b06f0
11 changed files with 1548 additions and 578 deletions
+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)))