바탕화면 바닷속 방치형 위젯 첫 커밋
화면 아래 가장자리에 가로로 깔리는 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:
@@ -0,0 +1,525 @@
|
||||
extends Control
|
||||
class_name Aquarium
|
||||
## 화면 하단에 깔리는 탑다운 바다.
|
||||
## 가로를 구역 수만큼 나눠, 왼쪽(얕은 바다)에서 오른쪽(열수구)으로 갈수록 깊어진다.
|
||||
## 구역마다 격자가 있고, 산 것은 플레이어가 고른 칸에 놓인다.
|
||||
|
||||
## 알림을 띄워야 할 때 (해금 실패, 배치 실패, 환불 등). main이 받아 HUD로 넘긴다.
|
||||
signal message(text: String)
|
||||
## 빈 곳을 눌렀을 때. 거품 클릭 연출에 쓴다.
|
||||
signal clicked(local_position: Vector2)
|
||||
## 배치 모드가 켜지거나 꺼졌을 때. 상점이 선택 상태를 표시하는 데 쓴다.
|
||||
signal placement_changed(active: bool, kind: String, id: String)
|
||||
## 이미 놓인 것을 집었거나 놓았을 때. HUD가 휴지통을 보였다 감추는 데 쓴다.
|
||||
signal drag_changed(dragging: bool)
|
||||
## 물 위에서 우클릭. main이 메뉴를 연다.
|
||||
signal context_menu_requested
|
||||
|
||||
## 드래그한 것을 여기에 떨구면 치운다. main이 HUD의 휴지통 위치를 넣어준다.
|
||||
var trash_rect := Rect2()
|
||||
|
||||
@onready var ground: Seabed = $Ground
|
||||
@onready var items: Node2D = $Items
|
||||
@onready var water: Control = $Water
|
||||
@onready var bubbles: CPUParticles2D = $Bubbles
|
||||
@onready var ghost: GridGhost = $Ghost
|
||||
@onready var dividers: Control = $Dividers
|
||||
@onready var zone_labels: Control = $ZoneLabels
|
||||
@onready var locks: Control = $Locks
|
||||
|
||||
## 위쪽 이만큼은 정보 바가 앉는 자리라 격자에서 뺀다.
|
||||
## 여기를 비워두지 않으면 첫 줄 칸이 바에 가려 보이지도, 눌리지도 않는다.
|
||||
const GRID_TOP := 30.0
|
||||
const DRAG_THRESHOLD := 5.0
|
||||
## _press_index에 쓰는 표식. 이번 누름은 클릭이 아니라 드래그였다는 뜻.
|
||||
const PRESS_WAS_DRAG := -2
|
||||
|
||||
var _rng := RandomNumberGenerator.new()
|
||||
var _lock_reasons := {} ## 구역 인덱스 -> 사유 Label
|
||||
var _base_material: ShaderMaterial
|
||||
|
||||
var _placing_kind := ""
|
||||
var _placing_id := ""
|
||||
var _hover := Vector2i(-1, -1)
|
||||
var _press_position := Vector2.ZERO
|
||||
var _press_index := -1
|
||||
var _drag_index := -1
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_rng.randomize()
|
||||
_base_material = preload("res://assets/shaders/water_material.tres")
|
||||
items.y_sort_enabled = true
|
||||
ghost.hide()
|
||||
_setup_bubbles()
|
||||
|
||||
resized.connect(_on_layout_changed)
|
||||
GameState.zone_unlocked.connect(_on_zone_unlocked)
|
||||
GameState.reloaded.connect(_rebuild_items)
|
||||
GameState.placed.connect(_on_placed)
|
||||
|
||||
_on_layout_changed()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# 잠긴 구역의 안내 문구는 거품이 쌓이면서 계속 바뀐다.
|
||||
for index in _lock_reasons:
|
||||
if index == GameState.next_zone_index():
|
||||
_lock_reasons[index].text = GameState.next_zone_blocker()
|
||||
|
||||
|
||||
# --- 격자 좌표 ---
|
||||
|
||||
## 격자가 실제로 깔리는 영역. 위쪽 정보 바 자리는 뺀다.
|
||||
func grid_rect() -> Rect2:
|
||||
return Rect2(Vector2(0.0, GRID_TOP), Vector2(size.x, maxf(size.y - GRID_TOP, 1.0)))
|
||||
|
||||
|
||||
func cell_size() -> Vector2:
|
||||
var g := grid_rect()
|
||||
return Vector2(g.size.x / GameState.columns(), g.size.y / GameState.rows())
|
||||
|
||||
|
||||
func cell_at(pos: Vector2) -> Vector2i:
|
||||
var c := cell_size()
|
||||
return Vector2i(int(floor(pos.x / c.x)), int(floor((pos.y - GRID_TOP) / c.y)))
|
||||
|
||||
|
||||
func cell_rect(col: int, row: int, span := Vector2i.ONE) -> Rect2:
|
||||
var c := cell_size()
|
||||
return Rect2(
|
||||
Vector2(col * c.x, GRID_TOP + row * c.y),
|
||||
Vector2(c.x * span.x, c.y * span.y))
|
||||
|
||||
|
||||
func zone_rect(index: int) -> Rect2:
|
||||
var c := cell_size()
|
||||
return Rect2(
|
||||
Vector2(index * Catalog.ZONE_COLUMNS * c.x, 0.0),
|
||||
Vector2(Catalog.ZONE_COLUMNS * c.x, size.y))
|
||||
|
||||
|
||||
## 커서가 가리키는 칸을 기준으로 발자국을 가운데 맞춘 왼쪽 위 칸.
|
||||
func _anchor_for(kind: String, id: String, hover: Vector2i) -> Vector2i:
|
||||
var s := Catalog.size_of(kind, id)
|
||||
return hover - Vector2i((s.x - 1) / 2, (s.y - 1) / 2)
|
||||
|
||||
|
||||
# --- 화면 구성 ---
|
||||
|
||||
## 크기가 바뀌거나 구역이 열리면 전부 다시 만든다.
|
||||
## 스트립은 자주 바뀌지 않으므로 부분 갱신보다 통째로 다시 만드는 쪽이 단순하다.
|
||||
func _on_layout_changed() -> void:
|
||||
ground.rect_size = size
|
||||
ground.grid_top = GRID_TOP
|
||||
ground.queue_redraw()
|
||||
|
||||
_build_water()
|
||||
_build_dividers()
|
||||
_build_zone_labels()
|
||||
_build_locks()
|
||||
_rebuild_items()
|
||||
|
||||
bubbles.position = size * 0.5
|
||||
bubbles.emission_rect_extents = size * 0.5
|
||||
bubbles.amount = clampi(int(size.x / 40.0), 10, 60)
|
||||
|
||||
|
||||
func _build_water() -> void:
|
||||
for child in water.get_children():
|
||||
child.queue_free()
|
||||
var n := Catalog.zone_count()
|
||||
for i in n:
|
||||
var r := zone_rect(i)
|
||||
var rect := ColorRect.new()
|
||||
rect.position = r.position
|
||||
rect.size = r.size
|
||||
rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var mat: ShaderMaterial = _base_material.duplicate()
|
||||
# 경계 값을 이웃과 공유해서 이음매가 보이지 않게 한다.
|
||||
mat.set_shader_parameter("tint_left", Catalog.ramp_color("tint", float(i) / n))
|
||||
mat.set_shader_parameter("tint_right", Catalog.ramp_color("tint", float(i + 1) / n))
|
||||
mat.set_shader_parameter("murk_left", Catalog.ramp_float("murk", float(i) / n))
|
||||
mat.set_shader_parameter("murk_right", Catalog.ramp_float("murk", float(i + 1) / n))
|
||||
mat.set_shader_parameter("x_offset", r.position.x)
|
||||
mat.set_shader_parameter("rect_size", r.size)
|
||||
rect.material = mat
|
||||
water.add_child(rect)
|
||||
|
||||
|
||||
func _build_dividers() -> void:
|
||||
for child in dividers.get_children():
|
||||
child.queue_free()
|
||||
for i in range(1, Catalog.zone_count()):
|
||||
var line := ColorRect.new()
|
||||
line.color = Color(1, 1, 1, 0.10)
|
||||
line.position = Vector2(zone_rect(i).position.x - 1.0, 0.0)
|
||||
line.size = Vector2(2.0, size.y)
|
||||
line.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
dividers.add_child(line)
|
||||
|
||||
|
||||
func _build_zone_labels() -> void:
|
||||
for child in zone_labels.get_children():
|
||||
child.queue_free()
|
||||
for i in Catalog.zone_count():
|
||||
if not GameState.is_zone_unlocked(i):
|
||||
continue
|
||||
var r := zone_rect(i)
|
||||
var l := Label.new()
|
||||
l.text = Catalog.zone(i).name
|
||||
# 바닥 오른쪽 아래 구석에 새겨 넣는다.
|
||||
l.size = Vector2(r.size.x - 10.0, 16.0)
|
||||
l.position = Vector2(r.position.x, r.size.y - 19.0)
|
||||
l.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
l.add_theme_font_size_override("font_size", 10)
|
||||
# 바닥에 새긴 것처럼. 밝은 모래 위에서도 어두운 물속에서도 읽히려면
|
||||
# 흰색 반투명이 아니라 그 자리 모래색을 어둡게 쓴 편이 낫다.
|
||||
var center := (float(i) + 0.5) / Catalog.zone_count()
|
||||
l.add_theme_color_override("font_color",
|
||||
Catalog.ramp_color("sand", center).darkened(0.45))
|
||||
zone_labels.add_child(l)
|
||||
|
||||
|
||||
func _build_locks() -> void:
|
||||
for child in locks.get_children():
|
||||
child.queue_free()
|
||||
_lock_reasons.clear()
|
||||
for i in Catalog.zone_count():
|
||||
if not GameState.is_zone_unlocked(i):
|
||||
locks.add_child(_make_lock(i))
|
||||
|
||||
|
||||
func _make_lock(index: int) -> Button:
|
||||
var r := zone_rect(index)
|
||||
var z := Catalog.zone(index)
|
||||
var is_next := index == GameState.next_zone_index()
|
||||
|
||||
var veil := Button.new()
|
||||
veil.position = r.position
|
||||
veil.size = r.size
|
||||
veil.add_theme_stylebox_override("normal", UIStyle.veil(0.70))
|
||||
veil.add_theme_stylebox_override("hover", UIStyle.veil(0.60 if is_next else 0.70))
|
||||
veil.add_theme_stylebox_override("pressed", UIStyle.veil(0.54 if is_next else 0.70))
|
||||
veil.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
|
||||
veil.pressed.connect(_on_lock_pressed.bind(index))
|
||||
|
||||
var box := VBoxContainer.new()
|
||||
box.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
box.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
box.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
box.add_theme_constant_override("separation", 3)
|
||||
veil.add_child(box)
|
||||
|
||||
var name_label := Label.new()
|
||||
name_label.text = z.name if is_next else "???"
|
||||
name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
UIStyle.label(name_label, 15, Color(1, 1, 1, 0.75 if is_next else 0.35))
|
||||
box.add_child(name_label)
|
||||
|
||||
if not is_next:
|
||||
# 다음 차례가 아닌 구역은 무엇이 있는지도 알려주지 않는다.
|
||||
return veil
|
||||
|
||||
var cost_label := Label.new()
|
||||
cost_label.text = "해금 %s" % NumberFormat.short(float(z.unlock_cost))
|
||||
cost_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
cost_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
UIStyle.label(cost_label, 13, UIStyle.GOLD)
|
||||
box.add_child(cost_label)
|
||||
|
||||
var reason := Label.new()
|
||||
reason.text = GameState.next_zone_blocker()
|
||||
reason.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
reason.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
reason.custom_minimum_size = Vector2(r.size.x - 24.0, 0.0)
|
||||
reason.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
UIStyle.label(reason, 11, UIStyle.INK_DIM)
|
||||
box.add_child(reason)
|
||||
_lock_reasons[index] = reason
|
||||
|
||||
return veil
|
||||
|
||||
|
||||
func _on_lock_pressed(index: int) -> void:
|
||||
if index != GameState.next_zone_index():
|
||||
message.emit("%s부터 열어야 해요" % Catalog.zone(GameState.next_zone_index()).name)
|
||||
return
|
||||
var blocker := GameState.next_zone_blocker()
|
||||
if not blocker.is_empty():
|
||||
message.emit(blocker)
|
||||
return
|
||||
GameState.unlock_next_zone()
|
||||
|
||||
|
||||
func _on_zone_unlocked(index: int) -> void:
|
||||
message.emit("%s 열렸어요" % Korean.i(String(Catalog.zone(index).name)))
|
||||
_on_layout_changed()
|
||||
|
||||
|
||||
# --- 놓인 것들 ---
|
||||
|
||||
func _setup_bubbles() -> void:
|
||||
bubbles.emitting = true
|
||||
bubbles.lifetime = 3.5
|
||||
bubbles.emission_shape = CPUParticles2D.EMISSION_SHAPE_RECTANGLE
|
||||
bubbles.gravity = Vector2.ZERO
|
||||
bubbles.initial_velocity_min = 0.0
|
||||
bubbles.initial_velocity_max = 5.0
|
||||
# 위에서 내려다보므로 거품은 위로 흐르는 대신 제자리에서 부풀었다 사라진다.
|
||||
bubbles.scale_amount_min = 0.4
|
||||
bubbles.scale_amount_max = 1.6
|
||||
bubbles.color = Color(1, 1, 1, 0.20)
|
||||
|
||||
|
||||
func _rebuild_items() -> void:
|
||||
for child in items.get_children():
|
||||
child.queue_free()
|
||||
for i in GameState.placements.size():
|
||||
_make_item(i)
|
||||
|
||||
|
||||
func _make_item(index: int) -> TileItem:
|
||||
var p := GameState.placements[index]
|
||||
var d := Catalog.item(p.kind, p.id)
|
||||
if d.is_empty():
|
||||
return null
|
||||
var node := TileItem.new()
|
||||
items.add_child(node)
|
||||
node.setup(d, p.kind, index, cell_rect(p.col, p.row, Catalog.size_of(p.kind, p.id)),
|
||||
cell_size(), _rng)
|
||||
return node
|
||||
|
||||
|
||||
func _on_placed(index: int) -> void:
|
||||
var node := _make_item(index)
|
||||
if node:
|
||||
# 새로 놓은 것은 살짝 부풀며 등장한다.
|
||||
node.scale = Vector2.ZERO
|
||||
var t := create_tween()
|
||||
t.tween_property(node, "scale", Vector2.ONE, 0.28) \
|
||||
.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
|
||||
|
||||
|
||||
## 커서 아래에 있는 놓인 것. 그려진 모양으로 먼저 찾는다.
|
||||
## 키 큰 것은 제 칸보다 위쪽에 그려지므로, 보이는 자리를 눌러야 집히는 게 자연스럽다.
|
||||
## 앞줄(화면 아래쪽)에 있는 것이 이긴다.
|
||||
func _pick_at(pos: Vector2) -> int:
|
||||
var best := -1
|
||||
var best_y := -INF
|
||||
for child in items.get_children():
|
||||
var it := child as TileItem
|
||||
if it == null:
|
||||
continue
|
||||
var r := it.visual_rect()
|
||||
r.position += it.position
|
||||
if r.has_point(pos) and it.position.y > best_y:
|
||||
best_y = it.position.y
|
||||
best = it.placement_index
|
||||
if best >= 0:
|
||||
return best
|
||||
# 그림에 안 걸렸으면 발자국 칸으로 한 번 더 본다.
|
||||
var cell := cell_at(pos)
|
||||
return GameState.placement_at(cell.x, cell.y)
|
||||
|
||||
|
||||
func _item_node(index: int) -> TileItem:
|
||||
for child in items.get_children():
|
||||
var it := child as TileItem
|
||||
if it and it.placement_index == index:
|
||||
return it
|
||||
return null
|
||||
|
||||
|
||||
# --- 배치 모드 ---
|
||||
|
||||
func begin_placement(kind: String, id: String) -> void:
|
||||
if kind == "upgrade":
|
||||
return
|
||||
_cancel_drag()
|
||||
_placing_kind = kind
|
||||
_placing_id = id
|
||||
placement_changed.emit(true, kind, id)
|
||||
_update_ghost()
|
||||
|
||||
|
||||
func cancel_placement() -> void:
|
||||
if _placing_id.is_empty():
|
||||
return
|
||||
_placing_kind = ""
|
||||
_placing_id = ""
|
||||
ghost.hide()
|
||||
placement_changed.emit(false, "", "")
|
||||
|
||||
|
||||
func is_placing() -> bool:
|
||||
return not _placing_id.is_empty()
|
||||
|
||||
|
||||
func _update_ghost() -> void:
|
||||
if not is_placing() or _hover.x < 0:
|
||||
ghost.hide()
|
||||
return
|
||||
var anchor := _anchor_for(_placing_kind, _placing_id, _hover)
|
||||
var span := Catalog.size_of(_placing_kind, _placing_id)
|
||||
var ok := GameState.can_place_at(_placing_kind, _placing_id, anchor.x, anchor.y) \
|
||||
and GameState.bubbles >= GameState.cost_of(_placing_kind, _placing_id)
|
||||
ghost.show_at(cell_rect(anchor.x, anchor.y, span), ok)
|
||||
|
||||
|
||||
func _try_place() -> void:
|
||||
var kind := _placing_kind
|
||||
var id := _placing_id
|
||||
var anchor := _anchor_for(kind, id, _hover)
|
||||
|
||||
if GameState.bubbles < GameState.cost_of(kind, id):
|
||||
message.emit("거품이 모자라요")
|
||||
cancel_placement()
|
||||
return
|
||||
if not GameState.can_place_at(kind, id, anchor.x, anchor.y):
|
||||
message.emit(_why_not(kind, id, anchor))
|
||||
return
|
||||
|
||||
GameState.place(kind, id, anchor.x, anchor.y)
|
||||
# 계속 놓을 수 있으면 모드를 유지한다. 같은 걸 여러 개 깔 때 편하다.
|
||||
if kind == "ornament" or GameState.bubbles < GameState.cost_of(kind, id):
|
||||
cancel_placement()
|
||||
else:
|
||||
_update_ghost()
|
||||
|
||||
|
||||
func _why_not(kind: String, id: String, anchor: Vector2i) -> String:
|
||||
var item_zone := Catalog.zone_of(kind, id)
|
||||
if not GameState.is_zone_unlocked(item_zone):
|
||||
return "%s 아직 잠겨 있어요" % Korean.i(String(Catalog.zone(item_zone).name))
|
||||
var cells := GameState.footprint(kind, id, anchor.x, anchor.y)
|
||||
if cells.is_empty():
|
||||
return "바다 밖으로 나가요"
|
||||
for c in cells:
|
||||
if Catalog.zone_of_column(c.x) != item_zone:
|
||||
return "%s 안에만 놓을 수 있어요" % Catalog.zone(item_zone).name
|
||||
return "이미 무언가 놓여 있어요"
|
||||
|
||||
|
||||
# --- 입력 ---
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseMotion:
|
||||
_hover = cell_at(event.position)
|
||||
if _drag_index >= 0:
|
||||
_update_drag(event.position)
|
||||
elif is_placing():
|
||||
_update_ghost()
|
||||
elif _press_index >= 0 and event.position.distance_to(_press_position) > DRAG_THRESHOLD:
|
||||
_begin_drag()
|
||||
return
|
||||
|
||||
if event is not InputEventMouseButton:
|
||||
return
|
||||
|
||||
if event.button_index == MOUSE_BUTTON_RIGHT and event.pressed:
|
||||
if is_placing():
|
||||
cancel_placement()
|
||||
elif _drag_index >= 0:
|
||||
_cancel_drag()
|
||||
else:
|
||||
context_menu_requested.emit()
|
||||
return
|
||||
|
||||
if event.button_index != MOUSE_BUTTON_LEFT:
|
||||
return
|
||||
|
||||
if event.pressed:
|
||||
_hover = cell_at(event.position)
|
||||
if is_placing():
|
||||
_try_place()
|
||||
return
|
||||
_press_position = event.position
|
||||
_press_index = _pick_at(event.position)
|
||||
else:
|
||||
if _drag_index >= 0:
|
||||
_drop(event.position)
|
||||
elif _press_index != PRESS_WAS_DRAG:
|
||||
# 끌지 않고 뗐으면 그냥 물을 클릭한 것으로 친다.
|
||||
clicked.emit(event.position)
|
||||
_press_index = -1
|
||||
|
||||
|
||||
func _begin_drag() -> void:
|
||||
if _press_index < 0:
|
||||
return
|
||||
_drag_index = _press_index
|
||||
_press_index = PRESS_WAS_DRAG
|
||||
drag_changed.emit(true)
|
||||
|
||||
|
||||
func _update_drag(pos: Vector2) -> void:
|
||||
var node := _item_node(_drag_index)
|
||||
if node:
|
||||
node.position = pos
|
||||
|
||||
if trash_rect.has_point(pos):
|
||||
ghost.hide()
|
||||
return
|
||||
|
||||
var p := GameState.placements[_drag_index]
|
||||
var anchor := _anchor_for(p.kind, p.id, _hover)
|
||||
var span := Catalog.size_of(p.kind, p.id)
|
||||
var ok := GameState.can_place_at(p.kind, p.id, anchor.x, anchor.y, _drag_index)
|
||||
ghost.show_at(cell_rect(anchor.x, anchor.y, span), ok)
|
||||
|
||||
|
||||
func _drop(pos: Vector2) -> void:
|
||||
var index := _drag_index
|
||||
_drag_index = -1
|
||||
ghost.hide()
|
||||
drag_changed.emit(false)
|
||||
|
||||
if index < 0 or index >= GameState.placements.size():
|
||||
_rebuild_items()
|
||||
return
|
||||
|
||||
if trash_rect.has_point(pos):
|
||||
var p := GameState.placements[index]
|
||||
var item_name := String(Catalog.item(p.kind, p.id).get("name", ""))
|
||||
var refund := GameState.remove(index)
|
||||
message.emit("%s 치우고 거품 %s를 돌려받았어요" % [
|
||||
Korean.eul(item_name), NumberFormat.short(refund)])
|
||||
return
|
||||
|
||||
var placement := GameState.placements[index]
|
||||
var anchor := _anchor_for(placement.kind, placement.id, _hover)
|
||||
if not GameState.move(index, anchor.x, anchor.y):
|
||||
message.emit("여기에는 놓을 수 없어요")
|
||||
# 성공이든 실패든 제자리를 다시 잡아준다.
|
||||
_rebuild_items()
|
||||
|
||||
|
||||
func _cancel_drag() -> void:
|
||||
if _drag_index < 0:
|
||||
return
|
||||
_drag_index = -1
|
||||
ghost.hide()
|
||||
drag_changed.emit(false)
|
||||
_rebuild_items()
|
||||
|
||||
|
||||
## 클릭 지점에서 거품이 터지는 연출.
|
||||
func pop_at(pos: Vector2) -> void:
|
||||
var burst := CPUParticles2D.new()
|
||||
add_child(burst)
|
||||
burst.position = pos
|
||||
burst.emitting = true
|
||||
burst.one_shot = true
|
||||
burst.explosiveness = 1.0
|
||||
burst.amount = 10
|
||||
burst.lifetime = 0.6
|
||||
burst.gravity = Vector2.ZERO
|
||||
burst.initial_velocity_min = 40.0
|
||||
burst.initial_velocity_max = 110.0
|
||||
burst.scale_amount_min = 0.5
|
||||
burst.scale_amount_max = 1.3
|
||||
burst.color = Color(1, 1, 1, 0.55)
|
||||
get_tree().create_timer(1.0).timeout.connect(burst.queue_free)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ocdjqcs67mr8
|
||||
@@ -0,0 +1,61 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/aquarium/aquarium.gd" id="1_aq"]
|
||||
[ext_resource type="Script" path="res://scenes/aquarium/seabed.gd" id="2_ground"]
|
||||
[ext_resource type="Script" path="res://scenes/aquarium/grid_ghost.gd" id="3_ghost"]
|
||||
|
||||
[node name="Aquarium" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
clip_contents = true
|
||||
script = ExtResource("1_aq")
|
||||
|
||||
[node name="Ground" type="Node2D" parent="."]
|
||||
script = ExtResource("2_ground")
|
||||
|
||||
[node name="Items" type="Node2D" parent="."]
|
||||
|
||||
[node name="Water" type="Control" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Bubbles" type="CPUParticles2D" parent="."]
|
||||
|
||||
[node name="Ghost" type="Node2D" parent="."]
|
||||
script = ExtResource("3_ghost")
|
||||
|
||||
[node name="Dividers" type="Control" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="ZoneLabels" type="Control" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Locks" type="Control" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
@@ -0,0 +1,26 @@
|
||||
extends Node2D
|
||||
class_name GridGhost
|
||||
## 배치·이동 중에 "여기에 놓입니다"를 보여주는 미리보기 사각형.
|
||||
|
||||
const OK_FILL := Color(0.45, 1.0, 0.70, 0.22)
|
||||
const OK_LINE := Color(0.55, 1.0, 0.78, 0.85)
|
||||
const BAD_FILL := Color(1.0, 0.35, 0.35, 0.20)
|
||||
const BAD_LINE := Color(1.0, 0.45, 0.45, 0.80)
|
||||
|
||||
var area := Rect2()
|
||||
var valid := true
|
||||
|
||||
|
||||
func show_at(rect: Rect2, is_valid: bool) -> void:
|
||||
area = rect
|
||||
valid = is_valid
|
||||
visible = true
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if area.size.x <= 0.0:
|
||||
return
|
||||
var inset := area.grow(-2.0)
|
||||
draw_rect(inset, BAD_FILL if not valid else OK_FILL, true)
|
||||
draw_rect(inset, BAD_LINE if not valid else OK_LINE, false, 2.0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://x5tqoap2rmxw
|
||||
@@ -0,0 +1,52 @@
|
||||
extends Node2D
|
||||
class_name Seabed
|
||||
## 탑다운 바다의 바닥. 격자 칸마다 그 자리 구역의 모래색을 조금씩 달리해 깔고,
|
||||
## 그 위에 아주 옅은 격자선을 얹어 "어디에 놓을 수 있는지"가 보이게 한다.
|
||||
|
||||
var rect_size := Vector2(1280, 220)
|
||||
## 위쪽 정보 바 자리. 여기에는 격자를 긋지 않는다.
|
||||
var grid_top := 30.0
|
||||
|
||||
const GRID_LINE := Color(0, 0, 0, 0.13)
|
||||
const SPECK := Color(0, 0, 0, 0.06)
|
||||
|
||||
|
||||
## 칸마다 늘 같은 값을 주는 해시. 매번 다른 무늬가 나오면 재배치할 때마다 바닥이 깜빡인다.
|
||||
func _hash(col: int, row: int, salt: int) -> float:
|
||||
var h := (col * 73856093) ^ (row * 19349663) ^ (salt * 83492791)
|
||||
return float(absi(h) % 1000) / 1000.0
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var cols := GameState.columns()
|
||||
var rows := GameState.rows()
|
||||
if rect_size.x < 4.0 or cols <= 0 or rows <= 0:
|
||||
return
|
||||
|
||||
var grid_height := maxf(rect_size.y - grid_top, 1.0)
|
||||
var tw := rect_size.x / cols
|
||||
var th := grid_height / rows
|
||||
|
||||
for col in cols:
|
||||
var base := Catalog.ramp_color("sand", (col + 0.5) / cols)
|
||||
# 정보 바가 앉는 위쪽 띠. 놓을 수 없는 자리라 조금 어둡게 둔다.
|
||||
draw_rect(Rect2(col * tw, 0.0, tw + 1.0, grid_top + 1.0), base.darkened(0.10))
|
||||
|
||||
for row in rows:
|
||||
var shade := (_hash(col, row, 1) - 0.5) * 0.09
|
||||
var c := base.lightened(shade) if shade > 0.0 else base.darkened(-shade)
|
||||
var y := grid_top + row * th
|
||||
# 칸 경계에 실선이 비지 않도록 1px씩 겹쳐 그린다.
|
||||
draw_rect(Rect2(col * tw, y, tw + 1.0, th + 1.0), c)
|
||||
|
||||
# 모래알 몇 점. 바닥이 완전히 평평해 보이는 걸 막는다.
|
||||
for s in 2:
|
||||
var px := (col + _hash(col, row, 10 + s)) * tw
|
||||
var py := y + _hash(col, row, 20 + s) * th
|
||||
draw_circle(Vector2(px, py), 1.0 + _hash(col, row, 30 + s), SPECK)
|
||||
|
||||
for col in range(1, cols):
|
||||
draw_line(Vector2(col * tw, grid_top), Vector2(col * tw, rect_size.y), GRID_LINE, 1.0)
|
||||
for row in rows + 1:
|
||||
var y := grid_top + row * th
|
||||
draw_line(Vector2(0, y), Vector2(rect_size.x, y), GRID_LINE, 1.0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://t23j7yuhibs7
|
||||
@@ -0,0 +1,253 @@
|
||||
extends Node2D
|
||||
class_name TileItem
|
||||
## 격자 위에 놓인 것 하나. 3/4 부감 시점으로 그린다.
|
||||
##
|
||||
## 바닥은 위에서 내려다보지만 물건은 "서 있는" 모습으로 그린다.
|
||||
## 노드의 원점은 발이 바닥에 닿는 지점(발자국 아래 가운데)이고,
|
||||
## 그림은 거기서 위(-Y)로 자란다. 그래서 키가 큰 것은 뒷줄을 가린다.
|
||||
## 아직 아트 에셋이 없어서 형태별로 직접 그린다.
|
||||
## assets/art/ 에 스프라이트가 생기면 Sprite2D + offset 기반으로 바꾸면 된다.
|
||||
|
||||
## placements 배열에서의 인덱스. 클릭·드래그로 무엇을 집었는지 알아내는 데 쓴다.
|
||||
var placement_index := -1
|
||||
|
||||
var data: Dictionary = {}
|
||||
var shape := "rock"
|
||||
var color := Color.WHITE
|
||||
## 바닥에서 차지하는 픽셀 크기.
|
||||
var span := Vector2(34, 32)
|
||||
## 서 있는 높이(픽셀).
|
||||
var body_height := 32.0
|
||||
## 바닥에서 얼마나 떠 있는지(픽셀). 헤엄치는 것만 0보다 크다.
|
||||
var hover := 0.0
|
||||
|
||||
var _t := 0.0
|
||||
var _phase := 0.0
|
||||
var _swim := Vector2.ZERO
|
||||
var _swim_range := Vector2.ZERO
|
||||
var _facing := 1.0
|
||||
var _shape_seed := 0.0
|
||||
|
||||
|
||||
func setup(item_data: Dictionary, kind: String, index: int, area: Rect2, cell: Vector2,
|
||||
rng: RandomNumberGenerator) -> void:
|
||||
data = item_data
|
||||
placement_index = index
|
||||
shape = String(item_data.get("shape", "rock"))
|
||||
color = item_data.get("color", Color.WHITE)
|
||||
_phase = rng.randf() * TAU
|
||||
_shape_seed = rng.randf() * 10.0
|
||||
_facing = 1.0 if rng.randf() < 0.5 else -1.0
|
||||
set_area(area, cell, Catalog.height_of(kind, String(item_data.get("id", ""))))
|
||||
|
||||
|
||||
## 놓인 자리가 정해졌을 때. 원점은 발자국의 아래 가운데,
|
||||
## 즉 물건이 바닥에 닿는 지점이다. 그림은 거기서 위로 자란다.
|
||||
func set_area(area: Rect2, cell: Vector2, height_tiles: float) -> void:
|
||||
span = area.size
|
||||
var cell_h := maxf(cell.y, 1.0)
|
||||
body_height = cell_h * height_tiles
|
||||
position = Vector2(area.position.x + area.size.x * 0.5, area.end.y)
|
||||
|
||||
match shape:
|
||||
"fish":
|
||||
hover = cell_h * 0.45
|
||||
_swim_range = Vector2(span.x * 0.30, cell_h * 0.22)
|
||||
"glow":
|
||||
hover = cell_h * 0.60
|
||||
_swim_range = Vector2(span.x * 0.18, cell_h * 0.20)
|
||||
_:
|
||||
hover = 0.0
|
||||
_swim_range = Vector2.ZERO
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## 그려지는 대략의 범위. 클릭으로 집을 때 쓴다.
|
||||
func visual_rect() -> Rect2:
|
||||
var top := -body_height - hover - _swim_range.y
|
||||
return Rect2(
|
||||
Vector2(-span.x * 0.5, top),
|
||||
Vector2(span.x, absf(top) + 4.0))
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_t += delta
|
||||
if _swim_range != Vector2.ZERO:
|
||||
var next_x := sin(_t * 0.55 + _phase) * _swim_range.x
|
||||
# 방향을 튼 순간 몸도 돌아본다.
|
||||
if absf(next_x - _swim.x) > 0.001:
|
||||
_facing = signf(next_x - _swim.x)
|
||||
_swim = Vector2(next_x, sin(_t * 0.9 + _phase * 1.7) * _swim_range.y)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
# 바닥 그림자. 3/4 시점에서 물건이 땅에 붙어 있다고 알려주는 신호다.
|
||||
# 떠 있을수록 넓고 옅어진다.
|
||||
var lift := clampf(hover / maxf(span.y, 1.0), 0.0, 1.0)
|
||||
draw_set_transform(Vector2(0, -span.y * 0.12), 0.0, Vector2(1.0, 0.34))
|
||||
draw_circle(Vector2.ZERO, span.x * (0.30 + lift * 0.10),
|
||||
Color(0, 0, 0, 0.20 - lift * 0.09))
|
||||
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
|
||||
|
||||
# 헤엄치는 것은 제 칸 위를 오간다.
|
||||
draw_set_transform(Vector2(_swim.x, -hover + _swim.y), 0.0, Vector2.ONE)
|
||||
match shape:
|
||||
"plant": _draw_plant()
|
||||
"fish": _draw_fish()
|
||||
"glow": _draw_glow()
|
||||
_: _draw_rock()
|
||||
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
|
||||
|
||||
|
||||
## 바닥에서 위로 자라는 잎. 밑동은 좁고 끝으로 갈수록 흔들린다.
|
||||
func _draw_plant() -> void:
|
||||
var blades := 3 + int(_shape_seed) % 3
|
||||
# 칸 너비를 다 채우지 않는다. 옆 칸과 사이가 벌어져야 격자가 읽힌다.
|
||||
var w := span.x * 0.32
|
||||
for i in blades:
|
||||
var offset := (i - (blades - 1) * 0.5) / maxf(blades - 1, 1) * w
|
||||
var height := body_height * (0.62 + fmod(_shape_seed * 3.7 + i * 0.43, 1.0) * 0.55)
|
||||
var sway := sin(_t * 1.1 + _phase + i * 0.8) * span.x * 0.18
|
||||
var thick := maxf(span.x * 0.13, 2.0)
|
||||
var tone := color.lerp(Color.BLACK, 0.06 + i * 0.05)
|
||||
|
||||
# 밑동에서 끝까지 부드럽게 휘는 띠.
|
||||
var left := PackedVector2Array()
|
||||
var right := PackedVector2Array()
|
||||
var steps := 5
|
||||
for s in steps + 1:
|
||||
var f := float(s) / steps
|
||||
var p := Vector2(offset + sway * f * f, -height * f)
|
||||
var half := thick * (1.0 - f * 0.65) * 0.5
|
||||
left.append(p + Vector2(half, 0))
|
||||
right.append(p - Vector2(half, 0))
|
||||
right.reverse()
|
||||
_fill(left + right, tone)
|
||||
|
||||
draw_circle(Vector2(0, -span.y * 0.06), span.x * 0.16, color.darkened(0.25))
|
||||
|
||||
|
||||
## 옆에서 본 물고기. 물 위에 떠 있고 바닥에 그림자를 남긴다.
|
||||
func _draw_fish() -> void:
|
||||
var l := span.x * 0.34
|
||||
var wag := sin(_t * 4.0 + _phase) * l * 0.30
|
||||
draw_set_transform(Vector2.ZERO, 0.0, Vector2(_facing, 1.0))
|
||||
|
||||
# 꼬리지느러미.
|
||||
_fill(PackedVector2Array([
|
||||
Vector2(-l * 0.55, 0.0),
|
||||
Vector2(-l * 1.10, -l * 0.42 + wag),
|
||||
Vector2(-l * 1.10, l * 0.38 + wag),
|
||||
]), color.darkened(0.30))
|
||||
|
||||
# 몸통. 코가 앞(+X), 등이 위(-Y).
|
||||
_fill(PackedVector2Array([
|
||||
Vector2(l * 1.00, -l * 0.02),
|
||||
Vector2(l * 0.45, -l * 0.46),
|
||||
Vector2(-l * 0.15, -l * 0.44),
|
||||
Vector2(-l * 0.58, -l * 0.12),
|
||||
Vector2(-l * 0.58, l * 0.14),
|
||||
Vector2(-l * 0.10, l * 0.42),
|
||||
Vector2(l * 0.50, l * 0.36),
|
||||
]), color)
|
||||
|
||||
# 등지느러미와 배 쪽 밝은 면.
|
||||
_fill(PackedVector2Array([
|
||||
Vector2(l * 0.10, -l * 0.44),
|
||||
Vector2(-l * 0.10, -l * 0.90),
|
||||
Vector2(-l * 0.35, -l * 0.36),
|
||||
]), color.darkened(0.22))
|
||||
_fill(PackedVector2Array([
|
||||
Vector2(l * 0.55, l * 0.28),
|
||||
Vector2(-l * 0.20, l * 0.40),
|
||||
Vector2(-l * 0.35, l * 0.10),
|
||||
Vector2(l * 0.40, l * 0.06),
|
||||
]), color.lightened(0.28))
|
||||
|
||||
draw_circle(Vector2(l * 0.60, -l * 0.14), maxf(l * 0.11, 1.2), Color(0.06, 0.07, 0.09))
|
||||
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
|
||||
|
||||
|
||||
## 스스로 빛나며 떠 있는 것. 후광과 아래로 늘어진 촉수.
|
||||
func _draw_glow() -> void:
|
||||
var r := span.x * 0.26
|
||||
var pulse := 0.88 + sin(_t * 1.5 + _phase) * 0.12
|
||||
var bell := Vector2(0, -body_height * 0.45)
|
||||
|
||||
for i in range(5, 0, -1):
|
||||
draw_circle(bell, r * (float(i) / 5.0) * 2.1 * pulse,
|
||||
Color(color.r, color.g, color.b, 0.08))
|
||||
|
||||
# 아래로 늘어진 촉수를 먼저 그려 갓 뒤로 보내둔다.
|
||||
for i in 4:
|
||||
var x := (i - 1.5) * r * 0.48
|
||||
var strand := PackedVector2Array()
|
||||
for s in 5:
|
||||
var f := float(s) / 4.0
|
||||
strand.append(bell + Vector2(
|
||||
x + sin(_t * 1.6 + _phase + i + f * 3.0) * r * 0.30 * f,
|
||||
r * 0.5 + body_height * 0.42 * f))
|
||||
draw_polyline(strand, Color(color.r, color.g, color.b, 0.50),
|
||||
maxf(r * 0.16, 1.2), true)
|
||||
|
||||
# 반구형 갓.
|
||||
var cap := PackedVector2Array()
|
||||
for i in 13:
|
||||
var a := PI + PI * (float(i) / 12.0)
|
||||
cap.append(bell + Vector2(cos(a) * r * 1.0, sin(a) * r * 0.85) * pulse)
|
||||
cap.append(bell + Vector2(r, 0))
|
||||
cap.append(bell + Vector2(-r, 0))
|
||||
_fill(cap, color, false)
|
||||
draw_circle(bell + Vector2(0, -r * 0.25), r * 0.32 * pulse, color.lightened(0.45))
|
||||
|
||||
|
||||
## 바위·구조물. 아래가 넓고 위로 갈수록 좁아지는 덩어리.
|
||||
## 위가 넓어지면 버섯이나 전등갓처럼 보이므로 반드시 좁혀야 한다.
|
||||
func _draw_rock() -> void:
|
||||
var w := span.x * 0.42
|
||||
var h := body_height
|
||||
var sides := 9
|
||||
|
||||
var body := PackedVector2Array()
|
||||
for i in sides + 1:
|
||||
var f := float(i) / sides
|
||||
body.append(Vector2(-w * _rock_profile(f, 0.0), -h * f))
|
||||
for i in range(sides, -1, -1):
|
||||
var f := float(i) / sides
|
||||
body.append(Vector2(w * _rock_profile(f, 1.7), -h * f))
|
||||
_fill(body, color.darkened(0.30))
|
||||
|
||||
# 빛을 받는 왼쪽 면.
|
||||
var lit := PackedVector2Array()
|
||||
for p in body:
|
||||
lit.append(Vector2(p.x * 0.58 - w * 0.16, p.y))
|
||||
_fill(lit, color, false)
|
||||
|
||||
# 꼭대기 면. 부감이라 윗면이 살짝 보이지만, 몸통보다 넓으면 안 된다.
|
||||
var top_w := w * _rock_profile(1.0, 0.0)
|
||||
draw_set_transform(Vector2(0, -h), 0.0, Vector2(1.0, 0.34))
|
||||
draw_circle(Vector2.ZERO, top_w * 0.92, color.lightened(0.16))
|
||||
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
|
||||
|
||||
|
||||
## 바위의 높이 f(0=밑동, 1=꼭대기)에서 옆으로 벌어지는 정도.
|
||||
## 납작한 것은 둥근 바위로, 키 큰 것은 기둥으로 보이도록 두 모양을 섞는다.
|
||||
## 위로 갈수록 넓어지면 버섯이나 전등갓처럼 보이므로 절대 1을 넘기지 않는다.
|
||||
func _rock_profile(f: float, salt: float) -> float:
|
||||
# 발자국 너비에 비해 얼마나 높은지. 낮고 넓으면 둥근 바위, 좁고 높으면 기둥.
|
||||
var tallness := clampf((body_height / maxf(span.x, 1.0) - 0.65) / 0.35, 0.0, 1.0)
|
||||
var dome := pow(maxf(1.0 - pow(f, 1.7), 0.0), 0.55)
|
||||
var column := 1.0 - f * 0.30
|
||||
var wobble := 0.93 + sin(f * 2.4 + _shape_seed + salt) * 0.07
|
||||
return lerpf(dome, column, tallness) * wobble
|
||||
|
||||
|
||||
## 면을 칠하고 어두운 테두리를 두른다.
|
||||
## 물빛 오버레이가 덮이면 형태가 바닥에 묻히기 때문에, 테두리가 없으면 무엇인지 안 읽힌다.
|
||||
func _fill(points: PackedVector2Array, fill: Color, outline := true) -> void:
|
||||
draw_colored_polygon(points, fill)
|
||||
if outline and points.size() > 2:
|
||||
var loop := points.duplicate()
|
||||
loop.append(points[0])
|
||||
draw_polyline(loop, fill.darkened(0.55), maxf(span.x * 0.04, 1.0), true)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cdtc2kqnfuc8o
|
||||
+284
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://d2lmk0fcjo351
|
||||
@@ -0,0 +1,28 @@
|
||||
[gd_scene load_steps=5 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/main.gd" id="1_main"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/aquarium/aquarium.tscn" id="2_aquarium"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/ui/hud.tscn" id="3_hud"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/ui/shop_panel.tscn" id="4_shop"]
|
||||
|
||||
[node name="Main" 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_main")
|
||||
|
||||
[node name="Aquarium" parent="." instance=ExtResource("2_aquarium")]
|
||||
layout_mode = 1
|
||||
|
||||
[node name="HUD" parent="." instance=ExtResource("3_hud")]
|
||||
layout_mode = 1
|
||||
|
||||
[node name="ShopPanel" parent="." instance=ExtResource("4_shop")]
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
|
||||
[node name="ContextMenu" type="PopupMenu" parent="."]
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://clxf1syisntmq
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bn8cwiiwdmbsg
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user