711cd453ff
메뉴 > 시간 하위 메뉴에 세 갈래를 둔다. - 실제 시각: 바깥 시계 그대로 (기본) - 빠르게: 하루를 5분으로 압축해 돌린다 - 한 시각에 멈추기: 새벽/아침/낮/저녁/밤 프리셋의 이름과 시각을 DayCycle.FIXED_PRESETS 한곳에 같이 두어, 메뉴 항목과 실제로 적용되는 시각이 어긋날 수 없게 했다. 고른 값은 window_state의 time_mode/time_hour에 저장한다. 모드를 바꿀 때 지금 보이던 시각을 먼저 잡아 둔다. mode를 먼저 바꾼 뒤 hour()를 부르면 이미 새 모드의 시계를 읽어 0시로 튀었다 -- 낮에 "빠르게"를 켜면 한밤중으로 떨어지는 버그였다. 명령줄 --hour=21은 그대로 살아 있고 FIXED 모드의 지름길이 되었다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
422 lines
14 KiB
GDScript
422 lines
14 KiB
GDScript
extends Control
|
|
## 바탕화면 스트립의 껍데기.
|
|
## 창을 화면 가장자리에 폭 전체로 도킹시키고, 게임 화면(수조·HUD·상점)을 연결한다.
|
|
|
|
enum MenuId {
|
|
ALWAYS_ON_TOP = 0,
|
|
CLICK_THROUGH = 1,
|
|
SAVE_NOW = 2,
|
|
RESET = 3,
|
|
QUIT = 4,
|
|
FULLSCREEN = 5,
|
|
}
|
|
|
|
## 높이 프리셋. 메뉴에서 고른다.
|
|
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
|
|
## 시간 하위 메뉴. 200 실제, 201 빠르게, 210+i 고정 프리셋.
|
|
const TIME_REAL := 200
|
|
const TIME_FAST := 201
|
|
const TIME_FIXED_BASE := 210
|
|
|
|
## 클릭 통과 중에도 이 크기의 모서리는 계속 클릭을 받는다.
|
|
## 이게 없으면 클릭 통과를 켠 순간 위젯으로 돌아올 방법이 없어진다.
|
|
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
|
|
@onready var time_menu: PopupMenu = $ContextMenu/TimeMenu
|
|
|
|
var _always_on_top := true
|
|
var _click_through := false
|
|
var _edge_bottom := true
|
|
var _strip_height := 220
|
|
var _screen := 0
|
|
## 감상 모드. 화면 전체를 덮고 바다를 크게 보여준다.
|
|
var _fullscreen := false
|
|
## 화면 배율(125%면 1.25). 창 크기는 물리 픽셀, 게임 좌표는 이 값으로 나눈 논리 픽셀.
|
|
var _ui_scale := 1.0
|
|
|
|
|
|
func _ready() -> void:
|
|
var state: Dictionary = SaveManager.window_state
|
|
# state는 SaveManager의 딕셔너리를 그대로 가리킨다. 아래 _apply_dock()이
|
|
# _remember_window()로 같은 딕셔너리를 덮어쓰므로, 나중에 쓸 값은 미리 빼둔다.
|
|
var saved_layer := int(state.get("layer", 0))
|
|
var saved_time_mode := int(state.get("time_mode", DayCycle.Mode.REAL))
|
|
var saved_time_hour := float(state.get("time_hour", 13.0))
|
|
_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()
|
|
aquarium.current_zone = saved_layer
|
|
DayCycle.fixed_hour = saved_time_hour
|
|
DayCycle.mode = saved_time_mode as DayCycle.Mode
|
|
|
|
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)
|
|
|
|
# 층 선택기와 바다를 서로 물린다. 어느 쪽으로 층을 바꿔도 표시가 맞는다.
|
|
hud.layer_bar.layer_picked.connect(func(i: int): aquarium.current_zone = i)
|
|
aquarium.layer_changed.connect(hud.layer_bar.set_current)
|
|
hud.layer_bar.set_current(aquarium.current_zone)
|
|
|
|
_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()
|
|
# 창을 화면과 픽셀까지 똑같이 맞추면 Windows가 독점 전체화면으로 승격시켜 버리고,
|
|
# 그 뒤로는 size를 넣어도 먹지 않아 감상 모드에서 빠져나올 수 없다.
|
|
# 그래서 감상 모드도 작업표시줄을 뺀 영역까지만 쓴다.
|
|
if win.mode != Window.MODE_WINDOWED:
|
|
win.mode = Window.MODE_WINDOWED
|
|
win.content_scale_factor = _ui_scale
|
|
if _fullscreen:
|
|
win.size = usable.size
|
|
win.position = usable.position
|
|
else:
|
|
win.size = Vector2i(usable.size.x, h)
|
|
win.position = Vector2i(
|
|
usable.position.x,
|
|
usable.end.y - h if _edge_bottom else usable.position.y
|
|
)
|
|
win.always_on_top = _always_on_top or _fullscreen
|
|
aquarium.showcase = _fullscreen
|
|
|
|
_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["layer"] = aquarium.current_zone
|
|
SaveManager.window_state["time_mode"] = int(DayCycle.mode)
|
|
SaveManager.window_state["time_hour"] = DayCycle.fixed_hour
|
|
SaveManager.window_state["edge_bottom"] = _edge_bottom
|
|
SaveManager.window_state["height"] = _strip_height
|
|
SaveManager.window_state["screen"] = _screen
|
|
|
|
|
|
## 클릭 통과를 켜면 안쪽 모서리 하나만 입력을 받는 영역으로 남긴다.
|
|
## 빈 배열을 넘기면 통과가 꺼지고 창 전체가 다시 입력을 받는다.
|
|
func _apply_passthrough() -> void:
|
|
# 감상 중에는 통과시키지 않는다. 화면을 다 덮은 채 클릭이 새 나가면 곤란하다.
|
|
if _fullscreen or 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 set_fullscreen(on: bool) -> void:
|
|
if _fullscreen == on:
|
|
return
|
|
_fullscreen = on
|
|
aquarium.cancel_build()
|
|
if on:
|
|
shop.hide()
|
|
_apply_dock()
|
|
hud.show_toast("감상 모드 · Esc나 F11로 돌아갑니다" if on else "위젯으로 돌아왔어요", 3.0)
|
|
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
if event.is_action_pressed("ui_cancel"):
|
|
if aquarium.is_building():
|
|
aquarium.cancel_build()
|
|
elif _fullscreen:
|
|
set_fullscreen(false)
|
|
else:
|
|
return
|
|
get_viewport().set_input_as_handled()
|
|
return
|
|
|
|
if event is InputEventKey and event.pressed and not event.echo:
|
|
match event.keycode:
|
|
KEY_F11:
|
|
set_fullscreen(not _fullscreen)
|
|
KEY_UP, KEY_PAGEUP:
|
|
aquarium.current_zone -= 1
|
|
KEY_DOWN, KEY_PAGEDOWN:
|
|
aquarium.current_zone += 1
|
|
_:
|
|
return
|
|
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_check_item("전체화면으로 감상 (F11)", MenuId.FULLSCREEN)
|
|
menu.add_separator()
|
|
_build_time_menu()
|
|
menu.add_submenu_node_item("시간", time_menu)
|
|
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 _build_time_menu() -> void:
|
|
time_menu.clear()
|
|
time_menu.add_radio_check_item("실제 시각", TIME_REAL)
|
|
time_menu.add_radio_check_item(
|
|
"빠르게 (하루 %d분)" % int(DayCycle.FAST_DAY_MINUTES), TIME_FAST)
|
|
time_menu.add_separator("한 시각에 멈추기")
|
|
for i in DayCycle.FIXED_PRESETS.size():
|
|
var p: Dictionary = DayCycle.FIXED_PRESETS[i]
|
|
time_menu.add_radio_check_item(String(p.name), TIME_FIXED_BASE + i)
|
|
time_menu.id_pressed.connect(_on_time_menu_id)
|
|
|
|
|
|
func _sync_time_menu() -> void:
|
|
_check_in(time_menu, TIME_REAL, DayCycle.mode == DayCycle.Mode.REAL)
|
|
_check_in(time_menu, TIME_FAST, DayCycle.mode == DayCycle.Mode.FAST)
|
|
for i in DayCycle.FIXED_PRESETS.size():
|
|
var fixed := DayCycle.mode == DayCycle.Mode.FIXED
|
|
var same := is_equal_approx(
|
|
DayCycle.fixed_hour, float(DayCycle.FIXED_PRESETS[i].hour))
|
|
_check_in(time_menu, TIME_FIXED_BASE + i, fixed and same)
|
|
|
|
|
|
func _on_time_menu_id(id: int) -> void:
|
|
if id == TIME_REAL:
|
|
DayCycle.mode = DayCycle.Mode.REAL
|
|
hud.show_toast("시간을 실제 시각에 맞췄어요 · 지금 %s" % DayCycle.clock_text())
|
|
elif id == TIME_FAST:
|
|
DayCycle.mode = DayCycle.Mode.FAST
|
|
hud.show_toast("하루가 %d분에 한 바퀴 돕니다" % int(DayCycle.FAST_DAY_MINUTES))
|
|
elif id >= TIME_FIXED_BASE:
|
|
var p: Dictionary = DayCycle.FIXED_PRESETS[id - TIME_FIXED_BASE]
|
|
DayCycle.fixed_hour = float(p.hour)
|
|
DayCycle.mode = DayCycle.Mode.FIXED
|
|
hud.show_toast("%s로 멈춰 뒀어요 · %s" % [p.name, DayCycle.clock_text()])
|
|
_sync_time_menu()
|
|
|
|
|
|
func _check_in(target: PopupMenu, id: int, on: bool) -> void:
|
|
var index := target.get_item_index(id)
|
|
if index >= 0:
|
|
target.set_item_checked(index, on)
|
|
|
|
|
|
func _sync_menu() -> void:
|
|
_check(MenuId.ALWAYS_ON_TOP, _always_on_top)
|
|
_check(MenuId.CLICK_THROUGH, _click_through)
|
|
_check(MenuId.FULLSCREEN, _fullscreen)
|
|
for h in HEIGHTS:
|
|
_check(h.id, _strip_height == int(h.px))
|
|
_sync_time_menu()
|
|
_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.FULLSCREEN:
|
|
set_fullscreen(not _fullscreen)
|
|
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()
|