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

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

격자 칸을 차지하는 것은 집뿐이고, 생물은 칸이 아니라 집에 산다(한 채당 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
+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)