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

화면 아래 가장자리에 가로로 깔리는 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
+284
View File
@@ -0,0 +1,284 @@
extends Control
## 바탕화면 스트립의 껍데기.
## 창을 화면 가장자리에 폭 전체로 도킹시키고, 게임 화면(수조·HUD·상점)을 연결한다.
enum MenuId {
ALWAYS_ON_TOP = 0,
CLICK_THROUGH = 1,
SAVE_NOW = 2,
RESET = 3,
QUIT = 4,
}
## 높이 프리셋. 메뉴에서 고른다.
const HEIGHTS := [
{"id": 10, "label": "높이 · 낮게", "px": 160},
{"id": 11, "label": "높이 · 보통", "px": 220},
{"id": 12, "label": "높이 · 높게", "px": 300},
]
const EDGE_BOTTOM := 20
const EDGE_TOP := 21
const SCREEN_ID_BASE := 100
## 클릭 통과 중에도 이 크기의 모서리는 계속 클릭을 받는다.
## 이게 없으면 클릭 통과를 켠 순간 위젯으로 돌아올 방법이 없어진다.
const ESCAPE_HATCH := Vector2(84, 56)
@onready var aquarium: Aquarium = $Aquarium
@onready var hud: HUDLayer = $HUD
@onready var shop: ShopPanel = $ShopPanel
@onready var menu: PopupMenu = $ContextMenu
var _always_on_top := true
var _click_through := false
var _edge_bottom := true
var _strip_height := 220
var _screen := 0
## 화면 배율(125%면 1.25). 창 크기는 물리 픽셀, 게임 좌표는 이 값으로 나눈 논리 픽셀.
var _ui_scale := 1.0
func _ready() -> void:
var state: Dictionary = SaveManager.window_state
_always_on_top = bool(state.get("always_on_top", true))
_click_through = bool(state.get("click_through", false))
_edge_bottom = bool(state.get("edge_bottom", true))
_strip_height = int(state.get("height", 220))
_screen = int(state.get("screen", DisplayServer.window_get_current_screen()))
var win := get_window()
win.transparent = true
win.borderless = true
get_viewport().transparent_bg = true
win.always_on_top = _always_on_top
_apply_dock()
shop.hide()
shop.closed.connect(_close_shop)
shop.item_selected.connect(_on_shop_item_selected)
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.context_menu_requested.connect(_open_menu)
aquarium.placement_changed.connect(_on_placement_changed)
aquarium.drag_changed.connect(_on_drag_changed)
_build_menu()
get_window().close_requested.connect(_quit)
# --- 도킹 ---
## 창을 화면 폭 전체로 늘려 위 또는 아래 가장자리에 붙인다.
## 작업표시줄을 덮지 않도록 usable_rect를 기준으로 삼는다.
func _apply_dock() -> void:
_screen = clampi(_screen, 0, DisplayServer.get_screen_count() - 1)
var usable := DisplayServer.screen_get_usable_rect(_screen)
# 고배율 화면에서 글씨가 깨알같이 작아지지 않도록 배율만큼 창을 키우고
# 게임 좌표계는 그대로 둔다. Windows에서 screen_get_scale()은 항상 1이라 DPI로 구한다.
_ui_scale = clampf(DisplayServer.screen_get_dpi(_screen) / 96.0, 1.0, 3.0)
var h := int(round(clampi(_strip_height, 120, 600) * _ui_scale))
h = mini(h, maxi(usable.size.y - 60, 120))
var win := get_window()
win.content_scale_factor = _ui_scale
win.size = Vector2i(usable.size.x, h)
win.position = Vector2i(
usable.position.x,
usable.end.y - h if _edge_bottom else usable.position.y
)
_apply_passthrough()
_remember_window()
func _remember_window() -> void:
SaveManager.window_state["always_on_top"] = _always_on_top
SaveManager.window_state["click_through"] = _click_through
SaveManager.window_state["edge_bottom"] = _edge_bottom
SaveManager.window_state["height"] = _strip_height
SaveManager.window_state["screen"] = _screen
## 클릭 통과를 켜면 안쪽 모서리 하나만 입력을 받는 영역으로 남긴다.
## 빈 배열을 넘기면 통과가 꺼지고 창 전체가 다시 입력을 받는다.
func _apply_passthrough() -> void:
if not _click_through:
DisplayServer.window_set_mouse_passthrough(PackedVector2Array())
return
# 통과 영역은 창의 물리 픽셀 좌표로 준다.
var s := Vector2(get_window().size)
var hatch := ESCAPE_HATCH * _ui_scale
# 화면 안쪽을 향한 모서리에 둔다. 아래 도킹이면 오른쪽 위.
var y0 := 0.0 if _edge_bottom else s.y - hatch.y
var y1 := hatch.y if _edge_bottom else s.y
DisplayServer.window_set_mouse_passthrough(PackedVector2Array([
Vector2(s.x - hatch.x, y0),
Vector2(s.x, y0),
Vector2(s.x, y1),
Vector2(s.x - hatch.x, y1),
]))
# --- 게임 입력 ---
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel") and aquarium.is_placing():
aquarium.cancel_placement()
get_viewport().set_input_as_handled()
func _on_water_clicked(pos: Vector2) -> void:
var gained := GameState.click()
aquarium.pop_at(pos)
hud.float_number(pos, gained)
func _toggle_shop() -> void:
if shop.visible:
_close_shop()
else:
aquarium.cancel_placement()
shop.show()
func _close_shop() -> void:
shop.hide()
aquarium.cancel_placement()
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)))
return
aquarium.begin_placement(kind, id)
## 배치 중에는 상점을 잠시 치운다. 상점이 바다 오른쪽을 가리고 있어서
## 열어 둔 채로는 뒤쪽 구역에 놓을 수가 없다.
func _on_placement_changed(active: bool, kind: String, id: String) -> void:
shop.set_selected(active, kind, id)
shop.visible = not active
if active:
hud.show_toast("놓을 칸을 클릭하세요 · 우클릭이나 Esc로 취소", 3.0)
func _on_drag_changed(dragging: bool) -> void:
hud.set_trash_visible(dragging)
aquarium.trash_rect = hud.trash_area() if dragging else Rect2()
# --- 메뉴 ---
func _build_menu() -> void:
menu.clear()
menu.add_check_item("항상 위에 표시", MenuId.ALWAYS_ON_TOP)
menu.add_check_item("클릭 통과 (모서리로 복귀)", MenuId.CLICK_THROUGH)
menu.add_separator()
for h in HEIGHTS:
menu.add_radio_check_item(h.label, h.id)
menu.add_separator()
menu.add_radio_check_item("화면 아래에 붙이기", EDGE_BOTTOM)
menu.add_radio_check_item("화면 위에 붙이기", EDGE_TOP)
if DisplayServer.get_screen_count() > 1:
menu.add_separator()
for i in DisplayServer.get_screen_count():
menu.add_radio_check_item("화면 %d" % (i + 1), SCREEN_ID_BASE + i)
menu.add_separator()
menu.add_item("지금 저장", MenuId.SAVE_NOW)
menu.add_item("처음부터 다시", MenuId.RESET)
menu.add_separator()
menu.add_item("종료", MenuId.QUIT)
menu.id_pressed.connect(_on_menu_id)
_sync_menu()
func _sync_menu() -> void:
_check(MenuId.ALWAYS_ON_TOP, _always_on_top)
_check(MenuId.CLICK_THROUGH, _click_through)
for h in HEIGHTS:
_check(h.id, _strip_height == int(h.px))
_check(EDGE_BOTTOM, _edge_bottom)
_check(EDGE_TOP, not _edge_bottom)
if DisplayServer.get_screen_count() > 1:
for i in DisplayServer.get_screen_count():
_check(SCREEN_ID_BASE + i, _screen == i)
func _check(id: int, on: bool) -> void:
var index := menu.get_item_index(id)
if index >= 0:
menu.set_item_checked(index, on)
func _open_menu() -> void:
_sync_menu()
menu.position = DisplayServer.mouse_get_position()
menu.popup()
func _on_menu_id(id: int) -> void:
if id >= SCREEN_ID_BASE:
_screen = id - SCREEN_ID_BASE
_apply_dock()
return
for h in HEIGHTS:
if id == int(h.id):
_strip_height = int(h.px)
_apply_dock()
return
match id:
EDGE_BOTTOM, EDGE_TOP:
_edge_bottom = id == EDGE_BOTTOM
_apply_dock()
MenuId.ALWAYS_ON_TOP:
_always_on_top = not _always_on_top
get_window().always_on_top = _always_on_top
_remember_window()
MenuId.CLICK_THROUGH:
_click_through = not _click_through
_apply_passthrough()
_remember_window()
if _click_through:
var corner := "오른쪽 위" if _edge_bottom else "오른쪽 아래"
hud.show_toast(
"클릭이 바탕화면으로 지나갑니다. %s 모서리를 눌러 돌아오세요." % corner, 5.0)
MenuId.SAVE_NOW:
_remember_window()
SaveManager.save_game()
hud.show_toast("저장했어요")
MenuId.RESET:
_confirm_reset()
MenuId.QUIT:
_quit()
func _confirm_reset() -> void:
var dialog := ConfirmationDialog.new()
dialog.title = "처음부터 다시"
dialog.dialog_text = "지금까지 모은 거품과 꾸민 수조가 모두 사라집니다.\n정말 진행할까요?"
dialog.ok_button_text = "초기화"
dialog.cancel_button_text = "취소"
add_child(dialog)
dialog.confirmed.connect(func():
SaveManager.wipe()
aquarium.rebuild()
hud.show_toast("수조를 비웠어요")
)
dialog.close_requested.connect(dialog.queue_free)
dialog.confirmed.connect(dialog.queue_free)
dialog.popup_centered()
func _quit() -> void:
_remember_window()
SaveManager.save_game()
get_tree().quit()