Files
blub-blub/scenes/aquarium/aquarium.gd
T
ttipo 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
2026-09-01 22:39:25 +09:00

701 lines
23 KiB
GDScript

extends Control
class_name Aquarium
## 화면 하단에 깔리는 탑다운 바다.
## 가로를 구역 수만큼 나눠, 왼쪽(얕은 바다)에서 오른쪽(열수구)으로 갈수록 깊어진다.
##
## 격자 칸을 차지하는 것은 집뿐이다. 생물은 칸이 아니라 집에 살기 때문에
## 그릴 때마다 구역별로 집에 차례로 배정해서 그 집 둘레에 세운다.
## 알림을 띄워야 할 때 (해금 실패, 건축 실패, 환불 등). main이 받아 HUD로 넘긴다.
signal message(text: String)
## 빈 곳을 눌렀을 때. 거품이 퍼지는 연출에만 쓴다 — 거품은 생물이 번다.
signal clicked(local_position: Vector2)
## 집 짓기 모드가 켜지거나 꺼졌을 때. 상점이 선택 상태를 표시하는 데 쓴다.
signal build_mode_changed(active: bool, id: String)
## 이미 놓인 것을 집었거나 놓았을 때. HUD가 휴지통을 보였다 감추는 데 쓴다.
signal drag_changed(dragging: bool)
## 물 위에서 우클릭. main이 메뉴를 연다.
signal context_menu_requested
## 드래그한 것을 여기에 떨구면 치운다. main이 HUD의 휴지통 위치를 넣어준다.
var trash_rect := Rect2()
@onready var ground: Seabed = $Ground
@onready var items: Node2D = $Items
@onready var water: Control = $Water
@onready var bubbles: CPUParticles2D = $Bubbles
@onready var ghost: GridGhost = $Ghost
@onready var dividers: Control = $Dividers
@onready var zone_labels: Control = $ZoneLabels
@onready var locks: Control = $Locks
## 위쪽 이만큼은 정보 바가 앉는 자리라 격자에서 뺀다.
## 여기를 비워두지 않으면 첫 줄 칸이 바에 가려 보이지도, 눌리지도 않는다.
## 바 높이는 글꼴과 담긴 내용에 따라 달라지므로 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
## 물고기가 집을 한 바퀴 도는 속도(라디안/초). 위젯이라 눈에 거슬리지 않게 느리다.
const ORBIT_SPEED := 0.5
var _rng := RandomNumberGenerator.new()
var _lock_reasons := {} ## 구역 인덱스 -> 사유 Label
var _base_material: ShaderMaterial
## 지금 짓는 중인 집의 id. 비어 있으면 짓는 중이 아니다.
var _building_id := ""
var _hover := Vector2i(-1, -1)
var _press_position := Vector2.ZERO
## 누르거나 끌고 있는 대상. {} 이면 없음. TileItem의 ref_* 를 그대로 담는다.
var _press_ref := {}
var _drag_ref := {}
var _drag_node: TileItem = null
## 이번 누름이 클릭이 아니라 드래그였다는 표식.
var _press_was_drag := false
func _ready() -> void:
_rng.randomize()
_base_material = preload("res://assets/shaders/water_material.tres")
items.y_sort_enabled = true
ghost.hide()
_setup_bubbles()
resized.connect(_on_layout_changed)
GameState.zone_unlocked.connect(_on_zone_unlocked)
GameState.reloaded.connect(_rebuild_items)
GameState.placed.connect(_on_placed)
_on_layout_changed()
func _process(_delta: float) -> void:
# 잠긴 구역의 안내 문구는 거품이 쌓이면서 계속 바뀐다.
for index in _lock_reasons:
if index == GameState.next_zone_index():
_lock_reasons[index].text = GameState.next_zone_blocker()
# --- 격자 좌표 ---
## 격자가 실제로 깔리는 영역. 위쪽 정보 바 자리는 뺀다.
func grid_rect() -> Rect2:
# 스트립이 낮을 때 바가 격자를 다 먹어버리지 않도록 위쪽 절반은 넘기지 않는다.
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:
var g := grid_rect()
return Vector2(g.size.x / GameState.columns(), g.size.y / GameState.rows())
func cell_at(pos: Vector2) -> Vector2i:
var c := cell_size()
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_rect().position.y + row * c.y),
Vector2(c.x * span.x, c.y * span.y))
func zone_rect(index: int) -> Rect2:
var c := cell_size()
return Rect2(
Vector2(index * Catalog.ZONE_COLUMNS * c.x, 0.0),
Vector2(Catalog.ZONE_COLUMNS * c.x, size.y))
## 커서가 가리키는 칸을 기준으로 집 발자국을 가운데 맞춘 왼쪽 위 칸.
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)
# --- 화면 구성 ---
## 크기가 바뀌거나 구역이 열리면 전부 다시 만든다.
## 스트립은 자주 바뀌지 않으므로 부분 갱신보다 통째로 다시 만드는 쪽이 단순하다.
func _on_layout_changed() -> void:
ground.rect_size = size
ground.grid_top = grid_rect().position.y
ground.queue_redraw()
_build_water()
_build_dividers()
_build_zone_labels()
_build_locks()
_rebuild_items()
bubbles.position = size * 0.5
bubbles.emission_rect_extents = size * 0.5
bubbles.amount = clampi(int(size.x / 40.0), 10, 60)
func _build_water() -> void:
for child in water.get_children():
child.queue_free()
var n := Catalog.zone_count()
for i in n:
var r := zone_rect(i)
var rect := ColorRect.new()
rect.position = r.position
rect.size = r.size
rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
var mat: ShaderMaterial = _base_material.duplicate()
# 경계 값을 이웃과 공유해서 이음매가 보이지 않게 한다.
mat.set_shader_parameter("tint_left", Catalog.ramp_color("tint", float(i) / n))
mat.set_shader_parameter("tint_right", Catalog.ramp_color("tint", float(i + 1) / n))
mat.set_shader_parameter("murk_left", Catalog.ramp_float("murk", float(i) / n))
mat.set_shader_parameter("murk_right", Catalog.ramp_float("murk", float(i + 1) / n))
mat.set_shader_parameter("x_offset", r.position.x)
mat.set_shader_parameter("rect_size", r.size)
rect.material = mat
water.add_child(rect)
func _build_dividers() -> void:
for child in dividers.get_children():
child.queue_free()
for i in range(1, Catalog.zone_count()):
var line := ColorRect.new()
line.color = Color(1, 1, 1, 0.10)
line.position = Vector2(zone_rect(i).position.x - 1.0, 0.0)
line.size = Vector2(2.0, size.y)
line.mouse_filter = Control.MOUSE_FILTER_IGNORE
dividers.add_child(line)
func _build_zone_labels() -> void:
for child in zone_labels.get_children():
child.queue_free()
for i in Catalog.zone_count():
if not GameState.is_zone_unlocked(i):
continue
var r := zone_rect(i)
var l := Label.new()
l.text = Catalog.zone(i).name
# 바닥 오른쪽 아래 구석에 새겨 넣는다.
l.size = Vector2(r.size.x - 10.0, 16.0)
l.position = Vector2(r.position.x, r.size.y - 19.0)
l.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
l.add_theme_font_size_override("font_size", 10)
# 바닥에 새긴 것처럼. 밝은 모래 위에서도 어두운 물속에서도 읽히려면
# 흰색 반투명이 아니라 그 자리 모래색을 어둡게 쓴 편이 낫다.
var center := (float(i) + 0.5) / Catalog.zone_count()
l.add_theme_color_override("font_color",
Catalog.ramp_color("sand", center).darkened(0.45))
zone_labels.add_child(l)
func _build_locks() -> void:
for child in locks.get_children():
child.queue_free()
_lock_reasons.clear()
for i in Catalog.zone_count():
if not GameState.is_zone_unlocked(i):
locks.add_child(_make_lock(i))
func _make_lock(index: int) -> Button:
var r := zone_rect(index)
var z := Catalog.zone(index)
var is_next := index == GameState.next_zone_index()
var veil := Button.new()
veil.position = r.position
veil.size = r.size
veil.add_theme_stylebox_override("normal", UIStyle.veil(0.70))
veil.add_theme_stylebox_override("hover", UIStyle.veil(0.60 if is_next else 0.70))
veil.add_theme_stylebox_override("pressed", UIStyle.veil(0.54 if is_next else 0.70))
veil.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
veil.pressed.connect(_on_lock_pressed.bind(index))
var box := VBoxContainer.new()
box.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
box.alignment = BoxContainer.ALIGNMENT_CENTER
box.mouse_filter = Control.MOUSE_FILTER_IGNORE
box.add_theme_constant_override("separation", 3)
veil.add_child(box)
var name_label := Label.new()
name_label.text = z.name if is_next else "???"
name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
UIStyle.label(name_label, 15, Color(1, 1, 1, 0.75 if is_next else 0.35))
box.add_child(name_label)
if not is_next:
# 다음 차례가 아닌 구역은 무엇이 있는지도 알려주지 않는다.
return veil
var cost_label := Label.new()
cost_label.text = "해금 %s" % NumberFormat.short(float(z.unlock_cost))
cost_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
cost_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
UIStyle.label(cost_label, 13, UIStyle.GOLD)
box.add_child(cost_label)
var reason := Label.new()
reason.text = GameState.next_zone_blocker()
reason.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
reason.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
reason.custom_minimum_size = Vector2(r.size.x - 24.0, 0.0)
reason.mouse_filter = Control.MOUSE_FILTER_IGNORE
UIStyle.label(reason, 11, UIStyle.INK_DIM)
box.add_child(reason)
_lock_reasons[index] = reason
return veil
func _on_lock_pressed(index: int) -> void:
if index != GameState.next_zone_index():
message.emit("%s부터 열어야 해요" % Catalog.zone(GameState.next_zone_index()).name)
return
var blocker := GameState.next_zone_blocker()
if not blocker.is_empty():
message.emit(blocker)
return
GameState.unlock_next_zone()
func _on_zone_unlocked(index: int) -> void:
message.emit("%s 열렸어요" % Korean.i(String(Catalog.zone(index).name)))
_on_layout_changed()
# --- 집과 생물 그리기 ---
func _setup_bubbles() -> void:
bubbles.emitting = true
bubbles.lifetime = 3.5
bubbles.emission_shape = CPUParticles2D.EMISSION_SHAPE_RECTANGLE
bubbles.gravity = Vector2.ZERO
bubbles.initial_velocity_min = 0.0
bubbles.initial_velocity_max = 5.0
# 위에서 내려다보므로 거품은 위로 흐르는 대신 제자리에서 부풀었다 사라진다.
bubbles.scale_amount_min = 0.4
bubbles.scale_amount_max = 1.6
bubbles.color = Color(1, 1, 1, 0.20)
func _rebuild_items() -> void:
for child in items.get_children():
child.queue_free()
for i in GameState.houses.size():
_make_house(i)
for entry in _assign_fish():
_make_fish(entry)
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, "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:
# 집이 하나 늘면 생물 배정도 달라지므로 통째로 다시 그린다.
_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) -> Dictionary:
var best: TileItem = null
var best_y := -INF
for child in items.get_children():
var it := child as TileItem
if it == null:
continue
var r := it.visual_rect()
r.position += it.position
if r.has_point(pos) and it.position.y > best_y:
best_y = it.position.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 _node_for_house(index: int) -> TileItem:
for child in items.get_children():
var it := child as TileItem
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_build(id: String) -> void:
if Catalog.house(id).is_empty():
return
_cancel_drag()
_building_id = id
build_mode_changed.emit(true, id)
_update_ghost()
func cancel_build() -> void:
if _building_id.is_empty():
return
_building_id = ""
ghost.hide()
build_mode_changed.emit(false, "")
func is_building() -> bool:
return not _building_id.is_empty()
func _update_ghost() -> void:
if not is_building() or _hover.x < 0:
ghost.hide()
return
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_build() -> void:
var id := _building_id
var anchor := _anchor_for(id, _hover)
if GameState.bubbles < GameState.cost_of("house", id):
message.emit("거품이 모자라요")
cancel_build()
return
if not GameState.can_build_at(id, anchor.x, anchor.y):
message.emit(_why_not(id, anchor))
return
GameState.build_house(id, anchor.x, anchor.y)
# 계속 지을 수 있으면 모드를 유지한다. 같은 집을 여러 채 깔 때 편하다.
if GameState.bubbles < GameState.cost_of("house", id):
cancel_build()
else:
_update_ghost()
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(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 "이미 무언가 서 있어요"
# --- 입력 ---
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
_hover = cell_at(event.position)
if not _drag_ref.is_empty():
_update_drag(event.position)
elif is_building():
_update_ghost()
elif not _press_ref.is_empty() and not _press_was_drag \
and event.position.distance_to(_press_position) > DRAG_THRESHOLD:
_begin_drag()
return
if event is not InputEventMouseButton:
return
if event.button_index == MOUSE_BUTTON_RIGHT and event.pressed:
if is_building():
cancel_build()
elif not _drag_ref.is_empty():
_cancel_drag()
else:
context_menu_requested.emit()
return
if event.button_index != MOUSE_BUTTON_LEFT:
return
if event.pressed:
_hover = cell_at(event.position)
if is_building():
_try_build()
return
_press_position = event.position
_press_ref = _pick_at(event.position)
_press_was_drag = false
else:
if not _drag_ref.is_empty():
_drop(event.position)
elif not _press_was_drag:
# 끌지 않고 뗐으면 물을 만진 것으로 친다.
clicked.emit(event.position)
_press_ref = {}
_press_was_drag = false
func _begin_drag() -> void:
if _press_ref.is_empty():
return
_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:
if _drag_node and is_instance_valid(_drag_node):
_drag_node.position = pos
# 생물은 칸을 차지하지 않으므로 놓일 자리를 미리 보여줄 것이 없다.
if trash_rect.has_point(pos) or String(_drag_ref.kind) != "house":
ghost.hide()
return
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 ref := _drag_ref
_drag_ref = {}
_drag_node = null
ghost.hide()
drag_changed.emit(false)
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 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)])
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_ref.is_empty():
return
_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)
burst.position = pos
burst.emitting = true
burst.one_shot = true
burst.explosiveness = 1.0
burst.amount = 10
burst.lifetime = 0.6
burst.gravity = Vector2.ZERO
burst.initial_velocity_min = 40.0
burst.initial_velocity_max = 110.0
burst.scale_amount_min = 0.5
burst.scale_amount_max = 1.3
burst.color = Color(1, 1, 1, 0.55)
get_tree().create_timer(1.0).timeout.connect(burst.queue_free)