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()