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)