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가 재어서 알려준다. hud.reserved_top_changed.connect(func(px: float): aquarium.grid_top = px) aquarium.grid_top = hud.reserved_top() hud.shop_pressed.connect(_toggle_shop) hud.menu_pressed.connect(_open_menu) aquarium.message.connect(hud.show_toast) aquarium.clicked.connect(_on_water_touched) aquarium.context_menu_requested.connect(_open_menu) aquarium.build_mode_changed.connect(_on_build_mode_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_building(): aquarium.cancel_build() get_viewport().set_input_as_handled() ## 물을 만지면 거품이 퍼진다. 거품은 생물이 버는 것이라 여기서 얻는 것은 없다. func _on_water_touched(pos: Vector2) -> void: aquarium.pop_at(pos) func _toggle_shop() -> void: if shop.visible: _close_shop() else: aquarium.cancel_build() shop.show() func _close_shop() -> void: shop.hide() aquarium.cancel_build() ## 상점에서 무언가를 골랐을 때. ## 집만 바다에 자리를 잡아야 하므로 배치 모드로 들어가고, ## 생물과 업그레이드는 자리가 필요 없어서 그 자리에서 바로 사진다. func _on_shop_item_selected(kind: String, id: String) -> void: match kind: "upgrade": if GameState.buy_upgrade(id): hud.show_toast("%s 켰어요" % Korean.eul(String(Catalog.upgrade(id).name))) "producer": _take_fish(id) "house": aquarium.begin_build(id) func _take_fish(id: String) -> void: var fish_name := String(Catalog.producer(id).get("name", "")) if GameState.buy_fish(id): var z := Catalog.zone_of("producer", id) hud.show_toast("%s 들였어요 · %s 남은 자리 %d" % [ Korean.eul(fish_name), Catalog.zone(z).name, GameState.free_slots_in_zone(z)]) return var why := GameState.fish_blocker(id) hud.show_toast(why if not why.is_empty() else "지금은 들일 수 없어요", 3.0) ## 집을 짓는 동안에는 상점을 잠시 치운다. 상점이 바다 오른쪽을 가리고 있어서 ## 열어 둔 채로는 뒤쪽 구역에 지을 수가 없다. func _on_build_mode_changed(active: bool, id: String) -> void: shop.set_selected(active, 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() 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()