extends Node ## 게임의 진행 상태와 파생 수치를 들고 있는 단일 저장소. ## 저장/불러오기는 SaveManager가, 밸런스 수치는 Catalog가 담당한다. ## ## 두 가지가 서로 다른 규칙으로 움직인다. ## 집(house) — 격자 칸을 차지한다. "어디에 무엇을 지을까"가 진짜 상태다. ## 생물(producer) — 칸을 차지하지 않고 집에 산다. 몇 마리인지만 센다. ## ## 거품은 오직 생물이 시간에 따라 번다. 집은 거품을 벌지 않고 자리와 아름다움을 준다. ## 그래서 성장은 "집을 지어 자리를 늘리고 그 자리를 생물로 채운다"의 반복이다. ## ## 어느 생물이 어느 집에 사는지는 저장하지 않는다. 집은 정원만 주므로 어느 집에 ## 살든 결과가 같고, 화면에 그릴 때 구역마다 차례로 배정하면 그만이다. ## 구매·이동·업그레이드처럼 화면을 다시 그려야 하는 이산적인 변화. signal state_changed ## 새 구역이 열렸을 때. signal zone_unlocked(index: int) ## 배치가 통째로 바뀌어 다시 그려야 할 때(불러오기, 초기화, 생물 증감, 집 철거). signal reloaded ## 집이 새로 지어졌을 때. 등장 연출에 쓴다. signal placed(index: int) # --- 저장되는 상태 --- var bubbles := 0.0 var total_bubbles := 0.0 ## [{id, col, row}, ...] — 지은 순서대로. 인덱스가 곧 식별자다. var houses: Array[Dictionary] = [] ## id -> 마리 수. 생물은 칸을 차지하지 않으므로 위치가 없다. var fish := {} 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 beauty_mult := 1.0 var fish_total := 0 ## 살고 있는 생물 수. 마지막 한 마리를 지키는 데 쓴다. var _house_counts := {} ## id -> 지은 채수 var _zone_capacity: Array[int] = [] ## 구역별 정원 합 var _zone_fish: Array[int] = [] ## 구역별 살고 있는 수 var _occupancy := PackedInt32Array() ## 칸 -> houses 인덱스, 빈 칸은 -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 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 ## 그 칸에 선 집의 houses 인덱스. 비었으면 -1. func house_at(col: int, row: int) -> int: if not in_grid(col, row): return -1 return _occupancy[_cell(col, row)] ## 집이 (col,row)에 섰을 때 덮는 칸들. 격자를 벗어나면 빈 배열. func footprint(id: String, col: int, row: int) -> Array[Vector2i]: var s := Catalog.size_of("house", 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_build_at(id: String, col: int, row: int, ignore_index := -1) -> bool: var item_zone := Catalog.zone_of("house", id) if not is_zone_unlocked(item_zone): return false var cells := footprint(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 := house_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 house_at(col, row) == -1: free += 1 return free ## 그 집을 지을 수 있는 첫 칸. 없으면 Vector2i(-1, -1). ## 아래 줄부터 훑는다. 게임이 알아서 세우는 집은 앞줄(화면 아래쪽)에 서야 눈에 띄고, ## 뒷줄은 플레이어가 직접 고르도록 남겨두는 편이 낫다. func first_free_spot(id: String) -> Vector2i: var z := Catalog.zone_of("house", id) for col in range(z * Catalog.ZONE_COLUMNS, (z + 1) * Catalog.ZONE_COLUMNS): for row in range(rows() - 1, -1, -1): if can_build_at(id, col, row): return Vector2i(col, row) return Vector2i(-1, -1) # --- 정원 (생물이 실제로 부딪히는 한계) --- func capacity_in_zone(zone_index: int) -> int: if zone_index < 0 or zone_index >= _zone_capacity.size(): return 0 return _zone_capacity[zone_index] func fish_in_zone(zone_index: int) -> int: if zone_index < 0 or zone_index >= _zone_fish.size(): return 0 return _zone_fish[zone_index] ## 그 구역에 남은 자리. 옛 저장본을 옮겨오면 음수가 될 수 있다(정원을 넘겨 살고 있다). func free_slots_in_zone(zone_index: int) -> int: return capacity_in_zone(zone_index) - fish_in_zone(zone_index) func capacity_total() -> int: var total := 0 for c in _zone_capacity: total += c return total # --- 조회 --- func house_count(id: String) -> int: return int(_house_counts.get(id, 0)) func fish_count(id: String) -> int: return int(fish.get(id, 0)) func count_of(kind: String, id: String) -> int: match kind: "producer": return fish_count(id) "house": return house_count(id) "upgrade": return 1 if has_upgrade(id) else 0 return 0 func has_upgrade(id: String) -> bool: return upgrades.has(id) ## 생물과 집은 늘어날수록 값이 오른다. 업그레이드는 한 번뿐이라 정가다. func cost_of(kind: String, id: String) -> float: var d := Catalog.item(kind, id) if d.is_empty(): return INF if kind == "upgrade": return float(d.get("cost", INF)) return _grown_cost(float(d.get("base_cost", INF)), count_of(kind, id)) func _grown_cost(base: float, owned_count: int) -> float: if not is_finite(base): return INF return ceil(base * pow(Catalog.COST_GROWTH, owned_count)) ## 치울 때 기준이 되는 값 — 지금 값이 아니라 마지막으로 산 그 하나의 값이다. func _last_paid(kind: String, id: String) -> float: var d := Catalog.item(kind, id) if d.is_empty(): return 0.0 return _grown_cost(float(d.get("base_cost", 0.0)), maxi(count_of(kind, id) - 1, 0)) ## 한 번만 살 수 있는 것(업그레이드)을 이미 가졌는지. func owned(kind: String, id: String) -> bool: return kind == "upgrade" and has_upgrade(id) 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 count_of(kind, id) > 0: return true return total_bubbles >= cost_of(kind, id) * 0.5 # --- 집 짓기 --- ## 사서 그 칸에 짓는다. 성공하면 houses 인덱스, 실패하면 -1. func build_house(id: String, col: int, row: int) -> int: if Catalog.house(id).is_empty(): return -1 if not can_build_at(id, col, row): return -1 var cost := cost_of("house", id) if not is_finite(cost) or bubbles < cost: return -1 bubbles -= cost houses.append({"id": id, "col": col, "row": row}) recalc() placed.emit(houses.size() - 1) state_changed.emit() return houses.size() - 1 ## 이미 지은 집을 다른 칸으로 옮긴다. 공짜다. 살던 생물도 같이 따라간다. func move_house(index: int, col: int, row: int) -> bool: if index < 0 or index >= houses.size(): return false var h := houses[index] if not can_build_at(String(h.id), col, row, index): return false h.col = col h.row = row recalc() state_changed.emit() return true ## 헐 수 있는지. 살고 있는 생물이 남는 집에 다 들어가야 한다. func can_remove_house(index: int) -> bool: if index < 0 or index >= houses.size(): return false var id := String(houses[index].id) var z := Catalog.zone_of("house", id) return fish_in_zone(z) <= capacity_in_zone(z) - Catalog.capacity_of(id) ## 헐고 절반을 돌려받는다. 돌려받은 양을 반환한다. 헐 수 없으면 0. func remove_house(index: int) -> float: if not can_remove_house(index): return 0.0 var id := String(houses[index].id) var refund: float = floor(_last_paid("house", id) * Catalog.REFUND_RATE) houses.remove_at(index) bubbles += refund recalc() reloaded.emit() state_changed.emit() return refund # --- 생물 들이기 --- ## 그 생물을 들일 수 있는지. 구역이 열려 있고, 그 구역에 빈 자리가 있어야 한다. func can_take_fish(id: String) -> bool: var d := Catalog.producer(id) if d.is_empty(): return false var z := int(d.get("zone", 0)) return is_zone_unlocked(z) and free_slots_in_zone(z) > 0 ## 왜 못 들이는지. 상점과 알림에 그대로 쓴다. func fish_blocker(id: String) -> String: var d := Catalog.producer(id) if d.is_empty(): return "그런 생물은 없어요" var z := int(d.get("zone", 0)) if not is_zone_unlocked(z): return "%s 아직 잠겨 있어요" % Korean.i(String(Catalog.zone(z).name)) if free_slots_in_zone(z) <= 0: return "%s에 빈 자리가 없어요 · 집을 더 지으세요" % Catalog.zone(z).name if bubbles < cost_of("producer", id): return "거품이 모자라요" return "" ## 한 마리 들인다. func buy_fish(id: String) -> bool: if not can_take_fish(id): return false var cost := cost_of("producer", id) if not is_finite(cost) or bubbles < cost: return false bubbles -= cost fish[id] = fish_count(id) + 1 recalc() reloaded.emit() state_changed.emit() return true ## 마지막 한 마리는 내보낼 수 없다. 생물이 0이 되면 거품이 영영 늘지 않기 때문이다. func can_release_fish(id: String) -> bool: return fish_count(id) > 0 and fish_total > 1 ## 한 마리 내보내고 절반을 돌려받는다. 돌려받은 양을 반환한다. func release_fish(id: String) -> float: if not can_release_fish(id): return 0.0 var refund: float = floor(_last_paid("producer", id) * Catalog.REFUND_RATE) var left := fish_count(id) - 1 if left > 0: fish[id] = left else: fish.erase(id) 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: var zones := Catalog.zone_count() _house_counts.clear() _occupancy.resize(columns() * rows()) _occupancy.fill(-1) zone_beauty.clear() zone_beauty.resize(zones) _zone_capacity.clear() _zone_capacity.resize(zones) _zone_fish.clear() _zone_fish.resize(zones) # 집 — 칸을 차지하고, 정원과 아름다움을 낸다. 거품은 벌지 않는다. for i in houses.size(): var id := String(houses[i].id) var d := Catalog.house(id) if d.is_empty(): continue _house_counts[id] = house_count(id) + 1 for c in footprint(id, int(houses[i].col), int(houses[i].row)): _occupancy[_cell(c.x, c.y)] = i var z := int(d.get("zone", 0)) _add_beauty(z, float(d.beauty)) _add_capacity(z, Catalog.capacity_of(id)) # 생물 — 거품을 버는 것은 이쪽뿐이다. fish_total = 0 var raw_bps := 0.0 for key in fish: var id := String(key) var d := Catalog.producer(id) var n := fish_count(id) if d.is_empty() or n <= 0: continue fish_total += n raw_bps += float(d.bps) * n var z := int(d.get("zone", 0)) _add_beauty(z, float(d.beauty) * n) _add_fish(z, n) # 업그레이드는 전부 전체 생산 배율이다. 클릭으로 버는 길은 없다. var upgrade_bps_mult := 1.0 for key in upgrades: var u := Catalog.upgrade(String(key)) if u.is_empty(): continue if String(u.kind) == "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 * upgrade_bps_mult * beauty_mult func _add_beauty(index: int, amount: float) -> void: if index >= 0 and index < zone_beauty.size(): zone_beauty[index] += amount func _add_capacity(index: int, amount: int) -> void: if index >= 0 and index < _zone_capacity.size(): _zone_capacity[index] += amount func _add_fish(index: int, amount: int) -> void: if index >= 0 and index < _zone_fish.size(): _zone_fish[index] += amount # --- 직렬화 --- func to_dict() -> Dictionary: var rows_out: Array = [] for h in houses: rows_out.append({"id": h.id, "col": h.col, "row": h.row}) return { "bubbles": bubbles, "total_bubbles": total_bubbles, "houses": rows_out, "fish": fish.duplicate(), "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)) 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 houses.clear() fish.clear() recalc() if d.has("houses") or d.has("fish"): for raw in d.get("houses", []): _restore_house(String(raw.get("id", "")), int(raw.get("col", 0)), int(raw.get("row", 0))) for id in d.get("fish", {}): _restore_fish(String(id), int(d["fish"][id])) else: _migrate_old_save(d) # 집이나 생물이 하나도 없으면 아무 일도 일어나지 않는다. 시작 상태를 채워준다. if houses.is_empty(): _grant_starter_houses() if fish_total == 0: _grant_starter_fish() _grant_homes_for_overflow() recalc() reloaded.emit() state_changed.emit() ## 저장된 칸이 비어 있으면 그대로, 겹치거나 규칙에 어긋나면 빈 칸을 찾아 옮겨 짓는다. ## 격자 크기나 구역 구성이 바뀌어도 진행도를 잃지 않기 위한 것. func _restore_house(id: String, col: int, row: int) -> void: if Catalog.house(id).is_empty(): return if not can_build_at(id, col, row): var spot := first_free_spot(id) if spot.x < 0: return col = spot.x row = spot.y houses.append({"id": id, "col": col, "row": row}) recalc() ## 정원을 넘겨도 이미 살던 생물을 내쫓지는 않는다. 자리가 빌 때까지 더 못 들일 뿐이다. func _restore_fish(id: String, count: int) -> void: if count <= 0: return # 돌·해초처럼 예전에 생물이었다가 집이 된 것은 그만큼 집으로 지어 준다. # 그냥 버리면 플레이어가 사 둔 것이 조용히 사라진다. if Catalog.producer(id).is_empty(): if not Catalog.house(id).is_empty(): for i in count: var spot := first_free_spot(id) if spot.x < 0: break _restore_house(id, spot.x, spot.y) return fish[id] = fish_count(id) + count recalc() ## 생물도 칸을 차지하던 시절의 저장본을 옮겨온다. ## 그때의 장식(ornament)이 지금의 집이고, 생물은 위치를 버리고 마릿수만 남긴다. func _migrate_old_save(d: Dictionary) -> void: if d.has("placements"): for raw in d["placements"]: var kind := String(raw.get("kind", "producer")) var id := String(raw.get("id", "")) if kind == "ornament" or kind == "house": _restore_house(id, int(raw.get("col", 0)), int(raw.get("row", 0))) else: _restore_fish(id, 1) return # 격자가 없던 더 옛날 저장본. 개수만 있다. for id in d.get("producers", {}): _restore_fish(String(id), int(d["producers"][id])) for id in d.get("ornaments", {}): var spot := first_free_spot(String(id)) if spot.x >= 0: _restore_house(String(id), spot.x, spot.y) ## 처음부터 다시. with_starter를 끄면 집도 생물도 없는 빈 바다가 된다(테스트용). func reset(with_starter := true) -> void: bubbles = 0.0 total_bubbles = 0.0 houses.clear() fish.clear() upgrades.clear() unlocked_zones = 1 first_played_unix = int(Time.get_unix_time_from_system()) recalc() if with_starter: _grant_starter_houses() _grant_starter_fish() bubbles = Catalog.STARTER_BUBBLES total_bubbles = Catalog.STARTER_BUBBLES recalc() reloaded.emit() state_changed.emit() ## 시작 집을 값 없이 짓는다. 정해 둔 칸이 차 있으면 빈 칸을 찾는다. func _grant_starter_houses() -> void: for s in Catalog.STARTER_HOUSES: _restore_house(String(s.id), int(s.col), int(s.row)) ## 시작 생물을 값 없이 들인다. func _grant_starter_fish() -> void: for id in Catalog.STARTER_FISH: _restore_fish(String(id), int(Catalog.STARTER_FISH[id])) ## 옛 저장본에서 옮겨온 생물이 정원을 넘길 수 있다(그때는 생물이 칸을 직접 차지했다). ## 그러면 집을 공짜로 세워 모두 들어가게 해준다. 살 곳 없는 생물은 화면에도 ## 그려지지 않으므로, 보이지 않는 생물을 남기지 않으려는 것이다. func _grant_homes_for_overflow() -> void: for z in Catalog.zone_count(): var id := Catalog.first_id_of_zone("house", z) if id.is_empty(): continue while fish_in_zone(z) > capacity_in_zone(z): var spot := first_free_spot(id) if spot.x < 0: break # 칸이 다 찼다. 더는 해줄 수 있는 게 없다. _restore_house(id, spot.x, spot.y)