바탕화면 바닷속 방치형 위젯 첫 커밋

화면 아래 가장자리에 가로로 깔리는 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:
2026-09-01 17:59:50 +09:00
commit 446d8d177c
48 changed files with 3584 additions and 0 deletions
+457
View File
@@ -0,0 +1,457 @@
extends Node
## 게임의 진행 상태와 파생 수치를 들고 있는 단일 저장소.
## 저장/불러오기는 SaveManager가, 밸런스 수치는 Catalog가 담당한다.
##
## 바다는 탑다운 격자이고, 산 것은 모두 격자 위 특정 칸에 놓인다.
## 즉 "몇 개 샀는가"가 아니라 "어디에 무엇이 있는가"가 진짜 상태다.
## 구매·이동·업그레이드처럼 화면을 다시 그려야 하는 이산적인 변화.
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] = []
var upgrades := {} ## id -> true
var unlocked_zones := 1 ## 왼쪽부터 몇 개 구역이 열려 있는지 (최소 1)
var first_played_unix := 0
# --- recalc()로 계산되는 파생 값 (저장하지 않음) ---
var bps := 0.0 ## 초당 거품 생산량 (모든 보너스 반영)
var beauty := 0.0 ## 총 아름다움
var zone_beauty: Array[float] = [] ## 구역별 아름다움
var click_power := 1.0 ## 클릭 한 번당 거품
var beauty_mult := 1.0
var _counts := {} ## id -> 놓인 개수
var _occupancy := PackedInt32Array() ## 칸 -> placements 인덱스, 빈 칸은 -1
func _ready() -> void:
first_played_unix = int(Time.get_unix_time_from_system())
recalc()
func _process(delta: float) -> void:
if bps > 0.0:
_earn(bps * delta)
func _earn(amount: float) -> void:
bubbles += amount
total_bubbles += amount
## 물을 클릭했을 때. 실제로 얻은 양을 돌려준다(연출용).
func click() -> float:
clicks += 1
_earn(click_power)
return click_power
# --- 격자 ---
func columns() -> int:
return Catalog.total_columns()
func rows() -> int:
return Catalog.GRID_ROWS
func in_grid(col: int, row: int) -> bool:
return col >= 0 and row >= 0 and col < columns() and row < rows()
func _cell(col: int, row: int) -> int:
return row * columns() + col
## 그 칸에 놓인 것의 placements 인덱스. 비었으면 -1.
func placement_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)
var cells: Array[Vector2i] = []
for dx in s.x:
for dy in s.y:
if not in_grid(col + dx, row + dy):
return []
cells.append(Vector2i(col + dx, row + dy))
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)
if not is_zone_unlocked(item_zone):
return false
var cells := footprint(kind, 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)
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:
free += 1
return free
## 그 아이템을 놓을 수 있는 첫 칸. 없으면 null 대신 Vector2i(-1, -1).
func first_free_spot(kind: String, id: String) -> Vector2i:
var z := Catalog.zone_of(kind, 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):
return Vector2i(col, row)
return Vector2i(-1, -1)
# --- 조회 ---
func count_of(id: String) -> int:
return _counts.get(id, 0)
func has_ornament(id: String) -> bool:
return count_of(id) > 0
func has_upgrade(id: String) -> bool:
return upgrades.has(id)
func producer_cost(id: String) -> float:
var d := Catalog.producer(id)
if d.is_empty():
return INF
return ceil(float(d.base_cost) * pow(Catalog.COST_GROWTH, count_of(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 owned(kind: String, id: String) -> bool:
match kind:
"ornament": return has_ornament(id)
"upgrade": return has_upgrade(id)
return false
func is_zone_unlocked(index: int) -> bool:
return index < unlocked_zones
## 상점에 노출할지 여부.
## 아직 열리지 않은 구역의 물건은 숨기고, 열린 구역 안에서는
## 가격의 절반까지 벌어본 적이 있으면 보여준다(점진 공개).
func is_revealed(kind: String, id: String) -> bool:
if not is_zone_unlocked(Catalog.zone_of(kind, id)):
return false
# 구역을 막 열었을 때 목록이 비어 보이지 않도록 그 구역의 첫 항목은 항상 보인다.
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):
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):
return -1
if not can_place_at(kind, id, col, row):
return -1
var cost := cost_of(kind, id)
if not is_finite(cost) or bubbles < cost:
return -1
bubbles -= cost
placements.append({"kind": kind, "id": id, "col": col, "row": row})
recalc()
placed.emit(placements.size() - 1)
state_changed.emit()
return placements.size() - 1
## 이미 놓인 것을 다른 칸으로 옮긴다. 공짜다.
func move(index: int, col: int, row: int) -> bool:
if index < 0 or index >= placements.size():
return false
var p := placements[index]
if not can_place_at(p.kind, p.id, col, row, index):
return false
p.col = col
p.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)
placements.remove_at(index)
bubbles += refund
recalc()
reloaded.emit()
state_changed.emit()
return refund
func buy_upgrade(id: String) -> bool:
if has_upgrade(id):
return false
var cost := cost_of("upgrade", id)
if not is_finite(cost) or bubbles < cost:
return false
bubbles -= cost
upgrades[id] = true
recalc()
state_changed.emit()
return true
# --- 구역 해금 ---
func next_zone_index() -> int:
return unlocked_zones
func has_next_zone() -> bool:
return unlocked_zones < Catalog.zone_count()
func next_zone_cost() -> float:
if not has_next_zone():
return INF
return float(Catalog.zone(next_zone_index()).unlock_cost)
## 다음 구역을 열려면 바로 앞 구역에 이만큼의 아름다움이 있어야 한다.
func next_zone_required_beauty() -> float:
if not has_next_zone():
return 0.0
return float(Catalog.zone(next_zone_index()).require_beauty)
func beauty_in_zone(index: int) -> float:
if index < 0 or index >= zone_beauty.size():
return 0.0
return zone_beauty[index]
## 다음 구역을 열 수 없다면 그 이유를, 열 수 있다면 빈 문자열을 돌려준다.
func next_zone_blocker() -> String:
if not has_next_zone():
return "마지막 구역까지 모두 열었어요"
var need_beauty := next_zone_required_beauty()
var have := beauty_in_zone(next_zone_index() - 1)
if have < need_beauty:
return "%s 더 꾸며야 해요 (아름다움 %s / %s)" % [
Korean.eul(String(Catalog.zone(next_zone_index() - 1).name)),
NumberFormat.short(have), NumberFormat.short(need_beauty),
]
if bubbles < next_zone_cost():
return "거품이 모자라요 (%s / %s)" % [
NumberFormat.short(bubbles), NumberFormat.short(next_zone_cost()),
]
return ""
func can_unlock_next() -> bool:
return next_zone_blocker().is_empty()
func unlock_next_zone() -> bool:
if not can_unlock_next():
return false
bubbles -= next_zone_cost()
var opened := unlocked_zones
unlocked_zones += 1
recalc()
zone_unlocked.emit(opened)
state_changed.emit()
return true
# --- 파생 값 재계산 ---
func recalc() -> void:
_counts.clear()
_occupancy.resize(columns() * rows())
_occupancy.fill(-1)
zone_beauty.clear()
zone_beauty.resize(Catalog.zone_count())
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)
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):
_occupancy[_cell(c.x, c.y)] = i
_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)
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)
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)
beauty = 0.0
for b in zone_beauty:
beauty += b
# 꾸밀수록 생산이 빨라지도록. 제곱근이라 초반 보상이 크고 후반은 완만하다.
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
func _add_zone_beauty(index: int, amount: float) -> void:
if index >= 0 and index < zone_beauty.size():
zone_beauty[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})
return {
"bubbles": bubbles,
"total_bubbles": total_bubbles,
"clicks": clicks,
"placements": rows_out,
"upgrades": upgrades.duplicate(),
"unlocked_zones": unlocked_zones,
"first_played_unix": first_played_unix,
}
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()))
upgrades.clear()
for id in d.get("upgrades", {}):
upgrades[id] = true
placements.clear()
recalc()
if d.has("placements"):
for raw in d["placements"]:
_restore(String(raw.get("kind", "producer")), String(raw.get("id", "")),
int(raw.get("col", 0)), int(raw.get("row", 0)))
else:
_migrate_from_counts(d)
recalc()
reloaded.emit()
state_changed.emit()
## 저장된 칸이 비어 있으면 그대로, 겹치거나 규칙에 어긋나면 빈 칸을 찾아 옮겨 놓는다.
## 격자 크기나 구역 구성이 바뀌어도 진행도를 잃지 않기 위한 것.
func _restore(kind: String, id: String, col: int, row: int) -> void:
if Catalog.item(kind, id).is_empty():
return
if not can_place_at(kind, id, col, row):
var spot := first_free_spot(kind, id)
if spot.x < 0:
return
col = spot.x
row = spot.y
placements.append({"kind": kind, "id": id, "col": col, "row": row})
recalc()
## 격자가 없던 시절의 저장본(개수만 있음)을 빈 칸에 차례로 놓아 되살린다.
func _migrate_from_counts(d: Dictionary) -> void:
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)
for id in d.get("ornaments", {}):
var spot := first_free_spot("ornament", String(id))
if spot.x >= 0:
_restore("ornament", String(id), spot.x, spot.y)
func reset() -> void:
bubbles = 0.0
total_bubbles = 0.0
clicks = 0
placements.clear()
upgrades.clear()
unlocked_zones = 1
first_played_unix = int(Time.get_unix_time_from_system())
recalc()
reloaded.emit()
state_changed.emit()