바탕화면 바닷속 방치형 위젯 첫 커밋

화면 아래 가장자리에 가로로 깔리는 Godot 4.7 위젯.
거품을 모아 바다를 꾸미고, 꾸밀수록 거품이 더 빨리 모인다.

- 도킹 창: 투명·테두리 없음, 화면 폭 전체, 위/아래 가장자리 스냅,
  높이 프리셋, 클릭 통과(모서리로 복귀), DPI 배율 보정
- 3/4 부감 격자: 구역당 9x6, 전체 45x6 = 270칸.
  바닥은 위에서, 물체는 서 있는 모습으로 그리고 Y 정렬로 앞뒤를 가린다
- 구역 5개(얕은 바다 -> 열수구): 앞 구역을 꾸며야 다음이 열린다
- 칸이 곧 한계인 경제: 배치/이동/치우기(절반 환불), 여러 칸짜리 장식
- 저장: 30초 자동 저장, 백업 한 세대, 8시간 상한 오프라인 보상,
  격자 이전 형식 저장본 자동 이전
- 스모크 테스트 60여 개 (test.ps1)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 17:59:50 +09:00
commit 446d8d177c
48 changed files with 3584 additions and 0 deletions
+525
View File
@@ -0,0 +1,525 @@
extends Control
class_name Aquarium
## 화면 하단에 깔리는 탑다운 바다.
## 가로를 구역 수만큼 나눠, 왼쪽(얕은 바다)에서 오른쪽(열수구)으로 갈수록 깊어진다.
## 구역마다 격자가 있고, 산 것은 플레이어가 고른 칸에 놓인다.
## 알림을 띄워야 할 때 (해금 실패, 배치 실패, 환불 등). main이 받아 HUD로 넘긴다.
signal message(text: String)
## 빈 곳을 눌렀을 때. 거품 클릭 연출에 쓴다.
signal clicked(local_position: Vector2)
## 배치 모드가 켜지거나 꺼졌을 때. 상점이 선택 상태를 표시하는 데 쓴다.
signal placement_changed(active: bool, kind: String, 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
## 위쪽 이만큼은 정보 바가 앉는 자리라 격자에서 뺀다.
## 여기를 비워두지 않으면 첫 줄 칸이 바에 가려 보이지도, 눌리지도 않는다.
const GRID_TOP := 30.0
const DRAG_THRESHOLD := 5.0
## _press_index에 쓰는 표식. 이번 누름은 클릭이 아니라 드래그였다는 뜻.
const PRESS_WAS_DRAG := -2
var _rng := RandomNumberGenerator.new()
var _lock_reasons := {} ## 구역 인덱스 -> 사유 Label
var _base_material: ShaderMaterial
var _placing_kind := ""
var _placing_id := ""
var _hover := Vector2i(-1, -1)
var _press_position := Vector2.ZERO
var _press_index := -1
var _drag_index := -1
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:
return Rect2(Vector2(0.0, GRID_TOP), Vector2(size.x, maxf(size.y - GRID_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()
return Vector2i(int(floor(pos.x / c.x)), int(floor((pos.y - GRID_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(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(kind: String, id: String, hover: Vector2i) -> Vector2i:
var s := Catalog.size_of(kind, 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_TOP
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.placements.size():
_make_item(i)
func _make_item(index: int) -> TileItem:
var p := GameState.placements[index]
var d := Catalog.item(p.kind, p.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)
return node
func _on_placed(index: int) -> void:
var node := _make_item(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
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.placement_index
if best >= 0:
return best
# 그림에 안 걸렸으면 발자국 칸으로 한 번 더 본다.
var cell := cell_at(pos)
return GameState.placement_at(cell.x, cell.y)
func _item_node(index: int) -> TileItem:
for child in items.get_children():
var it := child as TileItem
if it and it.placement_index == index:
return it
return null
# --- 배치 모드 ---
func begin_placement(kind: String, id: String) -> void:
if kind == "upgrade":
return
_cancel_drag()
_placing_kind = kind
_placing_id = id
placement_changed.emit(true, kind, id)
_update_ghost()
func cancel_placement() -> void:
if _placing_id.is_empty():
return
_placing_kind = ""
_placing_id = ""
ghost.hide()
placement_changed.emit(false, "", "")
func is_placing() -> bool:
return not _placing_id.is_empty()
func _update_ghost() -> void:
if not is_placing() 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)
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)
if GameState.bubbles < GameState.cost_of(kind, id):
message.emit("거품이 모자라요")
cancel_placement()
return
if not GameState.can_place_at(kind, id, anchor.x, anchor.y):
message.emit(_why_not(kind, id, anchor))
return
GameState.place(kind, id, anchor.x, anchor.y)
# 계속 놓을 수 있으면 모드를 유지한다. 같은 걸 여러 개 깔 때 편하다.
if kind == "ornament" or GameState.bubbles < GameState.cost_of(kind, id):
cancel_placement()
else:
_update_ghost()
func _why_not(kind: String, id: String, anchor: Vector2i) -> String:
var item_zone := Catalog.zone_of(kind, 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)
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 _drag_index >= 0:
_update_drag(event.position)
elif is_placing():
_update_ghost()
elif _press_index >= 0 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_placing():
cancel_placement()
elif _drag_index >= 0:
_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_placing():
_try_place()
return
_press_position = event.position
_press_index = _pick_at(event.position)
else:
if _drag_index >= 0:
_drop(event.position)
elif _press_index != PRESS_WAS_DRAG:
# 끌지 않고 뗐으면 그냥 물을 클릭한 것으로 친다.
clicked.emit(event.position)
_press_index = -1
func _begin_drag() -> void:
if _press_index < 0:
return
_drag_index = _press_index
_press_index = PRESS_WAS_DRAG
drag_changed.emit(true)
func _update_drag(pos: Vector2) -> void:
var node := _item_node(_drag_index)
if node:
node.position = pos
if trash_rect.has_point(pos):
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)
func _drop(pos: Vector2) -> void:
var index := _drag_index
_drag_index = -1
ghost.hide()
drag_changed.emit(false)
if index < 0 or index >= GameState.placements.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)])
return
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 _cancel_drag() -> void:
if _drag_index < 0:
return
_drag_index = -1
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)