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

화면 아래 가장자리에 가로로 깔리는 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
+107
View File
@@ -0,0 +1,107 @@
extends Control
class_name HUDLayer
## 스트립 왼쪽 위에 붙는 한 줄짜리 정보 바와 짧은 알림.
## 스트립은 화면에 도킹되므로 창을 끌어 옮기는 기능은 없다. 위치는 메뉴에서 바꾼다.
signal shop_pressed
signal menu_pressed
@onready var control_bar: PanelContainer = $ControlBar
@onready var bubbles_label: Label = %BubblesLabel
@onready var bps_label: Label = %BpsLabel
@onready var beauty_label: Label = %BeautyLabel
@onready var zone_label: Label = %ZoneLabel
@onready var shop_button: Button = %ShopButton
@onready var menu_button: Button = %MenuButton
@onready var toast: Label = %Toast
@onready var trash: PanelContainer = %Trash
@onready var trash_label: Label = %TrashLabel
var _toast_tween: Tween
func _ready() -> void:
control_bar.add_theme_stylebox_override("panel", UIStyle.panel(UIStyle.PANEL_BG, 12))
UIStyle.label(bubbles_label, 24, UIStyle.INK)
UIStyle.label(bps_label, 13, UIStyle.INK_DIM)
UIStyle.label(beauty_label, 13, UIStyle.GOLD)
UIStyle.label(zone_label, 13, UIStyle.ACCENT)
UIStyle.label(toast, 14, UIStyle.INK)
toast.modulate.a = 0.0
trash.add_theme_stylebox_override("panel", UIStyle.panel(Color(0.35, 0.06, 0.10, 0.88), 12))
UIStyle.label(trash_label, 13, UIStyle.INK)
trash.hide()
UIStyle.style_button(shop_button)
UIStyle.style_button(menu_button)
shop_button.pressed.connect(func(): shop_pressed.emit())
menu_button.pressed.connect(func(): menu_pressed.emit())
GameState.zone_unlocked.connect(func(_i: int): _refresh_zone_label())
GameState.state_changed.connect(_refresh_zone_label)
GameState.reloaded.connect(_refresh_zone_label)
SaveManager.offline_earned.connect(_on_offline_earned)
_refresh_zone_label()
func _process(_delta: float) -> void:
bubbles_label.text = NumberFormat.short(GameState.bubbles)
bps_label.text = "초당 %s" % NumberFormat.short(GameState.bps)
beauty_label.text = "아름다움 %s · x%.2f" % [
NumberFormat.short(GameState.beauty), GameState.beauty_mult
]
## 몇 개 구역을 열었고 다음은 무엇인지. 해금 조건 자체는 잠긴 구역 위에 직접 표시된다.
func _refresh_zone_label() -> void:
var opened := GameState.unlocked_zones
var total := Catalog.zone_count()
if not GameState.has_next_zone():
zone_label.text = "구역 %d/%d · 모두 열었어요" % [opened, total]
return
var next_zone := Catalog.zone(GameState.next_zone_index())
zone_label.text = "구역 %d/%d · 다음 %s %s" % [
opened, total, next_zone.name, NumberFormat.short(GameState.next_zone_cost())
]
func _on_offline_earned(amount: float, seconds: int) -> void:
show_toast("%s 동안 거품 %s를 모았어요" % [
NumberFormat.duration(seconds), NumberFormat.short(amount)
], 4.5)
func show_toast(text: String, duration := 2.0) -> void:
toast.text = text
if _toast_tween and _toast_tween.is_valid():
_toast_tween.kill()
_toast_tween = create_tween()
_toast_tween.tween_property(toast, "modulate:a", 1.0, 0.2)
_toast_tween.tween_interval(duration)
_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
## 휴지통의 사각형. HUD와 바다가 같은 좌표계(Main의 전체 영역)를 쓰므로 그대로 넘길 수 있다.
func trash_area() -> Rect2:
return Rect2(trash.position, trash.size)
+1
View File
@@ -0,0 +1 @@
uid://clxf1syisntmq
+121
View File
@@ -0,0 +1,121 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scenes/ui/hud.gd" id="1_hud"]
[node name="HUD" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_hud")
[node name="ControlBar" type="PanelContainer" parent="."]
layout_mode = 1
anchors_preset = 0
offset_left = 12.0
offset_top = 10.0
offset_right = 12.0
offset_bottom = 10.0
mouse_filter = 2
[node name="HBox" type="HBoxContainer" parent="ControlBar"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 14
[node name="BubblesLabel" type="Label" parent="ControlBar/HBox"]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
text = "0"
vertical_alignment = 1
[node name="Sep1" type="VSeparator" parent="ControlBar/HBox"]
layout_mode = 2
mouse_filter = 2
[node name="BpsLabel" type="Label" parent="ControlBar/HBox"]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
text = "초당 0"
vertical_alignment = 1
[node name="BeautyLabel" type="Label" parent="ControlBar/HBox"]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
text = "아름다움 0"
vertical_alignment = 1
[node name="Sep2" type="VSeparator" parent="ControlBar/HBox"]
layout_mode = 2
mouse_filter = 2
[node name="ZoneLabel" type="Label" parent="ControlBar/HBox"]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
text = "구역 1/5"
vertical_alignment = 1
[node name="Sep3" type="VSeparator" parent="ControlBar/HBox"]
layout_mode = 2
mouse_filter = 2
[node name="ShopButton" type="Button" parent="ControlBar/HBox"]
unique_name_in_owner = true
custom_minimum_size = Vector2(78, 32)
layout_mode = 2
size_flags_vertical = 4
text = "상점"
[node name="MenuButton" type="Button" parent="ControlBar/HBox"]
unique_name_in_owner = true
custom_minimum_size = Vector2(40, 32)
layout_mode = 2
size_flags_vertical = 4
text = "···"
[node name="Trash" type="PanelContainer" parent="."]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 7
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -110.0
offset_top = -50.0
offset_right = 110.0
offset_bottom = -12.0
grow_horizontal = 2
grow_vertical = 0
mouse_filter = 2
[node name="TrashLabel" type="Label" parent="Trash"]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
text = "여기에 놓아 치우기 (절반 돌려받음)"
horizontal_alignment = 1
vertical_alignment = 1
[node name="Toast" type="Label" parent="."]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 5
anchor_left = 0.5
anchor_right = 0.5
offset_left = -240.0
offset_top = 16.0
offset_right = 240.0
offset_bottom = 60.0
grow_horizontal = 2
mouse_filter = 2
horizontal_alignment = 1
vertical_alignment = 1
autowrap_mode = 3
+241
View File
@@ -0,0 +1,241 @@
extends PanelContainer
class_name ShopPanel
## 상점. 생물 / 장식 / 업그레이드 세 탭.
##
## 생물과 장식은 여기서 바로 사지지 않는다. 고르면 배치 모드로 들어가고,
## 실제 구매는 바다에서 칸을 클릭할 때 일어난다. 업그레이드만 즉시 구매다.
## 목록은 Catalog에서 바로 만들어지므로, 아이템을 추가할 때 이 파일은 건드릴 필요가 없다.
signal closed
## 고른 아이템. kind가 "upgrade"면 즉시 구매, 아니면 배치 모드로 들어간다.
signal item_selected(kind: String, id: String)
const TABS := [
{"kind": "producer", "label": "생물"},
{"kind": "ornament", "label": "장식"},
{"kind": "upgrade", "label": "업그레이드"},
]
@onready var tab_bar: HBoxContainer = %TabBar
@onready var space_label: Label = %SpaceLabel
@onready var close_button: Button = %CloseButton
@onready var list: VBoxContainer = %List
var _tab := 0
var _tab_buttons: Array[Button] = []
var _rows := {} ## id -> 행 정보 Dictionary
var _built_ids: Array[String] = []
var _selected_id := ""
func _ready() -> void:
add_theme_stylebox_override("panel", UIStyle.panel(Color(0.02, 0.08, 0.15, 0.94), 16))
UIStyle.style_button(close_button)
UIStyle.label(space_label, 11, UIStyle.INK_DIM)
close_button.pressed.connect(func(): closed.emit())
for i in TABS.size():
var b := Button.new()
b.text = TABS[i].label
b.size_flags_horizontal = Control.SIZE_EXPAND_FILL
b.custom_minimum_size = Vector2(0, 28)
UIStyle.style_button(b, 8)
b.pressed.connect(_select_tab.bind(i))
tab_bar.add_child(b)
_tab_buttons.append(b)
GameState.state_changed.connect(_refresh_list)
GameState.reloaded.connect(_rebuild)
_select_tab(0)
func _process(_delta: float) -> void:
if visible:
_update_rows()
## 배치 모드가 켜지고 꺼질 때 main이 알려준다. 고른 행을 강조해 둔다.
func set_selected(active: bool, _kind: String, id: String) -> void:
_selected_id = id if active else ""
_update_rows()
func _select_tab(index: int) -> void:
_tab = index
for i in _tab_buttons.size():
_tab_buttons[i].modulate = Color.WHITE if i == index else Color(1, 1, 1, 0.55)
_rebuild()
func _current_items() -> Array:
return Catalog.items_of(TABS[_tab].kind)
func _visible_ids() -> Array[String]:
var kind: String = TABS[_tab].kind
var 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):
continue
ids.append(String(d.id))
return ids
## 목록에 보여야 할 항목이 달라졌을 때만 다시 만든다.
func _refresh_list() -> void:
if _visible_ids() != _built_ids:
_rebuild()
func _rebuild() -> void:
for child in list.get_children():
child.queue_free()
_rows.clear()
_built_ids = _visible_ids()
var kind: String = TABS[_tab].kind
for d in _current_items():
if String(d.id) in _built_ids:
list.add_child(_make_row(kind, d))
if _built_ids.is_empty():
var empty := Label.new()
empty.text = "아직 살 수 있는 게 없어요.\n거품을 조금 더 모아보세요."
empty.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
empty.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
empty.custom_minimum_size = Vector2(0, 70)
UIStyle.label(empty, 12, UIStyle.INK_DIM)
list.add_child(empty)
_update_rows()
func _make_row(kind: String, d: Dictionary) -> Button:
var row := Button.new()
row.custom_minimum_size = Vector2(0, 48)
UIStyle.style_button(row, 10)
row.pressed.connect(func(): item_selected.emit(kind, String(d.id)))
# Button은 컨테이너가 아니라서 내용은 전체 크기에 앵커로 붙인다.
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_theme_constant_override("margin_left", 10)
margin.add_theme_constant_override("margin_right", 10)
margin.add_theme_constant_override("margin_top", 5)
margin.add_theme_constant_override("margin_bottom", 5)
row.add_child(margin)
var hbox := HBoxContainer.new()
hbox.mouse_filter = Control.MOUSE_FILTER_IGNORE
hbox.add_theme_constant_override("separation", 10)
margin.add_child(hbox)
var swatch := ColorRect.new()
swatch.color = d.get("color", UIStyle.ACCENT)
swatch.custom_minimum_size = Vector2(5, 0)
swatch.mouse_filter = Control.MOUSE_FILTER_IGNORE
hbox.add_child(swatch)
var left := VBoxContainer.new()
left.size_flags_horizontal = Control.SIZE_EXPAND_FILL
left.alignment = BoxContainer.ALIGNMENT_CENTER
left.mouse_filter = Control.MOUSE_FILTER_IGNORE
left.add_theme_constant_override("separation", 1)
hbox.add_child(left)
var name_label := Label.new()
name_label.text = _name_text(kind, d)
name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
UIStyle.label(name_label, 13)
left.add_child(name_label)
var effect_label := Label.new()
effect_label.text = _effect_text(kind, d)
effect_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
UIStyle.label(effect_label, 10, UIStyle.INK_DIM)
left.add_child(effect_label)
var right := VBoxContainer.new()
right.mouse_filter = Control.MOUSE_FILTER_IGNORE
right.alignment = BoxContainer.ALIGNMENT_CENTER
right.add_theme_constant_override("separation", 1)
hbox.add_child(right)
var cost_label := Label.new()
cost_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
cost_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
UIStyle.label(cost_label, 13, UIStyle.GOLD)
right.add_child(cost_label)
var owned_label := Label.new()
owned_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
owned_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
UIStyle.label(owned_label, 10, UIStyle.INK_DIM)
right.add_child(owned_label)
_rows[String(d.id)] = {
"kind": kind, "button": row, "cost": cost_label, "owned": owned_label,
}
return row
## 여러 칸을 차지하는 것은 크기를 이름 옆에 적어준다. 자리 계획에 필요한 정보다.
func _name_text(kind: String, d: Dictionary) -> String:
var s := Catalog.size_of(kind, String(d.id))
if s == Vector2i.ONE:
return String(d.name)
return "%s (%d×%d칸)" % [d.name, s.x, s.y]
func _effect_text(kind: String, d: Dictionary) -> String:
match kind:
"producer":
return "%s · 초당 +%s · 아름다움 +%s" % [
Catalog.zone(int(d.get("zone", 0))).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))
]
"upgrade":
return String(d.desc)
return ""
func _update_rows() -> void:
for id in _rows:
var r: Dictionary = _rows[id]
var kind: String = r.kind
var cost := GameState.cost_of(kind, id)
var affordable := GameState.bubbles >= cost
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.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 = ""
_update_space_label()
## 구역별로 빈 칸이 얼마나 남았는지. 칸이 곧 한계이므로 늘 보이게 둔다.
func _update_space_label() -> void:
if TABS[_tab].kind == "upgrade":
space_label.text = "칸을 차지하지 않아요"
return
var parts := PackedStringArray()
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)
+1
View File
@@ -0,0 +1 @@
uid://bn8cwiiwdmbsg
+54
View File
@@ -0,0 +1,54 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scenes/ui/shop_panel.gd" id="1_shop"]
[node name="ShopPanel" type="PanelContainer"]
anchors_preset = 11
anchor_left = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -560.0
offset_top = 10.0
offset_right = -12.0
offset_bottom = -10.0
grow_horizontal = 0
grow_vertical = 2
script = ExtResource("1_shop")
[node name="VBox" type="VBoxContainer" parent="."]
layout_mode = 2
theme_override_constants/separation = 5
[node name="Header" type="HBoxContainer" parent="VBox"]
layout_mode = 2
theme_override_constants/separation = 6
[node name="TabBar" type="HBoxContainer" parent="VBox/Header"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 5
[node name="CloseButton" type="Button" parent="VBox/Header"]
unique_name_in_owner = true
custom_minimum_size = Vector2(34, 28)
layout_mode = 2
size_flags_vertical = 4
text = "✕"
[node name="SpaceLabel" type="Label" parent="VBox"]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
text = "남은 칸"
[node name="Scroll" type="ScrollContainer" parent="VBox"]
layout_mode = 2
size_flags_vertical = 3
horizontal_scroll_mode = 0
[node name="List" type="VBoxContainer" parent="VBox/Scroll"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 5