89069b06f0
거품은 이제 클릭이 아니라 바다에 있는 생물이 시간에 따라 번다. 화면 가장자리에
깔아 두는 위젯인데 클릭이 주된 수입원이면 연타가 최적 플레이가 되어 형태와
어긋난다. 클릭 수익과 클릭 업그레이드를 걷어내고 업그레이드는 전부 생산 배율로
바꿨다. 물을 만지면 거품이 퍼지지만 얻는 것은 없다.
격자 칸을 차지하는 것은 집뿐이고, 생물은 칸이 아니라 집에 산다(한 채당 3마리).
칸 → 집 → 생물 → 거품으로 한계가 두 겹 걸린다. 예전에는 생물과 장식이 같은
자원(칸)을 놓고 경쟁해서 최적 플레이가 늘 "장식을 사지 말 것"이었는데, 집과
일꾼으로 나누니 그 경쟁이 의존으로 바뀐다. 집은 같은 종류도 여러 채 지을 수 있고
거품을 벌지 않는다 — 정원과 생산을 한 물건이 주면 곱연산이 두 번 걸린다.
어느 생물이 어느 집에 사는지는 저장하지 않는다. 집은 정원만 주므로 어디 살든
결과가 같고, 저장하면 재배정과 인덱스 관리만 붙는다. 그릴 때 구역마다 배정한다.
그래서 저장 형식에서 생물은 위치 없이 {id: 마리 수}다. 옛 저장본은 그때의
장식을 집으로, 생물을 마릿수로 옮겨오고 정원이 모자라면 집을 세워 메운다.
수입이 생물에서만 나오므로 막다른 길을 두 곳에서 막았다. 마지막 생물은 내보낼 수
없고, 살던 생물이 갈 곳이 없어지는 집은 헐 수 없다.
집과 생물의 그림 형태를 갈랐다. 집 10채 중 8채가 생물과 같은 rock이어서 항아리와
거품돌이 색만 다른 같은 덩어리로 보였다. 집은 vessel/wreck/pillar만, 생물은
plant/rock/fish/glow만 쓴다. 헤엄치는 생물은 집을 도는 타원 궤도에 올라가고,
원점이 궤도를 따라 움직이므로 Y 정렬이 앞뒤를 알아서 바꿔준다 — 뒤로 돌면 집에
가렸다가 앞으로 나온다. 얕은 바다 생물이 거품돌과 해초뿐이라 이 움직임을 볼 수
없어서 구피를 넣었다.
실행해 보고 잡은 버그 셋:
- 저장본이 없으면 load_game이 그냥 반환해 빈 바다로 시작했다. 집도 생물도 없어
아무 일도 일어나지 않는다. 이제 새 게임을 시작한다.
- 창 높이·도킹·모니터 설정이 복원되지 않고 기본값으로 덮어써지고 있었다.
window_state에 미리 있던 키만 복사하던 것을 저장본에 있는 것을 전부 싣도록 고쳤다.
- 정보 바가 격자 첫 줄을 덮어 왼쪽 위에 놓인 것이 보이지 않았다. GRID_TOP 상수를
버리고 HUD가 잰 실제 바 높이를 받아 쓴다.
- main.gd가 초기화할 때 존재하지 않는 aquarium.rebuild()를 부르고 있었다.
스모크 테스트 159개.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uze4mF1NAGfFRio77cMFqg
651 lines
19 KiB
GDScript
651 lines
19 KiB
GDScript
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 Catalog.producer(id).is_empty() or count <= 0:
|
|
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)
|