바탕화면 바닷속 방치형 위젯 첫 커밋
화면 아래 가장자리에 가로로 깔리는 Godot 4.7 위젯. 거품을 모아 바다를 꾸미고, 꾸밀수록 거품이 더 빨리 모인다. - 도킹 창: 투명·테두리 없음, 화면 폭 전체, 위/아래 가장자리 스냅, 높이 프리셋, 클릭 통과(모서리로 복귀), DPI 배율 보정 - 3/4 부감 격자: 구역당 9x6, 전체 45x6 = 270칸. 바닥은 위에서, 물체는 서 있는 모습으로 그리고 Y 정렬로 앞뒤를 가린다 - 구역 5개(얕은 바다 -> 열수구): 앞 구역을 꾸며야 다음이 열린다 - 칸이 곧 한계인 경제: 배치/이동/치우기(절반 환불), 여러 칸짜리 장식 - 저장: 30초 자동 저장, 백업 한 세대, 8시간 상한 오프라인 보상, 격자 이전 형식 저장본 자동 이전 - 스모크 테스트 60여 개 (test.ps1) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+357
@@ -0,0 +1,357 @@
|
||||
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()
|
||||
|
||||
_test_click()
|
||||
_test_producer_cost_curve()
|
||||
_test_place()
|
||||
_test_footprint_and_multi_tile()
|
||||
_test_zone_boundaries()
|
||||
_test_move()
|
||||
_test_remove_refund()
|
||||
_test_space_is_the_limit()
|
||||
_test_zone_gating()
|
||||
_test_zone_unlock()
|
||||
_test_zone_beauty_is_per_zone()
|
||||
_test_ramp_continuity()
|
||||
_test_serialization_round_trip()
|
||||
_test_legacy_save_migration()
|
||||
_test_number_format()
|
||||
_test_korean_particles()
|
||||
|
||||
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()
|
||||
GameState.unlocked_zones = Catalog.zone_count()
|
||||
GameState.bubbles = 1e15
|
||||
|
||||
|
||||
func _test_click() -> void:
|
||||
print("클릭")
|
||||
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))
|
||||
|
||||
|
||||
func _test_producer_cost_curve() -> 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])
|
||||
|
||||
|
||||
func _test_place() -> void:
|
||||
print("배치")
|
||||
GameState.reset()
|
||||
GameState.bubbles = 1000.0
|
||||
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))
|
||||
|
||||
check("같은 칸에 또 놓을 수 없다", GameState.place("producer", "seaweed", 2, 3) == -1)
|
||||
check("격자 밖에는 놓을 수 없다", GameState.place("producer", "seaweed", 2, 99) == -1)
|
||||
|
||||
GameState.bubbles = 5.0
|
||||
check("잔액이 모자라면 못 놓는다", GameState.place("producer", "bubble_stone", 5, 0) == -1)
|
||||
check("실패했으면 잔액 그대로", is_equal_approx(GameState.bubbles, 5.0))
|
||||
|
||||
|
||||
func _test_footprint_and_multi_tile() -> void:
|
||||
print("여러 칸짜리")
|
||||
_rich_and_open()
|
||||
var span := Catalog.size_of("ornament", "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 covered := 0
|
||||
for col in range(left, left + 3):
|
||||
for row in 2:
|
||||
if GameState.placement_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)
|
||||
|
||||
|
||||
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))
|
||||
# 항아리(2x1)를 마지막 열에 놓으면 다음 구역까지 걸친다.
|
||||
check("구역을 걸치면 못 놓는다",
|
||||
not GameState.can_place_at("ornament", "amphora", last, 0))
|
||||
check("한 구역 안에 들어가면 놓인다",
|
||||
GameState.can_place_at("ornament", "amphora", last - 1, 0))
|
||||
|
||||
|
||||
func _test_move() -> void:
|
||||
print("옮기기")
|
||||
_rich_and_open()
|
||||
var index := GameState.place("producer", "seaweed", 1, 1)
|
||||
var spent := GameState.bubbles
|
||||
|
||||
check("빈 칸으로 옮겨진다", GameState.move(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)
|
||||
|
||||
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))
|
||||
|
||||
|
||||
func _test_remove_refund() -> void:
|
||||
print("치우기와 환불")
|
||||
_rich_and_open()
|
||||
GameState.place("producer", "seaweed", 0, 0)
|
||||
var index := GameState.place("producer", "seaweed", 1, 0)
|
||||
var before := GameState.bubbles
|
||||
# 두 번째 해초 가격은 올림(110 * 1.15). 그 절반이 돌아온다.
|
||||
var expected: float = floor(ceil(110.0 * 1.15) * Catalog.REFUND_RATE)
|
||||
|
||||
var refund := GameState.remove(index)
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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,
|
||||
str(GameState.free_cells_in_zone(0)))
|
||||
|
||||
var placed := 0
|
||||
while true:
|
||||
var spot := GameState.first_free_spot("producer", "seaweed")
|
||||
if spot.x < 0:
|
||||
break
|
||||
if GameState.place("producer", "seaweed", spot.x, spot.y) < 0:
|
||||
break
|
||||
placed += 1
|
||||
|
||||
check("칸이 차면 더 못 놓는다", placed == total, str(placed))
|
||||
check("남은 칸 0", GameState.free_cells_in_zone(0) == 0)
|
||||
check("거품이 남아도 못 놓는다", GameState.place("producer", "seaweed", 0, 0) == -1)
|
||||
check("다른 구역은 아직 비어 있다",
|
||||
GameState.free_cells_in_zone(1) == total, str(GameState.free_cells_in_zone(1)))
|
||||
|
||||
|
||||
func _test_zone_gating() -> void:
|
||||
print("구역 잠금")
|
||||
GameState.reset()
|
||||
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("실패했으면 잔액 그대로", 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)
|
||||
|
||||
|
||||
func _test_zone_unlock() -> void:
|
||||
print("구역 해금")
|
||||
GameState.reset()
|
||||
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())
|
||||
|
||||
# 앞 구역(얕은 바다)을 해초로 채워 아름다움을 올린다. 한 포기당 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:
|
||||
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()
|
||||
GameState.place("producer", "seaweed", 0, 0) # 0구역, 아름다움 3
|
||||
GameState.place("producer", "clownfish", Catalog.ZONE_COLUMNS, 0) # 1구역, 아름다움 8
|
||||
|
||||
check("0구역에 3", is_equal_approx(GameState.beauty_in_zone(0), 3.0),
|
||||
str(GameState.beauty_in_zone(0)))
|
||||
check("1구역에 8", is_equal_approx(GameState.beauty_in_zone(1), 8.0),
|
||||
str(GameState.beauty_in_zone(1)))
|
||||
check("총합은 11", is_equal_approx(GameState.beauty, 11.0), str(GameState.beauty))
|
||||
check("건드리지 않은 구역은 0", is_equal_approx(GameState.beauty_in_zone(4), 0.0))
|
||||
|
||||
|
||||
func _test_ramp_continuity() -> 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 max_jump := 0.0
|
||||
var prev := Catalog.ramp_color("tint", 0.0)
|
||||
for i in range(1, 201):
|
||||
var c := Catalog.ramp_color("tint", float(i) / 200.0)
|
||||
max_jump = maxf(max_jump, absf(c.r - prev.r) + absf(c.g - prev.g) + absf(c.b - prev.b))
|
||||
prev = c
|
||||
check("램프에 색이 튀는 곳이 없다", max_jump < 0.05, "최대 변화 %f" % max_jump)
|
||||
# 구역 중심에서는 그 구역 값 그대로여야 한다.
|
||||
var center_ok := true
|
||||
for i in n:
|
||||
var f := (float(i) + 0.5) / n
|
||||
if not Catalog.ramp_color("sand", f).is_equal_approx(Catalog.ZONES[i].sand):
|
||||
center_ok = false
|
||||
if not is_equal_approx(Catalog.ramp_float("murk", f), float(Catalog.ZONES[i].murk)):
|
||||
center_ok = false
|
||||
check("구역 중심은 그 구역 값 그대로", center_ok)
|
||||
|
||||
|
||||
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.bubbles = 12345.0
|
||||
GameState.recalc()
|
||||
var bps_before := GameState.bps
|
||||
|
||||
# 실제 저장 경로와 똑같이 JSON 문자열을 거쳐서 되돌린다.
|
||||
var text := JSON.stringify(GameState.to_dict())
|
||||
GameState.reset()
|
||||
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()))
|
||||
var wreck := Catalog.ZONE_COLUMNS + 1
|
||||
check("배치된 칸까지 그대로",
|
||||
GameState.placement_at(2, 1) >= 0 and GameState.placement_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)
|
||||
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()
|
||||
# 격자가 없던 시절의 저장본. 개수만 있고 위치가 없다.
|
||||
GameState.from_dict({
|
||||
"bubbles": 500.0,
|
||||
"unlocked_zones": 2,
|
||||
"producers": {"seaweed": 4, "clownfish": 2},
|
||||
"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)
|
||||
|
||||
GameState.from_dict({"bubbles": 1.0})
|
||||
check("구역 개념이 없던 저장본은 1구역으로", GameState.unlocked_zones == 1)
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bldorg6w5weud
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/smoke.gd" id="1_smoke"]
|
||||
|
||||
[node name="Smoke" type="Node"]
|
||||
script = ExtResource("1_smoke")
|
||||
Reference in New Issue
Block a user