1ca381922f
거품돌·해초·산호·켈프·관벌레는 물고기가 깃드는 자리지 그 자체가 일꾼이 아니다. 실제로 물고기는 바위 틈과 해초 사이를 보금자리로 삼는다. 전부 집으로 옮기고, 생물에는 움직이는 해양동물만 남겼다. - 집이 자연 서식지(rock/plant)와 지어 올린 것(vessel/wreck/pillar) 두 갈래가 되면서 정원에 폭이 생겼다. 거품돌 1, 해초·산호 2, 켈프·관벌레 4, 지어 올린 것은 표준 3. - 빈 자리를 메우려 게·해마·새우를 넣어 층마다 생물 둘을 맞췄다. 소라게·해마·열수구 새우. 형태도 셋 새로 그렸다. - 형태 계통이 그대로 경계가 된다. 생물 fish/glow/crab/seahorse/shrimp, 집 rock/plant/vessel/wreck/pillar. 겹치지 않는 것을 테스트가 지킨다. - 예전에 생물로 사 둔 돌·해초는 같은 id의 집으로 그만큼 지어 준다. 그냥 버리면 사 둔 것이 조용히 사라진다. 버그 둘 _fish_area/_orbit_center가 생물 자리를 grid_rect(화면 테두리)로 잘랐다. 가로로 이어 스크롤하던 시절에는 화면 = 바다여서 맞았지만, 층이 옆으로 늘어선 지금은 뒤쪽 층 생물이 전부 첫 층 오른쪽 끝으로 끌려와 제 층에서 사라졌다. _home_bounds()가 그 집이 속한 층의 테두리를 돌려준다. _make_fish가 fish와 glow만 궤도에 올려, 새로 넣은 게·해마·새우가 제자리에 굳어 있었다. 고착생물이 전부 집으로 갔으므로 가드를 걷어냈다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
763 lines
32 KiB
GDScript
763 lines
32 KiB
GDScript
extends Node
|
|
## 경제·배치 로직 스모크 테스트. 저장 파일은 건드리지 않고 메모리 안에서만 돈다.
|
|
## Godot_v4.7.2-stable_win64_console.exe --headless --path . res://tests/smoke.tscn
|
|
|
|
var _failures := 0
|
|
|
|
|
|
func _ready() -> void:
|
|
# 이 테스트는 실제 저장 파일을 건드리면 안 된다.
|
|
# 끄지 않으면 종료할 때 초기화된 상태가 저장되어 진행도가 날아간다.
|
|
SaveManager.persistence_enabled = false
|
|
GameState.reset(false)
|
|
|
|
_test_starting_state()
|
|
_test_auto_income()
|
|
_test_cost_curves()
|
|
_test_build_house()
|
|
_test_house_footprint()
|
|
_test_zone_boundaries()
|
|
_test_capacity()
|
|
_test_move_house()
|
|
_test_remove_house()
|
|
_test_release_fish()
|
|
_test_space_is_the_limit()
|
|
_test_zone_gating()
|
|
_test_zone_unlock()
|
|
_test_zone_beauty_is_per_zone()
|
|
_test_zone_colors()
|
|
_test_serialization_round_trip()
|
|
_test_legacy_save_migration()
|
|
_test_number_format()
|
|
_test_korean_particles()
|
|
_test_shape_families()
|
|
_test_upgrades_are_all_production()
|
|
_test_day_cycle()
|
|
_test_time_modes()
|
|
|
|
if _failures == 0:
|
|
print("모든 검사 통과")
|
|
else:
|
|
print("실패 %d건" % _failures)
|
|
get_tree().quit(1 if _failures > 0 else 0)
|
|
|
|
|
|
func check(label: String, condition: bool, detail := "") -> void:
|
|
if condition:
|
|
print(" ok ", label)
|
|
else:
|
|
_failures += 1
|
|
print(" FAIL ", label, " ", detail)
|
|
|
|
|
|
## 모든 구역을 열고 거품을 넉넉히 준 빈 바다에서 시작한다.
|
|
func _rich_and_open() -> void:
|
|
GameState.reset(false)
|
|
GameState.unlocked_zones = Catalog.zone_count()
|
|
GameState.bubbles = 1e15
|
|
|
|
|
|
## 그 구역에 집을 한 채 세워 자리를 만든다. 세운 집의 인덱스를 돌려준다.
|
|
## 정원이 표준(HOUSE_CAPACITY)인 집을 고른다 -- 거품돌처럼 정원이 다른 집을
|
|
## 기준으로 삼으면 자리 계산 검사가 통째로 흔들린다.
|
|
func _house_in(zone_index: int, id := "") -> int:
|
|
if id.is_empty():
|
|
for d in Catalog.HOUSES:
|
|
var same_zone := int(d.get("zone", 0)) == zone_index
|
|
if same_zone and Catalog.capacity_of(String(d.id)) == Catalog.HOUSE_CAPACITY:
|
|
id = String(d.id)
|
|
break
|
|
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()
|
|
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", "guppy")
|
|
and GameState.can_take_fish("guppy"))
|
|
|
|
var s: Dictionary = Catalog.STARTER_HOUSES[0]
|
|
check("집이 정해 둔 칸에 선다",
|
|
GameState.house_at(int(s.col), int(s.row)) >= 0)
|
|
|
|
|
|
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("hermit_crab")
|
|
check("생물이 들어와야 거품이 난다", GameState.bps > 0.0, str(GameState.bps))
|
|
|
|
var before_bps := GameState.bps
|
|
GameState.buy_fish("hermit_crab")
|
|
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("가격 곡선")
|
|
_rich_and_open()
|
|
check("첫 구피는 15",
|
|
is_equal_approx(GameState.cost_of("producer", "guppy"), 15.0),
|
|
str(GameState.cost_of("producer", "guppy")))
|
|
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("guppy")
|
|
var fish_second := GameState.cost_of("producer", "guppy")
|
|
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_build_house() -> void:
|
|
print("집 짓기")
|
|
_rich_and_open()
|
|
var before := GameState.bubbles
|
|
|
|
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.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.build_house("amphora", 5, 0) == -1)
|
|
check("실패했으면 잔액 그대로", is_equal_approx(GameState.bubbles, 5.0))
|
|
|
|
|
|
func _test_house_footprint() -> void:
|
|
print("여러 칸짜리 집")
|
|
_rich_and_open()
|
|
var span := Catalog.size_of("house", "shipwreck")
|
|
check("난파선은 3x2", span == Vector2i(3, 2), str(span))
|
|
|
|
var left := Catalog.ZONE_COLUMNS + 1
|
|
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.house_at(col, row) == index:
|
|
covered += 1
|
|
check("여섯 칸을 모두 차지한다", covered == 6, str(covered))
|
|
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()
|
|
var last := Catalog.ZONE_COLUMNS - 1
|
|
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_build_at("amphora", last, 0))
|
|
check("한 구역 안에 들어가면 선다", GameState.can_build_at("amphora", last - 1, 0))
|
|
|
|
|
|
func _test_capacity() -> void:
|
|
print("정원")
|
|
_rich_and_open()
|
|
check("집이 없으면 한 마리도 못 들인다", not GameState.can_take_fish("guppy"))
|
|
check("사유를 알려준다", GameState.fish_blocker("guppy").contains("빈 자리"),
|
|
GameState.fish_blocker("guppy"))
|
|
check("거품이 넘쳐도 못 들인다", not GameState.buy_fish("guppy"))
|
|
|
|
_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("guppy"))
|
|
check("정원이 다 찼다", GameState.free_slots_in_zone(0) == 0,
|
|
str(GameState.free_slots_in_zone(0)))
|
|
check("정원이 차면 더 못 들인다", not GameState.buy_fish("guppy"))
|
|
check("종류를 바꿔도 마찬가지", not GameState.buy_fish("hermit_crab"))
|
|
|
|
_house_in(0)
|
|
check("집을 더 지으면 다시 들일 수 있다", GameState.buy_fish("hermit_crab"))
|
|
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_house(index, 4, 2))
|
|
check("옮기는 건 공짜", is_equal_approx(GameState.bubbles, spent), str(GameState.bubbles))
|
|
check("옛 칸이 비었다", GameState.house_at(1, 1) == -1)
|
|
check("새 칸이 자기 것", GameState.house_at(4, 2) == index)
|
|
|
|
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("guppy")
|
|
check("살던 생물은 그대로 따라간다", GameState.move_house(index, 6, 4)
|
|
and GameState.fish_total == 1)
|
|
|
|
|
|
func _test_remove_house() -> void:
|
|
print("집 헐기")
|
|
_rich_and_open()
|
|
GameState.build_house("amphora", 0, 0)
|
|
var second := GameState.build_house("amphora", 2, 0)
|
|
var before := GameState.bubbles
|
|
# 두 번째 항아리 값은 올림(90 * 1.15). 그 절반이 돌아온다.
|
|
var expected: float = floor(ceil(90.0 * 1.15) * Catalog.REFUND_RATE)
|
|
|
|
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.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)))
|
|
|
|
# 살고 있는 생물이 갈 곳이 없으면 헐 수 없다.
|
|
for i in Catalog.HOUSE_CAPACITY:
|
|
GameState.buy_fish("guppy")
|
|
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("hermit_crab")
|
|
var second_cost := GameState.cost_of("producer", "hermit_crab")
|
|
GameState.buy_fish("hermit_crab")
|
|
var before := GameState.bubbles
|
|
var expected: float = floor(second_cost * Catalog.REFUND_RATE)
|
|
|
|
var refund := GameState.release_fish("hermit_crab")
|
|
check("절반을 돌려받는다", is_equal_approx(refund, expected),
|
|
"%f vs %f" % [refund, expected])
|
|
check("잔액에 더해진다", is_equal_approx(GameState.bubbles, before + expected))
|
|
check("마릿수가 줄었다", GameState.fish_count("hermit_crab") == 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("hermit_crab"))
|
|
check("내보내려 해도 아무것도 돌아오지 않다",
|
|
is_equal_approx(GameState.release_fish("hermit_crab"), 0.0))
|
|
check("그대로 남는다", GameState.fish_total == 1, str(GameState.fish_total))
|
|
|
|
GameState.buy_fish("guppy")
|
|
check("둘이 되면 내보낼 수 있다", GameState.can_release_fish("hermit_crab"))
|
|
check("실제로 나간다", GameState.release_fish("hermit_crab") > 0.0)
|
|
check("한 마리가 남는다", GameState.fish_total == 1, str(GameState.fish_total))
|
|
check("없는 종류는 못 내보낸다", not GameState.can_release_fish("hermit_crab"))
|
|
|
|
|
|
func _test_space_is_the_limit() -> void:
|
|
print("공간이 곧 한계")
|
|
_rich_and_open()
|
|
# 한 구역을 끝까지 채우려면 값이 1.15배씩 오르는 집을 200채 넘게 지어야 한다.
|
|
# 거품이 먼저 떨어지면 "칸이 차서" 멈춘 것인지 알 수 없다.
|
|
GameState.bubbles = 1e30
|
|
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)))
|
|
|
|
# 유리병 편지는 1x1이라 한 구역을 가장 촘촘하게 채운다.
|
|
var built := 0
|
|
while true:
|
|
var spot := GameState.first_free_spot("bottle_letter")
|
|
if spot.x < 0 or GameState.build_house("bottle_letter", spot.x, spot.y) < 0:
|
|
break
|
|
built += 1
|
|
|
|
check("칸이 차면 더 못 짓는다", built == cells, str(built))
|
|
check("남은 칸 0", GameState.free_cells_in_zone(0) == 0)
|
|
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) == cells, str(GameState.free_cells_in_zone(1)))
|
|
|
|
|
|
func _test_zone_gating() -> void:
|
|
print("구역 잠금")
|
|
GameState.reset(false)
|
|
GameState.bubbles = 1e15
|
|
check("시작은 1구역만 열림", GameState.unlocked_zones == 1)
|
|
# 각 구역의 첫 항목은 값을 벌기 전에도 보여야 한다. 안 그러면 상점이 비어 시작한다.
|
|
check("0구역 물건은 보인다", GameState.is_revealed("producer", "guppy")
|
|
and GameState.is_revealed("house", "bubble_stone"))
|
|
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("house", "coral"))
|
|
check("열린 뒤에는 지어진다",
|
|
GameState.build_house("anchor", Catalog.ZONE_COLUMNS, 0) >= 0)
|
|
|
|
|
|
func _test_zone_unlock() -> void:
|
|
print("구역 해금")
|
|
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,
|
|
"beauty=%f cost=%f" % [need_beauty, cost])
|
|
|
|
GameState.bubbles = cost * 4.0
|
|
check("앞 구역을 안 꾸미면 못 연다", not GameState.can_unlock_next())
|
|
check("사유가 아름다움 부족이라고 나온다",
|
|
GameState.next_zone_blocker().contains("꾸며야"), GameState.next_zone_blocker())
|
|
|
|
# 집을 짓고 그 정원을 해초로 채우면 아름다움이 오른다.
|
|
while GameState.beauty_in_zone(0) < need_beauty:
|
|
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("hermit_crab"):
|
|
break
|
|
check("칸 안에서 조건을 채울 수 있다", GameState.beauty_in_zone(0) >= need_beauty,
|
|
"%f / %f" % [GameState.beauty_in_zone(0), need_beauty])
|
|
|
|
GameState.bubbles = cost
|
|
check("조건을 채우면 열 수 있다", GameState.can_unlock_next(), GameState.next_zone_blocker())
|
|
check("해금 성공", GameState.unlock_next_zone())
|
|
check("해금 비용이 빠진다", is_equal_approx(GameState.bubbles, 0.0), str(GameState.bubbles))
|
|
check("열린 구역이 2개", GameState.unlocked_zones == 2)
|
|
|
|
|
|
func _test_zone_beauty_is_per_zone() -> void:
|
|
print("구역별 아름다움")
|
|
_rich_and_open()
|
|
_house_in(0) # 항아리, 아름다움 12
|
|
GameState.buy_fish("hermit_crab") # 0구역, 아름다움 4
|
|
_house_in(1) # 낡은 닻, 아름다움 40
|
|
GameState.buy_fish("clownfish") # 1구역, 아름다움 8
|
|
|
|
check("0구역에 16", is_equal_approx(GameState.beauty_in_zone(0), 16.0),
|
|
str(GameState.beauty_in_zone(0)))
|
|
check("1구역에 48", is_equal_approx(GameState.beauty_in_zone(1), 48.0),
|
|
str(GameState.beauty_in_zone(1)))
|
|
check("총합은 64", is_equal_approx(GameState.beauty, 64.0), str(GameState.beauty))
|
|
check("건드리지 않은 구역은 0", is_equal_approx(GameState.beauty_in_zone(4), 0.0))
|
|
|
|
|
|
## 한 화면에 한 층만 보이므로 층 색은 이웃과 섞지 않고 제 색을 그대로 쓴다.
|
|
func _test_zone_colors() -> void:
|
|
print("층 색")
|
|
var n := Catalog.zone_count()
|
|
check("왼쪽 끝은 첫 층 색",
|
|
Catalog.ramp_color("tint", 0.0).is_equal_approx(Catalog.ZONES[0].tint))
|
|
check("오른쪽 끝은 마지막 층 색",
|
|
Catalog.ramp_color("tint", 1.0).is_equal_approx(Catalog.ZONES[n - 1].tint))
|
|
|
|
# 층 안 어디를 찍어도 그 층의 색이어야 한다. 안 그러면 한 화면 안에서
|
|
# 좌우로 색이 흐르는데, 이제 깊이는 가로가 아니라 층으로 표현한다.
|
|
var flat := true
|
|
for i in n:
|
|
for k in 9:
|
|
var f := (float(i) + 0.05 + 0.9 * k / 8.0) / n
|
|
if not Catalog.ramp_color("sand", f).is_equal_approx(Catalog.ZONES[i].sand):
|
|
flat = false
|
|
if not is_equal_approx(Catalog.ramp_float("murk", f), float(Catalog.ZONES[i].murk)):
|
|
flat = false
|
|
check("층 안에서는 색이 평평하다", flat)
|
|
|
|
var deepens := true
|
|
for i in range(1, n):
|
|
if Catalog.ZONES[i].murk <= Catalog.ZONES[i - 1].murk:
|
|
deepens = false
|
|
check("아래층일수록 흐리다", deepens)
|
|
|
|
var picks := true
|
|
for i in n:
|
|
if Catalog.zone_at((float(i) + 0.5) / n) != i:
|
|
picks = false
|
|
check("가로 위치로 층을 바르게 찾는다", picks)
|
|
|
|
|
|
func _test_serialization_round_trip() -> void:
|
|
print("저장 직렬화")
|
|
_rich_and_open()
|
|
GameState.build_house("amphora", 1, 1)
|
|
GameState.build_house("shipwreck", Catalog.ZONE_COLUMNS + 1, 2)
|
|
GameState.buy_fish("hermit_crab")
|
|
GameState.buy_fish("hermit_crab")
|
|
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(false)
|
|
GameState.from_dict(JSON.parse_string(text))
|
|
|
|
check("보유량 복원", is_equal_approx(GameState.bubbles, 12345.0), str(GameState.bubbles))
|
|
check("집 수 복원", GameState.houses.size() == 2, str(GameState.houses.size()))
|
|
var wreck := Catalog.ZONE_COLUMNS + 1
|
|
check("집이 선 칸까지 그대로",
|
|
GameState.house_at(1, 1) >= 0 and GameState.house_at(wreck, 2) >= 0)
|
|
check("난파선 발자국도 복원",
|
|
GameState.house_at(wreck + 2, 3) == GameState.house_at(wreck, 2))
|
|
check("생물 마릿수 복원", GameState.fish_count("hermit_crab") == 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),
|
|
"%f vs %f" % [GameState.bps, bps_before])
|
|
|
|
|
|
func _test_legacy_save_migration() -> void:
|
|
print("옛 저장본 이전")
|
|
# 생물도 칸을 차지하던 시절. 장식이 지금의 집이 되고, 생물은 마릿수만 남는다.
|
|
# 돌·해초처럼 그때는 생물이었다가 지금은 집이 된 것은 그만큼 집으로 지어 준다.
|
|
GameState.reset(false)
|
|
GameState.from_dict({
|
|
"bubbles": 500.0,
|
|
"unlocked_zones": 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() == 4, str(GameState.houses.size()))
|
|
check("집이 선 칸은 그대로", GameState.house_at(3, 0) >= 0)
|
|
check("해초는 집으로 넘어온다", GameState.house_count("seaweed") == 2,
|
|
str(GameState.house_count("seaweed")))
|
|
check("해초는 더 이상 생물이 아니다", GameState.fish_count("seaweed") == 0)
|
|
check("헤엄치던 것은 마릿수로 남는다", GameState.fish_count("clownfish") == 1,
|
|
str(GameState.fish_count("clownfish")))
|
|
check("생물은 칸을 놓아준다", GameState.fish_total == 1, str(GameState.fish_total))
|
|
check("각자 제 구역에 남는다", GameState.fish_in_zone(1) == 1,
|
|
str(GameState.fish_in_zone(1)))
|
|
check("보유량은 그대로", is_equal_approx(GameState.bubbles, 500.0))
|
|
|
|
# 개수만 있던 저장본도 같은 규칙으로 옮겨온다.
|
|
GameState.reset(false)
|
|
GameState.from_dict({
|
|
"bubbles": 1e9,
|
|
"producers": {"seaweed": 10, "guppy": 4},
|
|
"ornaments": {"amphora": true},
|
|
})
|
|
check("옛 개수 저장본도 옮겨온다", GameState.house_count("seaweed") == 10,
|
|
str(GameState.house_count("seaweed")))
|
|
check("헤엄치던 것은 그대로 마릿수", GameState.fish_count("guppy") == 4,
|
|
str(GameState.fish_count("guppy")))
|
|
check("한 마리도 내쫓지 않는다", GameState.fish_total == 4, str(GameState.fish_total))
|
|
check("정원이 모자라면 집을 더 세워준다", GameState.capacity_in_zone(0) >= 4,
|
|
"정원 %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:
|
|
print("숫자 표기")
|
|
check("999", NumberFormat.short(999.0) == "999", NumberFormat.short(999.0))
|
|
check("1.23K", NumberFormat.short(1234.0) == "1.23K", NumberFormat.short(1234.0))
|
|
check("12.3M", NumberFormat.short(12_345_678.0) == "12.3M", NumberFormat.short(12_345_678.0))
|
|
check("1.00B", NumberFormat.short(1e9) == "1.00B", NumberFormat.short(1e9))
|
|
check("소수점", NumberFormat.short(2.5) == "2.5", NumberFormat.short(2.5))
|
|
check("시간 표기", NumberFormat.duration(3 * 3600 + 720) == "3시간 12분",
|
|
NumberFormat.duration(3 * 3600 + 720))
|
|
|
|
|
|
func _test_korean_particles() -> void:
|
|
print("한글 조사")
|
|
check("받침 있으면 을", Korean.eul("켈프 숲") == "켈프 숲을", Korean.eul("켈프 숲"))
|
|
check("받침 없으면 를", Korean.eul("얕은 바다") == "얕은 바다를", Korean.eul("얕은 바다"))
|
|
check("받침 있으면 이", Korean.i("등불") == "등불이", Korean.i("등불"))
|
|
check("받침 없으면 가", Korean.i("심해") == "심해가", Korean.i("심해"))
|
|
var ok := true
|
|
for z in Catalog.ZONES:
|
|
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 := ["fish", "glow", "crab", "seahorse", "shrimp"]
|
|
const HOUSE_SHAPES := ["rock", "plant", "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)
|
|
|
|
# 고착생물은 전부 집으로 옮겼다. 생물 목록에 다시 섞여 들어오면 안 된다.
|
|
var sessile := ""
|
|
for d in Catalog.PRODUCERS:
|
|
if String(d.shape) in ["plant", "rock"]:
|
|
sessile = String(d.name)
|
|
check("생물에 고착생물이 없다", sessile.is_empty(), sessile)
|
|
|
|
# 자연 서식지가 하나도 없는 구역이 있으면 값싼 첫 자리가 없어 시작이 막힌다.
|
|
var starter_home := true
|
|
for z in Catalog.zone_count():
|
|
var cheapest := INF
|
|
for d in Catalog.HOUSES:
|
|
if int(d.get("zone", 0)) == z:
|
|
cheapest = minf(cheapest, float(d.base_cost))
|
|
if not is_finite(cheapest):
|
|
starter_home = false
|
|
check("구역마다 지을 집이 있다", starter_home)
|
|
|
|
|
|
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("hermit_crab")
|
|
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)
|
|
|
|
|
|
func _test_day_cycle() -> void:
|
|
print("하루의 빛")
|
|
var before_mode := DayCycle.mode
|
|
var before_hour := DayCycle.fixed_hour
|
|
DayCycle.mode = DayCycle.Mode.FIXED
|
|
|
|
DayCycle.fixed_hour = 5.0
|
|
check("새벽 5시는 새벽", DayCycle.phase == "새벽", DayCycle.phase)
|
|
DayCycle.fixed_hour = 8.0
|
|
check("아침 8시는 아침", DayCycle.phase == "아침", DayCycle.phase)
|
|
DayCycle.fixed_hour = 13.0
|
|
check("오후 1시는 낮", DayCycle.phase == "낮", DayCycle.phase)
|
|
DayCycle.fixed_hour = 18.0
|
|
check("저녁 6시는 저녁", DayCycle.phase == "저녁", DayCycle.phase)
|
|
DayCycle.fixed_hour = 22.0
|
|
check("밤 10시는 밤", DayCycle.phase == "밤", DayCycle.phase)
|
|
# 자정을 넘긴 시각은 전날 밤이 이어져야 한다. 여기서 구간이 비면 이름이 빈다.
|
|
DayCycle.fixed_hour = 1.0
|
|
check("새벽 1시도 밤", DayCycle.phase == "밤", DayCycle.phase)
|
|
|
|
# 가장 밝은 시각이 한낮 언저리에 있어야 한다. 정점 시각 자체는 표에서 조절한다.
|
|
var peak_hour := 0.0
|
|
var peak_light := -1.0
|
|
for i in 480:
|
|
var h := float(i) / 20.0
|
|
var l := DayCycle.light_at(h)
|
|
if l > peak_light:
|
|
peak_light = l
|
|
peak_hour = h
|
|
check("가장 밝은 때가 한낮 언저리", peak_hour >= 10.0 and peak_hour <= 14.0,
|
|
"%.1f시" % peak_hour)
|
|
check("한낮 밝기는 1.0", is_equal_approx(peak_light, 1.0), str(peak_light))
|
|
DayCycle.fixed_hour = 0.0
|
|
var midnight := DayCycle.light
|
|
DayCycle.fixed_hour = 24.0
|
|
check("자정과 24시가 이어진다", is_equal_approx(DayCycle.light, midnight),
|
|
"%f vs %f" % [DayCycle.light, midnight])
|
|
|
|
# 빛이 튀면 해가 순간이동하는 것처럼 보인다. 하루를 훑으며 급변이 없는지 본다.
|
|
var max_jump := 0.0
|
|
DayCycle.fixed_hour = 0.0
|
|
var prev := DayCycle.light
|
|
for i in range(1, 481):
|
|
DayCycle.fixed_hour = float(i) / 20.0
|
|
max_jump = maxf(max_jump, absf(DayCycle.light - prev))
|
|
prev = DayCycle.light
|
|
check("빛이 튀는 곳이 없다", max_jump < 0.05, "최대 변화 %f" % max_jump)
|
|
|
|
var night := DayCycle.tint_at(2.0)
|
|
var noon := DayCycle.tint_at(12.0)
|
|
check("밤은 낮보다 어둡다", night.r + night.g + night.b < noon.r + noon.g + noon.b)
|
|
check("저녁은 붉은 쪽으로 기운다", DayCycle.tint_at(18.5).r > DayCycle.tint_at(18.5).b)
|
|
|
|
DayCycle.fixed_hour = before_hour
|
|
DayCycle.mode = before_mode
|
|
|
|
|
|
func _test_time_modes() -> void:
|
|
print("시간 설정")
|
|
var before_mode := DayCycle.mode
|
|
var before_hour := DayCycle.fixed_hour
|
|
|
|
DayCycle.fixed_hour = 22.0
|
|
DayCycle.mode = DayCycle.Mode.FIXED
|
|
check("고정하면 그 시각에 멈춘다", is_equal_approx(DayCycle.hour(), 22.0),
|
|
str(DayCycle.hour()))
|
|
check("고정한 시각의 이름이 맞다", DayCycle.phase == "밤", DayCycle.phase)
|
|
check("시계 문자열", DayCycle.clock_text() == "22:00", DayCycle.clock_text())
|
|
DayCycle.fixed_hour = 18.7
|
|
check("분까지 나온다", DayCycle.clock_text() == "18:42", DayCycle.clock_text())
|
|
|
|
# 24시를 넘겨 넣어도 하루 안으로 돌아와야 한다. 안 그러면 램프가 끝에 붙어 멈춘다.
|
|
DayCycle.fixed_hour = 26.0
|
|
check("24시를 넘기면 돌아 들어온다", is_equal_approx(DayCycle.hour(), 2.0),
|
|
str(DayCycle.hour()))
|
|
|
|
# 빠르게로 넘어갈 때 보던 시각에서 이어져야 한다. 0시로 튀면 한밤중으로 뚝 떨어진다.
|
|
DayCycle.fixed_hour = 15.0
|
|
DayCycle.mode = DayCycle.Mode.FIXED
|
|
DayCycle.mode = DayCycle.Mode.FAST
|
|
check("빠르게는 보던 시각에서 이어진다", absf(DayCycle.hour() - 15.0) < 0.01,
|
|
str(DayCycle.hour()))
|
|
|
|
var start := DayCycle.hour()
|
|
DayCycle._process(30.0)
|
|
var moved := DayCycle.hour() - start
|
|
# 하루 5분이면 30초에 2.4시간이 흐른다.
|
|
var expected := 30.0 * 24.0 / (DayCycle.FAST_DAY_MINUTES * 60.0)
|
|
check("빠르게는 압축한 만큼 흐른다", absf(moved - expected) < 0.05,
|
|
"%f vs %f" % [moved, expected])
|
|
|
|
DayCycle.mode = DayCycle.Mode.REAL
|
|
var now := Time.get_time_dict_from_system()
|
|
check("실제 시각으로 돌아온다", absf(DayCycle.hour() - float(now.hour)) < 1.01,
|
|
"%f vs %d시" % [DayCycle.hour(), now.hour])
|
|
|
|
# 메뉴에 올리는 프리셋이 모두 제 이름의 구간에 들어 있어야 한다.
|
|
var named := true
|
|
for p in DayCycle.FIXED_PRESETS:
|
|
if DayCycle.phase_at(float(p.hour)) != String(p.name):
|
|
named = false
|
|
check("고정 프리셋 이름이 구간과 맞는다", named)
|
|
|
|
DayCycle.fixed_hour = before_hour
|
|
DayCycle.mode = before_mode
|