클리커에서 집·일꾼 방치형 구조로 전환

거품은 이제 클릭이 아니라 바다에 있는 생물이 시간에 따라 번다. 화면 가장자리에
깔아 두는 위젯인데 클릭이 주된 수입원이면 연타가 최적 플레이가 되어 형태와
어긋난다. 클릭 수익과 클릭 업그레이드를 걷어내고 업그레이드는 전부 생산 배율로
바꿨다. 물을 만지면 거품이 퍼지지만 얻는 것은 없다.

격자 칸을 차지하는 것은 집뿐이고, 생물은 칸이 아니라 집에 산다(한 채당 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
This commit is contained in:
2026-09-01 22:39:25 +09:00
parent b490e463b0
commit 89069b06f0
11 changed files with 1548 additions and 578 deletions
+294 -119
View File
@@ -2,14 +2,16 @@ extends Control
class_name Aquarium
## 화면 하단에 깔리는 탑다운 바다.
## 가로를 구역 수만큼 나눠, 왼쪽(얕은 바다)에서 오른쪽(열수구)으로 갈수록 깊어진다.
## 구역마다 격자가 있고, 산 것은 플레이어가 고른 칸에 놓인다.
##
## 격자 칸을 차지하는 것은 집뿐이다. 생물은 칸이 아니라 집에 살기 때문에
## 그릴 때마다 구역별로 집에 차례로 배정해서 그 집 둘레에 세운다.
## 알림을 띄워야 할 때 (해금 실패, 배치 실패, 환불 등). main이 받아 HUD로 넘긴다.
## 알림을 띄워야 할 때 (해금 실패, 건축 실패, 환불 등). main이 받아 HUD로 넘긴다.
signal message(text: String)
## 빈 곳을 눌렀을 때. 거품 클릭 연출에 쓴다.
## 빈 곳을 눌렀을 때. 거품이 퍼지는 연출에 쓴다 — 거품은 생물이 번다.
signal clicked(local_position: Vector2)
## 배치 모드가 켜지거나 꺼졌을 때. 상점이 선택 상태를 표시하는 데 쓴다.
signal placement_changed(active: bool, kind: String, id: String)
## 집 짓기 모드가 켜지거나 꺼졌을 때. 상점이 선택 상태를 표시하는 데 쓴다.
signal build_mode_changed(active: bool, id: String)
## 이미 놓인 것을 집었거나 놓았을 때. HUD가 휴지통을 보였다 감추는 데 쓴다.
signal drag_changed(dragging: bool)
## 물 위에서 우클릭. main이 메뉴를 연다.
@@ -29,21 +31,33 @@ var trash_rect := Rect2()
## 위쪽 이만큼은 정보 바가 앉는 자리라 격자에서 뺀다.
## 여기를 비워두지 않으면 첫 줄 칸이 바에 가려 보이지도, 눌리지도 않는다.
const GRID_TOP := 30.0
## 바 높이는 글꼴과 담긴 내용에 따라 달라지므로 main이 HUD에서 재어 넣어준다.
var grid_top := 30.0:
set(value):
if is_equal_approx(grid_top, value):
return
grid_top = value
if is_node_ready():
_on_layout_changed()
const DRAG_THRESHOLD := 5.0
## _press_index에 쓰는 표식. 이번 누름은 클릭이 아니라 드래그였다는 뜻.
const PRESS_WAS_DRAG := -2
## 물고기가 집을 한 바퀴 도는 속도(라디안/초). 위젯이라 눈에 거슬리지 않게 느리다.
const ORBIT_SPEED := 0.5
var _rng := RandomNumberGenerator.new()
var _lock_reasons := {} ## 구역 인덱스 -> 사유 Label
var _base_material: ShaderMaterial
var _placing_kind := ""
var _placing_id := ""
## 지금 짓는 중인 집의 id. 비어 있으면 짓는 중이 아니다.
var _building_id := ""
var _hover := Vector2i(-1, -1)
var _press_position := Vector2.ZERO
var _press_index := -1
var _drag_index := -1
## 누르거나 끌고 있는 대상. {} 이면 없음. TileItem의 ref_* 를 그대로 담는다.
var _press_ref := {}
var _drag_ref := {}
var _drag_node: TileItem = null
## 이번 누름이 클릭이 아니라 드래그였다는 표식.
var _press_was_drag := false
func _ready() -> void:
@@ -72,7 +86,9 @@ func _process(_delta: float) -> void:
## 격자가 실제로 깔리는 영역. 위쪽 정보 바 자리는 뺀다.
func grid_rect() -> Rect2:
return Rect2(Vector2(0.0, GRID_TOP), Vector2(size.x, maxf(size.y - GRID_TOP, 1.0)))
# 스트립이 낮을 때 바가 격자를 다 먹어버리지 않도록 위쪽 절반은 넘기지 않는다.
var top := clampf(grid_top, 0.0, size.y * 0.45)
return Rect2(Vector2(0.0, top), Vector2(size.x, maxf(size.y - top, 1.0)))
func cell_size() -> Vector2:
@@ -82,13 +98,14 @@ func cell_size() -> Vector2:
func cell_at(pos: Vector2) -> Vector2i:
var c := cell_size()
return Vector2i(int(floor(pos.x / c.x)), int(floor((pos.y - GRID_TOP) / c.y)))
var top := grid_rect().position.y
return Vector2i(int(floor(pos.x / c.x)), int(floor((pos.y - top) / c.y)))
func cell_rect(col: int, row: int, span := Vector2i.ONE) -> Rect2:
var c := cell_size()
return Rect2(
Vector2(col * c.x, GRID_TOP + row * c.y),
Vector2(col * c.x, grid_rect().position.y + row * c.y),
Vector2(c.x * span.x, c.y * span.y))
@@ -99,9 +116,9 @@ func zone_rect(index: int) -> Rect2:
Vector2(Catalog.ZONE_COLUMNS * c.x, size.y))
## 커서가 가리키는 칸을 기준으로 발자국을 가운데 맞춘 왼쪽 위 칸.
func _anchor_for(kind: String, id: String, hover: Vector2i) -> Vector2i:
var s := Catalog.size_of(kind, id)
## 커서가 가리키는 칸을 기준으로 발자국을 가운데 맞춘 왼쪽 위 칸.
func _anchor_for(id: String, hover: Vector2i) -> Vector2i:
var s := Catalog.size_of("house", id)
return hover - Vector2i((s.x - 1) / 2, (s.y - 1) / 2)
@@ -111,7 +128,7 @@ func _anchor_for(kind: String, id: String, hover: Vector2i) -> Vector2i:
## 스트립은 자주 바뀌지 않으므로 부분 갱신보다 통째로 다시 만드는 쪽이 단순하다.
func _on_layout_changed() -> void:
ground.rect_size = size
ground.grid_top = GRID_TOP
ground.grid_top = grid_rect().position.y
ground.queue_redraw()
_build_water()
@@ -259,7 +276,9 @@ func _on_zone_unlocked(index: int) -> void:
_on_layout_changed()
# --- 놓인 것들 ---
# --- 집과 생물 그리기 ---
func _setup_bubbles() -> void:
bubbles.emitting = true
@@ -277,37 +296,153 @@ func _setup_bubbles() -> void:
func _rebuild_items() -> void:
for child in items.get_children():
child.queue_free()
for i in GameState.placements.size():
_make_item(i)
for i in GameState.houses.size():
_make_house(i)
for entry in _assign_fish():
_make_fish(entry)
func _make_item(index: int) -> TileItem:
var p := GameState.placements[index]
var d := Catalog.item(p.kind, p.id)
func _house_rect(index: int) -> Rect2:
var h := GameState.houses[index]
return cell_rect(int(h.col), int(h.row), Catalog.size_of("house", String(h.id)))
func _make_house(index: int) -> TileItem:
var id := String(GameState.houses[index].id)
var d := Catalog.house(id)
if d.is_empty():
return null
var node := TileItem.new()
items.add_child(node)
node.setup(d, p.kind, index, cell_rect(p.col, p.row, Catalog.size_of(p.kind, p.id)),
cell_size(), _rng)
node.setup(d, "house", index, _house_rect(index), cell_size(), _rng)
return node
func _make_fish(entry: Dictionary) -> void:
var d := Catalog.producer(String(entry.id))
if d.is_empty():
return
var home := _house_rect(int(entry.house))
var cell := cell_size()
var slot := int(entry.slot)
var node := TileItem.new()
items.add_child(node)
node.setup(d, "producer", -1, _fish_area(home, cell, slot), cell, _rng)
# 헤엄치는 것만 집 둘레를 돈다. 해초나 거품돌은 제자리에 붙어 있어야 한다.
var shape := String(d.get("shape", "rock"))
if shape != "fish" and shape != "glow":
return
var house_index := int(entry.house)
var cap: int = maxi(Catalog.capacity_of(String(GameState.houses[house_index].id)), 1)
# 집마다 도는 방향을 바꿔 여러 채가 한 화면에 있어도 단조롭지 않게 한다.
var dir := 1.0 if house_index % 2 == 0 else -1.0
node.set_orbit(_orbit_center(home, cell), _orbit_radius(home, cell),
TAU * float(slot) / float(cap), ORBIT_SPEED * dir)
## 어느 생물이 어느 집에 사는지 그릴 때마다 정한다. 집은 정원만 주므로 배정을
## 저장할 필요가 없다. 구역마다 집을 격자 순서로, 생물을 카탈로그 순서로 세워 채운다.
## 돌려주는 것: [{id, house, slot}, ...]. 정원을 넘겨 사는 것들은 빠진다.
func _assign_fish() -> Array:
var out: Array = []
for z in Catalog.zone_count():
var homes := _homes_in_zone(z)
if homes.is_empty():
continue
var hi := 0
var used := 0
for d in Catalog.PRODUCERS:
if int(d.get("zone", 0)) != z:
continue
for n in GameState.fish_count(String(d.id)):
while hi < homes.size() \
and used >= Catalog.capacity_of(String(GameState.houses[homes[hi]].id)):
hi += 1
used = 0
if hi >= homes.size():
break
out.append({"id": String(d.id), "house": homes[hi], "slot": used})
used += 1
return out
## 그 구역의 집들을 격자 순서(왼쪽 위부터)로. 배정이 매번 같아야 그림이 안 튄다.
func _homes_in_zone(zone_index: int) -> Array[int]:
var homes: Array[int] = []
for i in GameState.houses.size():
if Catalog.zone_of_column(int(GameState.houses[i].col)) == zone_index:
homes.append(i)
homes.sort_custom(func(a: int, b: int) -> bool:
var ha := GameState.houses[a]
var hb := GameState.houses[b]
if int(ha.col) != int(hb.col):
return int(ha.col) < int(hb.col)
return int(ha.row) < int(hb.row))
return homes
## 물고기가 도는 궤도의 반지름. 집을 감싸되 3/4 부감이라 세로로 납작하다.
func _orbit_radius(home: Rect2, cell: Vector2) -> Vector2:
return Vector2(home.size.x * 0.5 + cell.x * 0.55, cell.y * 0.42)
## 궤도의 중심. 집 발치에 두되, 궤도가 통째로 바다 안에 들어오도록 밀어 넣는다.
## 중심을 옮기지 않고 매 프레임 위치만 자르면 물고기가 벽에 붙어 멈춘 것처럼 보인다.
func _orbit_center(home: Rect2, cell: Vector2) -> Vector2:
var r := _orbit_radius(home, cell)
var g := grid_rect()
return Vector2(
_squeeze(home.position.x + home.size.x * 0.5, g.position.x + r.x, g.end.x - r.x),
_squeeze(home.end.y - cell.y * 0.10,
g.position.y + cell.y * 0.9 + r.y, g.end.y - r.y))
## 범위가 뒤집힐 만큼 좁으면(작은 창) 가운데로 보낸다. clampf는 그때 엉뚱한 값을 준다.
func _squeeze(value: float, lo: float, hi: float) -> float:
if lo > hi:
return (lo + hi) * 0.5
return clampf(value, lo, hi)
## 집 둘레의 붙박이 자리 하나. 해초·거품돌처럼 바닥에 서는 것이 쓴다.
## 왼쪽 → 오른쪽 → 앞 순으로 돌고, 정원이 더 크면 한 겹 더 벌어진다.
func _fish_area(home: Rect2, cell: Vector2, slot: int) -> Rect2:
# 앞자리는 살짝 오른쪽으로 밀어 둔다. 바다 왼쪽 끝에 붙은 집은 왼쪽 자리가
# 안으로 밀려 들어오는데, 그때 앞자리와 겹쳐 한 마리처럼 보이기 때문이다.
const SPOTS := [Vector2(-0.5, -0.1), Vector2(0.5, -0.1), Vector2(0.12, 0.62)]
var spot: Vector2 = SPOTS[slot % SPOTS.size()]
var ring := float(slot / SPOTS.size())
var cx := home.position.x + home.size.x * 0.5
var x := cx + spot.x * (home.size.x + cell.x * 0.9) + ring * cell.x * 0.28
var base := home.end.y + spot.y * cell.y + ring * cell.y * 0.22
# 바다 밖으로 새어 나가지 않게 잡아둔다.
var g := grid_rect()
x = clampf(x, g.position.x + cell.x * 0.5, g.end.x - cell.x * 0.5)
base = clampf(base, g.position.y + cell.y * 0.6, g.end.y)
return Rect2(Vector2(x - cell.x * 0.5, base - cell.y), cell)
func _on_placed(index: int) -> void:
var node := _make_item(index)
# 집이 하나 늘면 생물 배정도 달라지므로 통째로 다시 그린다.
_rebuild_items()
var node := _node_for_house(index)
if node:
# 새로 은 살짝 부풀며 등장한다.
# 새로 은 살짝 부풀며 등장한다.
node.scale = Vector2.ZERO
var t := create_tween()
t.tween_property(node, "scale", Vector2.ONE, 0.28) \
.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
## 커서 아래에 있는 놓인 것. 그려진 모양으로 먼저 찾는다.
## 커서 아래에 있는 것. 그려진 모양으로 먼저 찾는다.
## 키 큰 것은 제 칸보다 위쪽에 그려지므로, 보이는 자리를 눌러야 집히는 게 자연스럽다.
## 앞줄(화면 아래쪽)에 있는 것이 이긴다.
func _pick_at(pos: Vector2) -> int:
var best := -1
func _pick_at(pos: Vector2) -> Dictionary:
var best: TileItem = null
var best_y := -INF
for child in items.get_children():
var it := child as TileItem
@@ -317,90 +452,102 @@ func _pick_at(pos: Vector2) -> int:
r.position += it.position
if r.has_point(pos) and it.position.y > best_y:
best_y = it.position.y
best = it.placement_index
if best >= 0:
return best
# 그림에 안 걸렸으면 발자국 칸으로 한 번 더 본다.
var cell := cell_at(pos)
return GameState.placement_at(cell.x, cell.y)
best = it
if best == null:
# 그림에 안 걸렸으면 발자국 칸으로 한 번 더 본다. 칸을 가진 것은 집뿐이다.
var cell := cell_at(pos)
var index := GameState.house_at(cell.x, cell.y)
if index < 0:
return {}
return {"kind": "house", "index": index, "id": String(GameState.houses[index].id)}
return {"kind": best.ref_kind, "index": best.ref_index, "id": best.ref_id}
func _item_node(index: int) -> TileItem:
func _node_for_house(index: int) -> TileItem:
for child in items.get_children():
var it := child as TileItem
if it and it.placement_index == index:
if it and it.ref_kind == "house" and it.ref_index == index:
return it
return null
# --- 배치 모드 ---
func _node_for(ref: Dictionary) -> TileItem:
if ref.is_empty():
return null
if String(ref.kind) == "house":
return _node_for_house(int(ref.index))
for child in items.get_children():
var it := child as TileItem
if it and it.ref_kind == "producer" and it.ref_id == String(ref.id):
return it
return null
func begin_placement(kind: String, id: String) -> void:
if kind == "upgrade":
# --- 집 짓기 모드 ---
func begin_build(id: String) -> void:
if Catalog.house(id).is_empty():
return
_cancel_drag()
_placing_kind = kind
_placing_id = id
placement_changed.emit(true, kind, id)
_building_id = id
build_mode_changed.emit(true, id)
_update_ghost()
func cancel_placement() -> void:
if _placing_id.is_empty():
func cancel_build() -> void:
if _building_id.is_empty():
return
_placing_kind = ""
_placing_id = ""
_building_id = ""
ghost.hide()
placement_changed.emit(false, "", "")
build_mode_changed.emit(false, "")
func is_placing() -> bool:
return not _placing_id.is_empty()
func is_building() -> bool:
return not _building_id.is_empty()
func _update_ghost() -> void:
if not is_placing() or _hover.x < 0:
if not is_building() or _hover.x < 0:
ghost.hide()
return
var anchor := _anchor_for(_placing_kind, _placing_id, _hover)
var span := Catalog.size_of(_placing_kind, _placing_id)
var ok := GameState.can_place_at(_placing_kind, _placing_id, anchor.x, anchor.y) \
and GameState.bubbles >= GameState.cost_of(_placing_kind, _placing_id)
var anchor := _anchor_for(_building_id, _hover)
var span := Catalog.size_of("house", _building_id)
var ok := GameState.can_build_at(_building_id, anchor.x, anchor.y) \
and GameState.bubbles >= GameState.cost_of("house", _building_id)
ghost.show_at(cell_rect(anchor.x, anchor.y, span), ok)
func _try_place() -> void:
var kind := _placing_kind
var id := _placing_id
var anchor := _anchor_for(kind, id, _hover)
func _try_build() -> void:
var id := _building_id
var anchor := _anchor_for(id, _hover)
if GameState.bubbles < GameState.cost_of(kind, id):
if GameState.bubbles < GameState.cost_of("house", id):
message.emit("거품이 모자라요")
cancel_placement()
cancel_build()
return
if not GameState.can_place_at(kind, id, anchor.x, anchor.y):
message.emit(_why_not(kind, id, anchor))
if not GameState.can_build_at(id, anchor.x, anchor.y):
message.emit(_why_not(id, anchor))
return
GameState.place(kind, id, anchor.x, anchor.y)
# 계속 을 수 있으면 모드를 유지한다. 같은 여러 깔 때 편하다.
if kind == "ornament" or GameState.bubbles < GameState.cost_of(kind, id):
cancel_placement()
GameState.build_house(id, anchor.x, anchor.y)
# 계속 을 수 있으면 모드를 유지한다. 같은 집을 여러 깔 때 편하다.
if GameState.bubbles < GameState.cost_of("house", id):
cancel_build()
else:
_update_ghost()
func _why_not(kind: String, id: String, anchor: Vector2i) -> String:
var item_zone := Catalog.zone_of(kind, id)
func _why_not(id: String, anchor: Vector2i) -> String:
var item_zone := Catalog.zone_of("house", id)
if not GameState.is_zone_unlocked(item_zone):
return "%s 아직 잠겨 있어요" % Korean.i(String(Catalog.zone(item_zone).name))
var cells := GameState.footprint(kind, id, anchor.x, anchor.y)
var cells := GameState.footprint(id, anchor.x, anchor.y)
if cells.is_empty():
return "바다 밖으로 나가요"
for c in cells:
if Catalog.zone_of_column(c.x) != item_zone:
return "%s 안에만 을 수 있어요" % Catalog.zone(item_zone).name
return "이미 무언가 놓여 있어요"
return "%s 안에만 을 수 있어요" % Catalog.zone(item_zone).name
return "이미 무언가 있어요"
# --- 입력 ---
@@ -408,11 +555,12 @@ func _why_not(kind: String, id: String, anchor: Vector2i) -> String:
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
_hover = cell_at(event.position)
if _drag_index >= 0:
if not _drag_ref.is_empty():
_update_drag(event.position)
elif is_placing():
elif is_building():
_update_ghost()
elif _press_index >= 0 and event.position.distance_to(_press_position) > DRAG_THRESHOLD:
elif not _press_ref.is_empty() and not _press_was_drag \
and event.position.distance_to(_press_position) > DRAG_THRESHOLD:
_begin_drag()
return
@@ -420,9 +568,9 @@ func _gui_input(event: InputEvent) -> void:
return
if event.button_index == MOUSE_BUTTON_RIGHT and event.pressed:
if is_placing():
cancel_placement()
elif _drag_index >= 0:
if is_building():
cancel_build()
elif not _drag_ref.is_empty():
_cancel_drag()
else:
context_menu_requested.emit()
@@ -433,80 +581,107 @@ func _gui_input(event: InputEvent) -> void:
if event.pressed:
_hover = cell_at(event.position)
if is_placing():
_try_place()
if is_building():
_try_build()
return
_press_position = event.position
_press_index = _pick_at(event.position)
_press_ref = _pick_at(event.position)
_press_was_drag = false
else:
if _drag_index >= 0:
if not _drag_ref.is_empty():
_drop(event.position)
elif _press_index != PRESS_WAS_DRAG:
# 끌지 않고 뗐으면 그냥 물을 클릭한 것으로 친다.
elif not _press_was_drag:
# 끌지 않고 뗐으면 물을 만진 것으로 친다.
clicked.emit(event.position)
_press_index = -1
_press_ref = {}
_press_was_drag = false
func _begin_drag() -> void:
if _press_index < 0:
if _press_ref.is_empty():
return
_drag_index = _press_index
_press_index = PRESS_WAS_DRAG
_drag_ref = _press_ref
_drag_node = _node_for(_drag_ref)
# 끄는 동안에는 궤도를 멈춘다. 안 그러면 손을 따라오지 않고 제 길을 간다.
if _drag_node:
_drag_node.orbit_enabled = false
_press_was_drag = true
drag_changed.emit(true)
func _update_drag(pos: Vector2) -> void:
var node := _item_node(_drag_index)
if node:
node.position = pos
if _drag_node and is_instance_valid(_drag_node):
_drag_node.position = pos
if trash_rect.has_point(pos):
# 생물은 칸을 차지하지 않으므로 놓일 자리를 미리 보여줄 것이 없다.
if trash_rect.has_point(pos) or String(_drag_ref.kind) != "house":
ghost.hide()
return
var p := GameState.placements[_drag_index]
var anchor := _anchor_for(p.kind, p.id, _hover)
var span := Catalog.size_of(p.kind, p.id)
var ok := GameState.can_place_at(p.kind, p.id, anchor.x, anchor.y, _drag_index)
ghost.show_at(cell_rect(anchor.x, anchor.y, span), ok)
var id := String(_drag_ref.id)
var anchor := _anchor_for(id, _hover)
var ok := GameState.can_build_at(id, anchor.x, anchor.y, int(_drag_ref.index))
ghost.show_at(cell_rect(anchor.x, anchor.y, Catalog.size_of("house", id)), ok)
func _drop(pos: Vector2) -> void:
var index := _drag_index
_drag_index = -1
var ref := _drag_ref
_drag_ref = {}
_drag_node = null
ghost.hide()
drag_changed.emit(false)
if index < 0 or index >= GameState.placements.size():
if trash_rect.has_point(pos):
if String(ref.kind) == "house":
_trash_house(int(ref.index))
else:
_release_fish(String(ref.id))
return
# 집은 끌어다 옮길 수 있다. 생물은 자기 집을 따라다니므로 제자리로 돌아간다.
if String(ref.kind) == "house":
var anchor := _anchor_for(String(ref.id), _hover)
if not GameState.move_house(int(ref.index), anchor.x, anchor.y):
message.emit("여기에는 놓을 수 없어요")
_rebuild_items()
func _trash_house(index: int) -> void:
if index < 0 or index >= GameState.houses.size():
_rebuild_items()
return
if trash_rect.has_point(pos):
var p := GameState.placements[index]
var item_name := String(Catalog.item(p.kind, p.id).get("name", ""))
var refund := GameState.remove(index)
message.emit("%s 치우고 거품 %s를 돌려받았어요" % [
Korean.eul(item_name), NumberFormat.short(refund)])
if not GameState.can_remove_house(index):
message.emit("살고 있는 생물이 갈 곳이 없어요")
_rebuild_items()
return
var house_name := String(Catalog.house(String(GameState.houses[index].id)).get("name", ""))
var refund := GameState.remove_house(index)
message.emit("%s 헐고 거품 %s를 돌려받았어요" % [
Korean.eul(house_name), NumberFormat.short(refund)])
var placement := GameState.placements[index]
var anchor := _anchor_for(placement.kind, placement.id, _hover)
if not GameState.move(index, anchor.x, anchor.y):
message.emit("여기에는 놓을 수 없어요")
# 성공이든 실패든 제자리를 다시 잡아준다.
_rebuild_items()
func _release_fish(id: String) -> void:
if not GameState.can_release_fish(id):
message.emit("마지막 생물은 내보낼 수 없어요")
_rebuild_items()
return
var fish_name := String(Catalog.producer(id).get("name", ""))
var refund := GameState.release_fish(id)
message.emit("%s 내보내고 거품 %s를 돌려받았어요" % [
Korean.eul(fish_name), NumberFormat.short(refund)])
func _cancel_drag() -> void:
if _drag_index < 0:
if _drag_ref.is_empty():
return
_drag_index = -1
_drag_ref = {}
_drag_node = null
ghost.hide()
drag_changed.emit(false)
_rebuild_items()
## 클릭 지점에서 거품이 지는 연출.
## 만진 자리에서 거품이 지는 연출.
func pop_at(pos: Vector2) -> void:
var burst := CPUParticles2D.new()
add_child(burst)
+158 -5
View File
@@ -8,8 +8,12 @@ class_name TileItem
## 아직 아트 에셋이 없어서 형태별로 직접 그린다.
## assets/art/ 에 스프라이트가 생기면 Sprite2D + offset 기반으로 바꾸면 된다.
## placements 배열에서의 인덱스. 클릭·드래그로 무엇을 집었는지 알아내는 데 쓴다.
var placement_index := -1
## 이 그림이 가리키는 것. 클릭·드래그로 무엇을 집었는지 알아내는 데 쓴다.
## 집이면 ref_kind "house" + ref_index (houses 배열 인덱스)
## 생물이면 ref_kind "producer" + ref_id (종류). 생물은 개체를 구분하지 않는다.
var ref_kind := ""
var ref_id := ""
var ref_index := -1
var data: Dictionary = {}
var shape := "rock"
@@ -21,6 +25,13 @@ var body_height := 32.0
## 바닥에서 얼마나 떠 있는지(픽셀). 헤엄치는 것만 0보다 크다.
var hover := 0.0
## 집 둘레를 도는 것(fish/glow)만 궤도를 갖는다. 끌고 있는 동안에는 멈춘다.
var orbit_enabled := true
var _orbit_center := Vector2.ZERO
var _orbit_radius := Vector2.ZERO
var _orbit_speed := 0.0
var _orbit_phase := 0.0
var _t := 0.0
var _phase := 0.0
var _swim := Vector2.ZERO
@@ -32,7 +43,9 @@ var _shape_seed := 0.0
func setup(item_data: Dictionary, kind: String, index: int, area: Rect2, cell: Vector2,
rng: RandomNumberGenerator) -> void:
data = item_data
placement_index = index
ref_kind = kind
ref_index = index
ref_id = String(item_data.get("id", ""))
shape = String(item_data.get("shape", "rock"))
color = item_data.get("color", Color.WHITE)
_phase = rng.randf() * TAU
@@ -62,6 +75,37 @@ func set_area(area: Rect2, cell: Vector2, height_tiles: float) -> void:
queue_redraw()
## 집 둘레를 도는 궤도에 올린다.
## 원점이 궤도를 따라 움직이므로 Y 정렬이 알아서 앞뒤를 바꿔준다 —
## 뒤쪽으로 돌면 집에 가려 "들어간" 것처럼 보이고, 앞으로 나오면 다시 드러난다.
func set_orbit(center: Vector2, radius: Vector2, phase: float, speed: float) -> void:
_orbit_center = center
_orbit_radius = radius
_orbit_phase = phase
_orbit_speed = speed
# 궤도가 큰 움직임을 맡으므로 제자리 흔들림은 줄인다. 안 그러면 서로 싸운다.
_swim_range *= 0.35
_apply_orbit(0.0)
func is_orbiting() -> bool:
return orbit_enabled and _orbit_radius != Vector2.ZERO
func _apply_orbit(t: float) -> void:
var angle := _orbit_phase + t * _orbit_speed
var next := _orbit_center + Vector2(
cos(angle) * _orbit_radius.x, sin(angle) * _orbit_radius.y)
# 나아가는 쪽으로 몸을 돌린다.
if absf(next.x - position.x) > 0.01:
_facing = signf(next.x - position.x)
position = next
# 뒤(위쪽)로 돌수록 작고 흐려져 집 안으로 들어간 것처럼 보인다.
var depth := (sin(angle) + 1.0) * 0.5
scale = Vector2.ONE * lerpf(0.72, 1.0, depth)
modulate.a = lerpf(0.45, 1.0, depth)
## 그려지는 대략의 범위. 클릭으로 집을 때 쓴다.
func visual_rect() -> Rect2:
var top := -body_height - hover - _swim_range.y
@@ -72,10 +116,12 @@ func visual_rect() -> Rect2:
func _process(delta: float) -> void:
_t += delta
if is_orbiting():
_apply_orbit(_t)
if _swim_range != Vector2.ZERO:
var next_x := sin(_t * 0.55 + _phase) * _swim_range.x
# 방향을 튼 순간 몸도 돌아본다.
if absf(next_x - _swim.x) > 0.001:
# 방향을 튼 순간 몸도 돌아본다. 궤도를 돌 때는 궤도가 방향을 정한다.
if not is_orbiting() and absf(next_x - _swim.x) > 0.001:
_facing = signf(next_x - _swim.x)
_swim = Vector2(next_x, sin(_t * 0.9 + _phase * 1.7) * _swim_range.y)
queue_redraw()
@@ -96,6 +142,9 @@ func _draw() -> void:
"plant": _draw_plant()
"fish": _draw_fish()
"glow": _draw_glow()
"vessel": _draw_vessel()
"wreck": _draw_wreck()
"pillar": _draw_pillar()
_: _draw_rock()
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
@@ -251,3 +300,107 @@ func _fill(points: PackedVector2Array, fill: Color, outline := true) -> void:
var loop := points.duplicate()
loop.append(points[0])
draw_polyline(loop, fill.darkened(0.55), maxf(span.x * 0.04, 1.0), true)
# --- 집 전용 형태 ---
# 생물과 같은 도형을 쓰면 "건물"과 "사는 것"이 구분되지 않는다.
# 그래서 집은 아래 셋만 쓰고, 셋 다 물고기가 드나들 만한 틈이나 입구를 드러낸다.
## 항아리·유리병. 배가 부르고 목이 좁으며, 꼭대기에 어두운 입구가 뚫려 있다.
func _draw_vessel() -> void:
# 발자국 너비를 다 쓰면 항아리가 납작한 돌처럼 보인다. 좁혀야 그릇으로 읽힌다.
var w := span.x * 0.29
var h := body_height
var steps := 14
var body := PackedVector2Array()
for i in steps + 1:
var f := float(i) / steps
body.append(Vector2(-w * _vessel_profile(f), -h * f))
for i in range(steps, -1, -1):
var f := float(i) / steps
body.append(Vector2(w * _vessel_profile(f), -h * f))
_fill(body, color.darkened(0.18))
# 빛을 받는 왼쪽 배. 몸통보다 좁게 얹어야 둥글어 보인다.
var lit := PackedVector2Array()
for p in body:
lit.append(Vector2(p.x * 0.52 - w * 0.20, p.y))
_fill(lit, color, false)
# 입구. 물고기가 드나드는 곳이라 확실히 어둡게 파 둔다.
var mouth := w * _vessel_profile(1.0)
draw_set_transform(Vector2(0, -h), 0.0, Vector2(1.0, 0.38))
draw_circle(Vector2.ZERO, mouth * 1.05, color.darkened(0.72))
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
## 밑동은 좁고, 중간이 부르고, 목에서 다시 좁아진다.
func _vessel_profile(f: float) -> float:
return 0.55 + sin(clampf(f, 0.0, 1.0) * PI) * 0.50 - f * 0.16
## 난파선·잠수정·고래 뼈. 옆으로 길게 누운 뼈대와 그 사이의 빈틈.
## 기울여 그려야 "가라앉은 것"으로 읽힌다.
func _draw_wreck() -> void:
var w := span.x * 0.44
var h := body_height
var lean := h * 0.13 # 오른쪽으로 기운 정도
var deck := -h * 0.52
# 선체. 아래는 둥글고 위는 갑판으로 열려 있다.
var hull := PackedVector2Array()
var steps := 12
for i in steps + 1:
var a := PI + PI * float(i) / steps
hull.append(Vector2(cos(a) * w, sin(a) * h * 0.46))
hull.append(Vector2(w + lean, deck))
hull.append(Vector2(-w + lean * 0.4, deck * 0.72))
_fill(hull, color)
# 갈비뼈. 사이의 빈틈이 물고기가 숨는 자리로 읽힌다.
var rib := color.darkened(0.48)
var thick := maxf(span.x * 0.035, 1.2)
for i in 3:
var x := (float(i) - 1.0) * w * 0.52
var top := deck * (0.78 + float(i) * 0.14)
draw_line(Vector2(x, -h * 0.06), Vector2(x + lean * 0.8, top), rib, thick)
# 부러진 돛대 하나. 실루엣에 세로선이 하나 있어야 배로 읽힌다.
draw_line(Vector2(-w * 0.30, deck * 0.85),
Vector2(-w * 0.30 + lean * 1.6, -h * 1.0), color.darkened(0.32), thick * 1.3)
## 석주·석상·굴뚝·수정. 위로 곧게 선 기둥. 이음매를 그어 돌덩이로 읽히게 한다.
func _draw_pillar() -> void:
var w := span.x * 0.22
var h := body_height
var body := PackedVector2Array([
Vector2(-w * 1.30, 0.0),
Vector2(-w * 0.92, -h * 0.90),
Vector2(-w * 1.02, -h),
Vector2(w * 1.02, -h),
Vector2(w * 0.92, -h * 0.90),
Vector2(w * 1.30, 0.0),
])
_fill(body, color.darkened(0.26))
# 빛을 받는 왼쪽 면.
var lit := PackedVector2Array()
for p in body:
lit.append(Vector2(p.x * 0.46 - w * 0.34, p.y))
_fill(lit, color, false)
# 돌을 쌓아 올린 이음매.
var seam := color.darkened(0.52)
for i in 3:
var y := -h * (0.26 + float(i) * 0.23)
var half := w * lerpf(1.24, 1.0, float(i) / 3.0)
draw_line(Vector2(-half, y), Vector2(half, y), seam, maxf(span.x * 0.028, 1.0))
# 꼭대기 면. 부감이라 윗면이 살짝 보인다.
draw_set_transform(Vector2(0, -h), 0.0, Vector2(1.0, 0.34))
draw_circle(Vector2.ZERO, w * 1.02, color.lightened(0.18))
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
+38 -20
View File
@@ -57,13 +57,17 @@ func _ready() -> void:
shop.hide()
shop.closed.connect(_close_shop)
shop.item_selected.connect(_on_shop_item_selected)
# 격자는 정보 바 아래에서 시작해야 한다. 바 높이는 HUD가 재어서 알려준다.
hud.reserved_top_changed.connect(func(px: float): aquarium.grid_top = px)
aquarium.grid_top = hud.reserved_top()
hud.shop_pressed.connect(_toggle_shop)
hud.menu_pressed.connect(_open_menu)
aquarium.message.connect(hud.show_toast)
aquarium.clicked.connect(_on_water_clicked)
aquarium.clicked.connect(_on_water_touched)
aquarium.context_menu_requested.connect(_open_menu)
aquarium.placement_changed.connect(_on_placement_changed)
aquarium.build_mode_changed.connect(_on_build_mode_changed)
aquarium.drag_changed.connect(_on_drag_changed)
_build_menu()
@@ -128,46 +132,61 @@ func _apply_passthrough() -> void:
# --- 게임 입력 ---
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel") and aquarium.is_placing():
aquarium.cancel_placement()
if event.is_action_pressed("ui_cancel") and aquarium.is_building():
aquarium.cancel_build()
get_viewport().set_input_as_handled()
func _on_water_clicked(pos: Vector2) -> void:
var gained := GameState.click()
## 물을 만지면 거품이 퍼진다. 거품은 생물이 버는 것이라 여기서 얻는 것은 없다.
func _on_water_touched(pos: Vector2) -> void:
aquarium.pop_at(pos)
hud.float_number(pos, gained)
func _toggle_shop() -> void:
if shop.visible:
_close_shop()
else:
aquarium.cancel_placement()
aquarium.cancel_build()
shop.show()
func _close_shop() -> void:
shop.hide()
aquarium.cancel_placement()
aquarium.cancel_build()
## 상점에서 무언가를 골랐을 때.
## 집만 바다에 자리를 잡아야 하므로 배치 모드로 들어가고,
## 생물과 업그레이드는 자리가 필요 없어서 그 자리에서 바로 사진다.
func _on_shop_item_selected(kind: String, id: String) -> void:
if kind == "upgrade":
if GameState.buy_upgrade(id):
hud.show_toast("%s 켰어요" % Korean.eul(String(Catalog.upgrade(id).name)))
match kind:
"upgrade":
if GameState.buy_upgrade(id):
hud.show_toast("%s 켰어요" % Korean.eul(String(Catalog.upgrade(id).name)))
"producer":
_take_fish(id)
"house":
aquarium.begin_build(id)
func _take_fish(id: String) -> void:
var fish_name := String(Catalog.producer(id).get("name", ""))
if GameState.buy_fish(id):
var z := Catalog.zone_of("producer", id)
hud.show_toast("%s 들였어요 · %s 남은 자리 %d" % [
Korean.eul(fish_name), Catalog.zone(z).name, GameState.free_slots_in_zone(z)])
return
aquarium.begin_placement(kind, id)
var why := GameState.fish_blocker(id)
hud.show_toast(why if not why.is_empty() else "지금은 들일 수 없어요", 3.0)
## 배치 중에는 상점을 잠시 치운다. 상점이 바다 오른쪽을 가리고 있어서
## 열어 둔 채로는 뒤쪽 구역에 을 수가 없다.
func _on_placement_changed(active: bool, kind: String, id: String) -> void:
shop.set_selected(active, kind, id)
## 집을 짓는 동안에는 상점을 잠시 치운다. 상점이 바다 오른쪽을 가리고 있어서
## 열어 둔 채로는 뒤쪽 구역에 을 수가 없다.
func _on_build_mode_changed(active: bool, id: String) -> void:
shop.set_selected(active, id)
shop.visible = not active
if active:
hud.show_toast("을 칸을 클릭하세요 · 우클릭이나 Esc로 취소", 3.0)
hud.show_toast("집을 지을 칸을 클릭하세요 · 우클릭이나 Esc로 취소", 3.0)
func _on_drag_changed(dragging: bool) -> void:
hud.set_trash_visible(dragging)
@@ -270,7 +289,6 @@ func _confirm_reset() -> void:
add_child(dialog)
dialog.confirmed.connect(func():
SaveManager.wipe()
aquarium.rebuild()
hud.show_toast("수조를 비웠어요")
)
dialog.close_requested.connect(dialog.queue_free)
+17 -16
View File
@@ -5,6 +5,12 @@ class_name HUDLayer
signal shop_pressed
signal menu_pressed
## 정보 바가 위에서 자리를 얼마나 먹는지. 바 높이는 글꼴과 내용에 따라 달라지므로
## 재어서 알려주고, main이 받아 수조 격자를 그만큼 아래로 내린다.
signal reserved_top_changed(px: float)
## 바 아래로 이만큼은 띄워야 첫 줄 칸이 바에 닿지 않는다.
const BAR_CLEARANCE := 6.0
@onready var control_bar: PanelContainer = $ControlBar
@onready var bubbles_label: Label = %BubblesLabel
@@ -38,6 +44,8 @@ func _ready() -> void:
shop_button.pressed.connect(func(): shop_pressed.emit())
menu_button.pressed.connect(func(): menu_pressed.emit())
control_bar.resized.connect(func(): reserved_top_changed.emit(reserved_top()))
GameState.zone_unlocked.connect(func(_i: int): _refresh_zone_label())
GameState.state_changed.connect(_refresh_zone_label)
GameState.reloaded.connect(_refresh_zone_label)
@@ -47,7 +55,10 @@ func _ready() -> void:
func _process(_delta: float) -> void:
bubbles_label.text = NumberFormat.short(GameState.bubbles)
bps_label.text = "초당 %s" % NumberFormat.short(GameState.bps)
# 정원이 곧 성장의 한계라 초당 생산량 옆에 늘 같이 둔다.
bps_label.text = "초당 %s · 생물 %d/%d" % [
NumberFormat.short(GameState.bps), GameState.fish_total, GameState.capacity_total()
]
beauty_label.text = "아름다움 %s · x%.2f" % [
NumberFormat.short(GameState.beauty), GameState.beauty_mult
]
@@ -82,21 +93,6 @@ func show_toast(text: String, duration := 2.0) -> void:
_toast_tween.tween_property(toast, "modulate:a", 0.0, 0.5)
## 클릭했을 때 얻은 양이 위로 떠오르는 숫자.
func float_number(at: Vector2, amount: float) -> void:
var l := Label.new()
add_child(l)
l.text = "+%s" % NumberFormat.short(amount)
UIStyle.label(l, 15, UIStyle.ACCENT)
l.position = at - Vector2(14, 10)
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
var t := create_tween().set_parallel(true)
t.tween_property(l, "position:y", l.position.y - 42.0, 0.9) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
t.tween_property(l, "modulate:a", 0.0, 0.9)
t.chain().tween_callback(l.queue_free)
## 무언가를 집어 든 동안만 휴지통을 보여준다. 평소에는 자리를 차지하지 않는다.
func set_trash_visible(on: bool) -> void:
trash.visible = on
@@ -105,3 +101,8 @@ func set_trash_visible(on: bool) -> void:
## 휴지통의 사각형. HUD와 바다가 같은 좌표계(Main의 전체 영역)를 쓰므로 그대로 넘길 수 있다.
func trash_area() -> Rect2:
return Rect2(trash.position, trash.size)
## 정보 바가 먹는 세로 자리. 격자는 이 아래에서 시작해야 한다.
func reserved_top() -> float:
return control_bar.position.y + control_bar.size.y + BAR_CLEARANCE
+40 -26
View File
@@ -1,18 +1,18 @@
extends PanelContainer
class_name ShopPanel
## 상점. 생물 / 장식 / 업그레이드 세 탭.
## 상점. 생물 / / 업그레이드 세 탭.
##
## 생물과 장식은 여기서 바로 사지지 않는다. 고르면 배치 모드로 들어가고,
## 실제 구매는 바다에서 칸을 클릭할 때 일어난다. 업그레이드만 즉시 구매다.
## 집만 바다에 자리를 잡아야 하므로, 고르면 배치 모드로 들어가고
## 실제 값은 칸을 클릭할 때 치러진다. 생물과 업그레이드는 여기서 바로 사진다.
## 목록은 Catalog에서 바로 만들어지므로, 아이템을 추가할 때 이 파일은 건드릴 필요가 없다.
signal closed
## 고른 아이템. kind가 "upgrade"면 즉시 구매, 아니면 배치 모드로 들어간다.
## 고른 것. "house"면 배치 모드로 들어가고, 나머지는 즉시 구매다.
signal item_selected(kind: String, id: String)
const TABS := [
{"kind": "producer", "label": "생물"},
{"kind": "ornament", "label": "장식"},
{"kind": "house", "label": ""},
{"kind": "upgrade", "label": "업그레이드"},
]
@@ -54,8 +54,8 @@ func _process(_delta: float) -> void:
_update_rows()
## 배치 모드가 켜지고 꺼질 때 main이 알려준다. 고른 행을 강조해 둔다.
func set_selected(active: bool, _kind: String, id: String) -> void:
## 집 짓기 모드가 켜지고 꺼질 때 main이 알려준다. 고른 행을 강조해 둔다.
func set_selected(active: bool, id: String) -> void:
_selected_id = id if active else ""
_update_rows()
@@ -77,8 +77,8 @@ func _visible_ids() -> Array[String]:
for d in _current_items():
if not GameState.is_revealed(kind, d.id):
continue
# 한 번만 살 수 있는 것은 이미 가지고 있으면 목록에서 빠진다.
if kind != "producer" and GameState.owned(kind, d.id):
# 업그레이드만 한 번뿐이다. 생물과 집은 얼마든지 더 들일 수 있다.
if GameState.owned(kind, String(d.id)):
continue
ids.append(String(d.id))
return ids
@@ -192,16 +192,16 @@ func _name_text(kind: String, d: Dictionary) -> String:
func _effect_text(kind: String, d: Dictionary) -> String:
var zone_name := String(Catalog.zone(int(d.get("zone", 0))).name)
match kind:
"producer":
return "%s · 초당 +%s · 아름다움 +%s" % [
Catalog.zone(int(d.get("zone", 0))).name,
NumberFormat.short(d.bps), NumberFormat.short(d.beauty)
zone_name, NumberFormat.short(d.bps), NumberFormat.short(d.beauty)
]
"ornament":
return "%s · 아름다움 +%s · 생산 +%d%%" % [
Catalog.zone(int(d.get("zone", 0))).name,
NumberFormat.short(d.beauty), int(round(float(d.mult) * 100.0))
"house":
return "%s · 생물 %d마리 · 아름다움 +%s" % [
zone_name, Catalog.capacity_of(String(d.id)),
NumberFormat.short(d.beauty)
]
"upgrade":
return String(d.desc)
@@ -214,28 +214,42 @@ func _update_rows() -> void:
var kind: String = r.kind
var cost := GameState.cost_of(kind, id)
var affordable := GameState.bubbles >= cost
# 생물은 값을 치를 수 있어도 살 집이 없으면 못 들인다.
var has_room: bool = kind != "producer" or GameState.can_take_fish(String(id))
r.cost.text = NumberFormat.short(cost)
r.cost.modulate = Color.WHITE if affordable else Color(1, 1, 1, 0.45)
r.button.disabled = not affordable
# 고른 것은 밝게 남겨 두어 지금 무엇을 는 중인지 보이게 한다.
r.button.disabled = not affordable or not has_room
# 고른 것은 밝게 남겨 두어 지금 무엇을 는 중인지 보이게 한다.
r.button.modulate = Color(1.0, 1.25, 1.35) if id == _selected_id else Color.WHITE
if kind == "producer":
var owned := GameState.count_of(id)
r.owned.text = "놓임 %d" % owned if owned > 0 else ""
else:
r.owned.text = ""
var owned := GameState.count_of(kind, String(id))
match kind:
"producer":
if not has_room and affordable:
r.owned.text = "자리 없음"
else:
r.owned.text = "%d마리" % owned if owned > 0 else ""
"house":
r.owned.text = "%d" % owned if owned > 0 else ""
_:
r.owned.text = ""
_update_space_label()
## 구역별로 빈 칸이 얼마나 남았는지. 칸이 곧 한계이므로 늘 보이게 둔다.
## 구역별로 생물 자리가 얼마나 남았는지. 이것이 성장의 진짜 한계다.
## 집 탭에서는 집을 세울 빈 칸을 대신 보여준다.
func _update_space_label() -> void:
if TABS[_tab].kind == "upgrade":
space_label.text = "칸을 차지하지 않아요"
space_label.text = "자리를 차지하지 않아요"
return
var parts := PackedStringArray()
var is_house_tab: bool = TABS[_tab].kind == "house"
for i in GameState.unlocked_zones:
parts.append("%s %d" % [Catalog.zone(i).name, GameState.free_cells_in_zone(i)])
space_label.text = "남은 칸 · " + " ".join(parts)
var left := GameState.free_cells_in_zone(i) if is_house_tab \
else maxi(GameState.free_slots_in_zone(i), 0)
parts.append("%s %d" % [Catalog.zone(i).name, left])
var label := "빈 칸 · " if is_house_tab else "남은 자리 · "
space_label.text = label + " ".join(parts)