89069b06f0
거품은 이제 클릭이 아니라 바다에 있는 생물이 시간에 따라 번다. 화면 가장자리에
깔아 두는 위젯인데 클릭이 주된 수입원이면 연타가 최적 플레이가 되어 형태와
어긋난다. 클릭 수익과 클릭 업그레이드를 걷어내고 업그레이드는 전부 생산 배율로
바꿨다. 물을 만지면 거품이 퍼지지만 얻는 것은 없다.
격자 칸을 차지하는 것은 집뿐이고, 생물은 칸이 아니라 집에 산다(한 채당 3마리).
칸 → 집 → 생물 → 거품으로 한계가 두 겹 걸린다. 예전에는 생물과 장식이 같은
자원(칸)을 놓고 경쟁해서 최적 플레이가 늘 "장식을 사지 말 것"이었는데, 집과
일꾼으로 나누니 그 경쟁이 의존으로 바뀐다. 집은 같은 종류도 여러 채 지을 수 있고
거품을 벌지 않는다 — 정원과 생산을 한 물건이 주면 곱연산이 두 번 걸린다.
어느 생물이 어느 집에 사는지는 저장하지 않는다. 집은 정원만 주므로 어디 살든
결과가 같고, 저장하면 재배정과 인덱스 관리만 붙는다. 그릴 때 구역마다 배정한다.
그래서 저장 형식에서 생물은 위치 없이 {id: 마리 수}다. 옛 저장본은 그때의
장식을 집으로, 생물을 마릿수로 옮겨오고 정원이 모자라면 집을 세워 메운다.
수입이 생물에서만 나오므로 막다른 길을 두 곳에서 막았다. 마지막 생물은 내보낼 수
없고, 살던 생물이 갈 곳이 없어지는 집은 헐 수 없다.
집과 생물의 그림 형태를 갈랐다. 집 10채 중 8채가 생물과 같은 rock이어서 항아리와
거품돌이 색만 다른 같은 덩어리로 보였다. 집은 vessel/wreck/pillar만, 생물은
plant/rock/fish/glow만 쓴다. 헤엄치는 생물은 집을 도는 타원 궤도에 올라가고,
원점이 궤도를 따라 움직이므로 Y 정렬이 앞뒤를 알아서 바꿔준다 — 뒤로 돌면 집에
가렸다가 앞으로 나온다. 얕은 바다 생물이 거품돌과 해초뿐이라 이 움직임을 볼 수
없어서 구피를 넣었다.
실행해 보고 잡은 버그 셋:
- 저장본이 없으면 load_game이 그냥 반환해 빈 바다로 시작했다. 집도 생물도 없어
아무 일도 일어나지 않는다. 이제 새 게임을 시작한다.
- 창 높이·도킹·모니터 설정이 복원되지 않고 기본값으로 덮어써지고 있었다.
window_state에 미리 있던 키만 복사하던 것을 저장본에 있는 것을 전부 싣도록 고쳤다.
- 정보 바가 격자 첫 줄을 덮어 왼쪽 위에 놓인 것이 보이지 않았다. GRID_TOP 상수를
버리고 HUD가 잰 실제 바 높이를 받아 쓴다.
- main.gd가 초기화할 때 존재하지 않는 aquarium.rebuild()를 부르고 있었다.
스모크 테스트 159개.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uze4mF1NAGfFRio77cMFqg
303 lines
9.5 KiB
GDScript
303 lines
9.5 KiB
GDScript
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()
|