먼 바다 배경·하루의 빛·층 구조·감상 모드
네 가지 작업이 같은 함수들을 겹쳐 고쳐서 한 커밋으로 묶는다. 쪼개려면 hunk 단위로 갈라야 하는데 서로 얽혀 있어 오히려 위험하다. 먼 바다 배경 (backdrop.gd) - 3/4 부감에서 화면 위쪽은 더 먼 곳이다. 거기까지 모래를 깔면 바다가 아니라 사막처럼 보여서, 위쪽 띠를 물로 비웠다. - 먼 물 그라디언트 → 능선 → 먼 실루엣 순으로 깊이를 만든다. - Seabed가 뒷줄일수록 색을 물빛 쪽으로 당겨 공기원근을 준다. - 빛줄기를 알파를 더하는 방향으로 바꿨다. 걷어내는 방향이면 바닥만 밝아지고 물기둥은 텅 빈 채로 남는다. 하루의 빛 (day_cycle.gd) - 실제 시각에 맞춰 새벽/아침/낮/저녁/밤. 이름은 구간으로 끊고 색과 밝기는 계속 보간한다. - 밤에는 물결과 빛줄기가 잦아들고, 스스로 빛나는 것은 어둠을 되밀어 오히려 도드라진다. - godot --path . -- --hour=21 로 특정 시각을 눈으로 확인할 수 있다. 한 화면에 한 층 - 가로 연속 스크롤을 버리고 층 전환으로 바꿨다. 위층(얕은 바다)에서 아래층(열수구)으로 내려간다. 층 선택기·휠·↑↓ 키. - 층은 내부적으로 여전히 옆으로 늘어서 있고 화면이 한 구간만 비춘다. 갈아탈 때만 세로로 미끄러뜨려 깊이가 위아래로 읽히게 했다. - 칸 크기를 가로에서 정하도록 뒤집고 ZONE_COLUMNS를 36으로 올렸다. 층당 칸이 96 -> 216이라 정원과 해금 곡선은 다시 봐야 한다. - 한 화면에 한 층만 보이므로 층 색은 이웃과 섞지 않는다. 감상 모드 (F11) - 창을 화면 전체로. 세로로 남는 공간은 격자가 아니라 물기둥에 준다. 격자를 키우면 타일만 커지고 보이는 열은 오히려 줄어든다. - 창을 화면과 픽셀까지 똑같이 맞추면 Windows가 독점 전체화면으로 승격시켜 size 지정이 먹지 않는다. usable_rect까지만 쓴다. 그 밖에 - 저장 window_state: scroll_x -> layer - 알림이 정보 바와 겹치던 것을 바 아래로 옮겼다 - 난파선 선체 폴리곤이 제 몸을 가로질러 삼각분할이 실패하던 것 수정 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+186
-42
@@ -1,7 +1,11 @@
|
||||
extends Control
|
||||
class_name Aquarium
|
||||
## 화면 하단에 깔리는 탑다운 바다.
|
||||
## 가로를 구역 수만큼 나눠, 왼쪽(얕은 바다)에서 오른쪽(열수구)으로 갈수록 깊어진다.
|
||||
## 화면 하단에 깔리는 3/4 부감 바다.
|
||||
## 한 화면에 한 층만 담는다. 위층(얕은 바다)에서 아래층(열수구)으로 내려갈수록 깊어지고,
|
||||
## 층은 왼쪽 아래 선택기나 휠·위아래 키로 갈아탄다.
|
||||
##
|
||||
## 층은 내부적으로 옆으로 늘어서 있고(층 n은 열 n*ZONE_COLUMNS부터), 화면은 그 중
|
||||
## 한 구간만 비춘다. 갈아탈 때만 위아래로 미끄러뜨려 깊이가 세로로 읽히게 한다.
|
||||
##
|
||||
## 격자 칸을 차지하는 것은 집뿐이다. 생물은 칸이 아니라 집에 살기 때문에
|
||||
## 그릴 때마다 구역별로 집에 차례로 배정해서 그 집 둘레에 세운다.
|
||||
@@ -16,18 +20,21 @@ signal build_mode_changed(active: bool, id: String)
|
||||
signal drag_changed(dragging: bool)
|
||||
## 물 위에서 우클릭. main이 메뉴를 연다.
|
||||
signal context_menu_requested
|
||||
## 보고 있는 층이 바뀌었을 때. 층 선택기가 표시를 맞춘다.
|
||||
signal layer_changed(index: int)
|
||||
|
||||
## 드래그한 것을 여기에 떨구면 치운다. 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
|
||||
@onready var world: Node2D = $World
|
||||
@onready var backdrop: Backdrop = $World/Backdrop
|
||||
@onready var ground: Seabed = $World/Ground
|
||||
@onready var items: Node2D = $World/Items
|
||||
@onready var water: Node2D = $World/Water
|
||||
@onready var bubbles: CPUParticles2D = $World/Bubbles
|
||||
@onready var ghost: GridGhost = $World/Ghost
|
||||
@onready var zone_labels: Node2D = $World/ZoneLabels
|
||||
@onready var locks: Node2D = $World/Locks
|
||||
|
||||
## 위쪽 이만큼은 정보 바가 앉는 자리라 격자에서 뺀다.
|
||||
## 여기를 비워두지 않으면 첫 줄 칸이 바에 가려 보이지도, 눌리지도 않는다.
|
||||
@@ -40,6 +47,13 @@ var grid_top := 30.0:
|
||||
if is_node_ready():
|
||||
_on_layout_changed()
|
||||
|
||||
## 칸의 가로:세로 비. 1보다 크면 살짝 옆으로 퍼져 부감처럼 보인다.
|
||||
const TILE_ASPECT := 1.15
|
||||
## 층을 갈아탈 때 미끄러지는 시간.
|
||||
const LAYER_SLIDE := 0.32
|
||||
## 감상 모드에서 격자가 차지하는 세로 비율. 나머지는 물기둥이 된다.
|
||||
## 높이를 전부 격자에 주면 칸만 거대해지고 보이는 열은 오히려 줄어든다.
|
||||
const SHOWCASE_GRID_RATIO := 0.45
|
||||
const DRAG_THRESHOLD := 5.0
|
||||
## 물고기가 집을 한 바퀴 도는 속도(라디안/초). 위젯이라 눈에 거슬리지 않게 느리다.
|
||||
const ORBIT_SPEED := 0.5
|
||||
@@ -58,6 +72,34 @@ var _drag_ref := {}
|
||||
var _drag_node: TileItem = null
|
||||
## 이번 누름이 클릭이 아니라 드래그였다는 표식.
|
||||
var _press_was_drag := false
|
||||
var _pressing := false
|
||||
var _slide: Tween
|
||||
## 낮빛을 입힐 대상들. 배치 미리보기(Ghost)는 빼둔다 -- 밤에도 또렷해야 한다.
|
||||
var _daylit: Array[CanvasItem] = []
|
||||
## 마지막으로 셰이더에 넣은 빛의 양. 조금이라도 달라져야 다시 넣는다.
|
||||
var _applied_sun := -1.0
|
||||
|
||||
## 감상 모드. 바다를 아래에 깔고 그 위를 물기둥으로 채운다.
|
||||
var showcase := false:
|
||||
set(value):
|
||||
if showcase == value:
|
||||
return
|
||||
showcase = value
|
||||
if is_node_ready():
|
||||
_on_layout_changed()
|
||||
|
||||
## 지금 보고 있는 층. 0이 맨 위(얕은 바다), 커질수록 깊다.
|
||||
## 한 화면에 한 층만 담기므로 화면 위치는 이 값 하나로 정해진다.
|
||||
var current_zone := 0:
|
||||
set(value):
|
||||
var next := clampi(value, 0, Catalog.zone_count() - 1)
|
||||
if next == current_zone and is_node_ready():
|
||||
return
|
||||
var going_down := next > current_zone
|
||||
current_zone = next
|
||||
if is_node_ready():
|
||||
_slide_to_layer(going_down)
|
||||
layer_changed.emit(current_zone)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -67,6 +109,9 @@ func _ready() -> void:
|
||||
ghost.hide()
|
||||
_setup_bubbles()
|
||||
|
||||
_daylit = [backdrop, ground, items, water, bubbles, zone_labels, locks]
|
||||
_apply_daylight()
|
||||
|
||||
resized.connect(_on_layout_changed)
|
||||
GameState.zone_unlocked.connect(_on_zone_unlocked)
|
||||
GameState.reloaded.connect(_rebuild_items)
|
||||
@@ -76,30 +121,100 @@ func _ready() -> void:
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
_apply_daylight()
|
||||
_follow_view()
|
||||
|
||||
# 잠긴 구역의 안내 문구는 거품이 쌓이면서 계속 바뀐다.
|
||||
for index in _lock_reasons:
|
||||
if index == GameState.next_zone_index():
|
||||
_lock_reasons[index].text = GameState.next_zone_blocker()
|
||||
|
||||
|
||||
## 부유물 뿌리개를 지금 보이는 곳으로 옮긴다. 스크롤해도 밀도가 유지된다.
|
||||
func _follow_view() -> void:
|
||||
bubbles.position = Vector2(layer_offset() + size.x * 0.5, size.y * 0.5)
|
||||
bubbles.emission_rect_extents = Vector2(size.x, size.y) * 0.55
|
||||
|
||||
|
||||
## 하루의 빛을 바다에 입힌다.
|
||||
## 곱하기라 밤에는 전체가 어두워지고 저녁에는 주황빛이 돈다.
|
||||
func _apply_daylight() -> void:
|
||||
for node in _daylit:
|
||||
if is_instance_valid(node):
|
||||
node.modulate = DayCycle.tint
|
||||
|
||||
# 물결과 빛줄기는 수면에서 오는 빛이므로 밤에는 잦아들어야 한다.
|
||||
if absf(DayCycle.light - _applied_sun) < 0.01:
|
||||
return
|
||||
_applied_sun = DayCycle.light
|
||||
for child in water.get_children():
|
||||
var rect := child as ColorRect
|
||||
if rect and rect.material is ShaderMaterial:
|
||||
(rect.material as ShaderMaterial).set_shader_parameter("sun", _applied_sun)
|
||||
|
||||
|
||||
# --- 격자 좌표 ---
|
||||
|
||||
## 격자가 실제로 깔리는 영역. 위쪽 정보 바 자리는 뺀다.
|
||||
func grid_rect() -> Rect2:
|
||||
# 스트립이 낮을 때 바가 격자를 다 먹어버리지 않도록 위쪽 절반은 넘기지 않는다.
|
||||
var top := clampf(grid_top, 0.0, size.y * 0.45)
|
||||
return Rect2(Vector2(0.0, top), Vector2(size.x, maxf(size.y - top, 1.0)))
|
||||
|
||||
|
||||
## 칸 하나의 픽셀 크기.
|
||||
## 한 층이 화면 가로를 꽉 채우므로 가로는 화면 너비를 열 수로 나눈 값이고,
|
||||
## 세로는 거기에 비를 맞춘다. 그렇게 잡은 격자가 남은 높이보다 크면 눌러 넣는다.
|
||||
func cell_size() -> Vector2:
|
||||
var g := grid_rect()
|
||||
return Vector2(g.size.x / GameState.columns(), g.size.y / GameState.rows())
|
||||
var w := size.x / maxf(float(Catalog.ZONE_COLUMNS), 1.0)
|
||||
var h := w / TILE_ASPECT
|
||||
var room := _band_height()
|
||||
if h * GameState.rows() > room:
|
||||
h = room / GameState.rows()
|
||||
return Vector2(w, h)
|
||||
|
||||
|
||||
func cell_at(pos: Vector2) -> Vector2i:
|
||||
## 격자가 쓸 수 있는 세로 폭.
|
||||
func _band_height() -> float:
|
||||
if showcase:
|
||||
return maxf(size.y * SHOWCASE_GRID_RATIO, 1.0)
|
||||
# 스트립이 낮을 때 바가 격자를 다 먹어버리지 않도록 위쪽 절반은 넘기지 않는다.
|
||||
return maxf(size.y - clampf(grid_top, 0.0, size.y * 0.45), 1.0)
|
||||
|
||||
|
||||
## 격자가 실제로 깔리는 영역. 언제나 화면 아래쪽에 붙는다.
|
||||
## 남는 위쪽은 Backdrop이 물기둥으로 채운다 -- 세로가 넉넉할 때 격자를 키우는 것보다
|
||||
## 물을 채우는 편이 바다로 읽힌다.
|
||||
func grid_rect() -> Rect2:
|
||||
var h := cell_size().y * GameState.rows()
|
||||
return Rect2(Vector2(0.0, size.y - h), Vector2(size.x, h))
|
||||
|
||||
|
||||
## 바다 전체의 가로 길이. 층 수만큼 옆으로 이어져 있지만 한 번에 하나만 보인다.
|
||||
func world_width() -> float:
|
||||
return cell_size().x * GameState.columns()
|
||||
|
||||
|
||||
## 지금 층이 화면 왼쪽 끝에 오도록 밀어 둔 양.
|
||||
func layer_offset() -> float:
|
||||
return current_zone * Catalog.ZONE_COLUMNS * cell_size().x
|
||||
|
||||
|
||||
## 화면 좌표를 바다 좌표로. 지금 층만큼 옆으로 밀려 있다.
|
||||
func to_world(screen_pos: Vector2) -> Vector2:
|
||||
return screen_pos + Vector2(layer_offset(), 0.0)
|
||||
|
||||
|
||||
## 층을 갈아탈 때 위아래로 미끄러뜨린다.
|
||||
## 실제 배치는 옆으로 늘어서 있지만, 화면에서는 위층/아래층으로 오가는 것처럼 보여야 한다.
|
||||
func _slide_to_layer(going_down: bool) -> void:
|
||||
if _slide and _slide.is_valid():
|
||||
_slide.kill()
|
||||
world.position = Vector2(-layer_offset(), size.y * (1.0 if going_down else -1.0))
|
||||
_slide = create_tween()
|
||||
_slide.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
|
||||
_slide.tween_property(world, "position:y", 0.0, LAYER_SLIDE)
|
||||
|
||||
|
||||
## 화면에서 누른 자리가 어느 칸인지.
|
||||
func cell_at(screen_pos: Vector2) -> Vector2i:
|
||||
var c := cell_size()
|
||||
var p := to_world(screen_pos)
|
||||
var top := grid_rect().position.y
|
||||
return Vector2i(int(floor(pos.x / c.x)), int(floor((pos.y - top) / c.y)))
|
||||
return Vector2i(int(floor(p.x / c.x)), int(floor((p.y - top) / c.y)))
|
||||
|
||||
|
||||
func cell_rect(col: int, row: int, span := Vector2i.ONE) -> Rect2:
|
||||
@@ -127,19 +242,33 @@ func _anchor_for(id: String, hover: Vector2i) -> Vector2i:
|
||||
## 크기가 바뀌거나 구역이 열리면 전부 다시 만든다.
|
||||
## 스트립은 자주 바뀌지 않으므로 부분 갱신보다 통째로 다시 만드는 쪽이 단순하다.
|
||||
func _on_layout_changed() -> void:
|
||||
ground.rect_size = size
|
||||
# 배경과 바닥은 화면이 아니라 바다 전체 너비로 그린다.
|
||||
var world_size := Vector2(world_width(), size.y)
|
||||
|
||||
backdrop.rect_size = world_size
|
||||
backdrop.grid_top = grid_rect().position.y
|
||||
backdrop.queue_redraw()
|
||||
|
||||
ground.rect_size = world_size
|
||||
ground.grid_top = grid_rect().position.y
|
||||
# 감상 중에는 격자선을 눌러 둔다. 지금은 놓는 시간이 아니라 보는 시간이다.
|
||||
ground.grid_alpha = 0.05 if showcase else 0.13
|
||||
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)
|
||||
bubbles.position = Vector2(world_width(), size.y) * 0.5
|
||||
bubbles.emission_rect_extents = Vector2(world_width(), size.y) * 0.5
|
||||
# 부유물은 바다 전체가 아니라 지금 보이는 곳에만 뿌린다.
|
||||
# 넓은 바다에 고르게 뿌리면 화면당 밀도가 낮아져 텅 빈 벽처럼 보인다.
|
||||
bubbles.amount = clampi(int(size.x * size.y / 7000.0), 24, 200)
|
||||
_follow_view()
|
||||
|
||||
# 격자 크기가 바뀌었으므로 지금 층을 다시 화면에 맞춘다.
|
||||
world.position = Vector2(-layer_offset(), 0.0)
|
||||
|
||||
|
||||
func _build_water() -> void:
|
||||
@@ -158,22 +287,21 @@ func _build_water() -> void:
|
||||
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("ray_left", _ray_strength(float(i) / n))
|
||||
mat.set_shader_parameter("ray_right", _ray_strength(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)
|
||||
|
||||
# 새로 만든 물이라 빛의 양이 아직 안 들어갔다. 다음 틀에서 넣도록 표시해 둔다.
|
||||
_applied_sun = -1.0
|
||||
|
||||
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 _ray_strength(f: float) -> float:
|
||||
return clampf(1.0 - Catalog.ramp_float("murk", f) * 1.5, 0.0, 1.0)
|
||||
|
||||
|
||||
func _build_zone_labels() -> void:
|
||||
@@ -274,6 +402,8 @@ func _on_lock_pressed(index: int) -> void:
|
||||
func _on_zone_unlocked(index: int) -> void:
|
||||
message.emit("%s 열렸어요" % Korean.i(String(Catalog.zone(index).name)))
|
||||
_on_layout_changed()
|
||||
# 새로 열린 곳은 보통 화면 밖이다. 바로 보여준다.
|
||||
current_zone = index
|
||||
|
||||
|
||||
|
||||
@@ -282,6 +412,9 @@ func _on_zone_unlocked(index: int) -> void:
|
||||
|
||||
func _setup_bubbles() -> void:
|
||||
bubbles.emitting = true
|
||||
# 한 번 나온 부유물은 그 자리에 머문다. 이게 없으면 화면을 밀 때
|
||||
# 부유물이 통째로 따라와 물이 같이 움직이는 것처럼 보인다.
|
||||
bubbles.local_coords = false
|
||||
bubbles.lifetime = 3.5
|
||||
bubbles.emission_shape = CPUParticles2D.EMISSION_SHAPE_RECTANGLE
|
||||
bubbles.gravity = Vector2.ZERO
|
||||
@@ -450,7 +583,7 @@ func _pick_at(pos: Vector2) -> Dictionary:
|
||||
continue
|
||||
var r := it.visual_rect()
|
||||
r.position += it.position
|
||||
if r.has_point(pos) and it.position.y > best_y:
|
||||
if r.has_point(to_world(pos)) and it.position.y > best_y:
|
||||
best_y = it.position.y
|
||||
best = it
|
||||
if best == null:
|
||||
@@ -490,6 +623,8 @@ func begin_build(id: String) -> void:
|
||||
return
|
||||
_cancel_drag()
|
||||
_building_id = id
|
||||
# 그 구역이 화면 밖이면 지을 자리를 찍을 수가 없다. 먼저 데려다 놓는다.
|
||||
current_zone = Catalog.zone_of("house", id)
|
||||
build_mode_changed.emit(true, id)
|
||||
_update_ghost()
|
||||
|
||||
@@ -559,7 +694,7 @@ func _gui_input(event: InputEvent) -> void:
|
||||
_update_drag(event.position)
|
||||
elif is_building():
|
||||
_update_ghost()
|
||||
elif not _press_ref.is_empty() and not _press_was_drag \
|
||||
elif _pressing and not _press_ref.is_empty() and not _press_was_drag \
|
||||
and event.position.distance_to(_press_position) > DRAG_THRESHOLD:
|
||||
_begin_drag()
|
||||
return
|
||||
@@ -576,6 +711,12 @@ func _gui_input(event: InputEvent) -> void:
|
||||
context_menu_requested.emit()
|
||||
return
|
||||
|
||||
# 휠은 층을 오르내린다. 위로 굴리면 얕은 쪽, 아래로 굴리면 깊은 쪽.
|
||||
if event.pressed and event.button_index in [
|
||||
MOUSE_BUTTON_WHEEL_UP, MOUSE_BUTTON_WHEEL_DOWN]:
|
||||
current_zone += -1 if event.button_index == MOUSE_BUTTON_WHEEL_UP else 1
|
||||
return
|
||||
|
||||
if event.button_index != MOUSE_BUTTON_LEFT:
|
||||
return
|
||||
|
||||
@@ -587,6 +728,7 @@ func _gui_input(event: InputEvent) -> void:
|
||||
_press_position = event.position
|
||||
_press_ref = _pick_at(event.position)
|
||||
_press_was_drag = false
|
||||
_pressing = true
|
||||
else:
|
||||
if not _drag_ref.is_empty():
|
||||
_drop(event.position)
|
||||
@@ -595,6 +737,7 @@ func _gui_input(event: InputEvent) -> void:
|
||||
clicked.emit(event.position)
|
||||
_press_ref = {}
|
||||
_press_was_drag = false
|
||||
_pressing = false
|
||||
|
||||
|
||||
func _begin_drag() -> void:
|
||||
@@ -611,7 +754,7 @@ func _begin_drag() -> void:
|
||||
|
||||
func _update_drag(pos: Vector2) -> void:
|
||||
if _drag_node and is_instance_valid(_drag_node):
|
||||
_drag_node.position = pos
|
||||
_drag_node.position = to_world(pos)
|
||||
|
||||
# 생물은 칸을 차지하지 않으므로 놓일 자리를 미리 보여줄 것이 없다.
|
||||
if trash_rect.has_point(pos) or String(_drag_ref.kind) != "house":
|
||||
@@ -682,10 +825,11 @@ func _cancel_drag() -> void:
|
||||
|
||||
|
||||
## 만진 자리에서 거품이 퍼지는 연출.
|
||||
func pop_at(pos: Vector2) -> void:
|
||||
## 스크롤해도 물에 남아 있도록 화면이 아니라 바다에 붙인다.
|
||||
func pop_at(screen_pos: Vector2) -> void:
|
||||
var burst := CPUParticles2D.new()
|
||||
add_child(burst)
|
||||
burst.position = pos
|
||||
world.add_child(burst)
|
||||
burst.position = to_world(screen_pos)
|
||||
burst.emitting = true
|
||||
burst.one_shot = true
|
||||
burst.explosiveness = 1.0
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
[gd_scene load_steps=5 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"]
|
||||
[ext_resource type="Script" path="res://scenes/aquarium/backdrop.gd" id="4_backdrop"]
|
||||
|
||||
[node name="Aquarium" type="Control"]
|
||||
layout_mode = 3
|
||||
@@ -14,48 +15,23 @@ grow_vertical = 2
|
||||
clip_contents = true
|
||||
script = ExtResource("1_aq")
|
||||
|
||||
[node name="Ground" type="Node2D" parent="."]
|
||||
[node name="World" type="Node2D" parent="."]
|
||||
|
||||
[node name="Backdrop" type="Node2D" parent="World"]
|
||||
script = ExtResource("4_backdrop")
|
||||
|
||||
[node name="Ground" type="Node2D" parent="World"]
|
||||
script = ExtResource("2_ground")
|
||||
|
||||
[node name="Items" type="Node2D" parent="."]
|
||||
[node name="Items" type="Node2D" parent="World"]
|
||||
|
||||
[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="Water" type="Node2D" parent="World"]
|
||||
|
||||
[node name="Bubbles" type="CPUParticles2D" parent="."]
|
||||
[node name="Bubbles" type="CPUParticles2D" parent="World"]
|
||||
|
||||
[node name="Ghost" type="Node2D" parent="."]
|
||||
[node name="Ghost" type="Node2D" parent="World"]
|
||||
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="Node2D" parent="World"]
|
||||
|
||||
[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
|
||||
[node name="Locks" type="Node2D" parent="World"]
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
extends Node2D
|
||||
class_name Backdrop
|
||||
## 격자 위쪽의 먼 바다.
|
||||
##
|
||||
## 3/4 부감이라 화면 위쪽은 "더 먼 곳"이다. 거기까지 모래를 깔면
|
||||
## 바다가 아니라 사막처럼 보인다. 그래서 위쪽 띠를 물로 비우고,
|
||||
## 먼 물 → 흐릿한 능선 → 격자 순으로 이어 깊이를 만든다.
|
||||
##
|
||||
## 색은 Seabed와 같은 구역 램프를 쓰므로 좌우로 갈수록 자연스럽게 깊어진다.
|
||||
|
||||
var rect_size := Vector2(1280, 220)
|
||||
## 이 높이까지가 먼 바다. 그 아래부터 격자다.
|
||||
var grid_top := 46.0
|
||||
|
||||
## 능선이 격자선 위로 얼마나 솟는지.
|
||||
const RIDGE_HEIGHT := 9.0
|
||||
## 능선에 세우는 먼 실루엣 수(구역당).
|
||||
const SILHOUETTES_PER_ZONE := 4
|
||||
|
||||
|
||||
## 늘 같은 무늬가 나오도록 하는 해시. 다시 그릴 때마다 배경이 흔들리면 안 된다.
|
||||
func _hash(i: int, salt: int) -> float:
|
||||
var h := (i * 73856093) ^ (salt * 83492791)
|
||||
return float(absi(h) % 1000) / 1000.0
|
||||
|
||||
|
||||
## 스트립 가로 위치 f에서 먼 물의 색. 위쪽 끝(카메라에서 가장 먼 곳)이다.
|
||||
func _far_color(f: float) -> Color:
|
||||
return Catalog.ramp_color("tint", f).darkened(0.30)
|
||||
|
||||
|
||||
## 능선 언저리의 색. 모래가 물빛에 절반쯤 먹힌 상태.
|
||||
func _near_color(f: float) -> Color:
|
||||
var sand := Catalog.ramp_color("sand", f)
|
||||
var tint := Catalog.ramp_color("tint", f)
|
||||
# 깊을수록 모래가 더 많이 먹힌다.
|
||||
var haze := clampf(0.42 + Catalog.ramp_float("murk", f) * 0.55, 0.0, 0.95)
|
||||
return sand.lerp(tint, haze)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var cols := GameState.columns()
|
||||
if rect_size.x < 4.0 or cols <= 0 or grid_top < 4.0:
|
||||
return
|
||||
|
||||
_draw_open_water(cols)
|
||||
_draw_ridge(cols)
|
||||
_draw_silhouettes()
|
||||
|
||||
|
||||
## 먼 물. 열마다 사다리꼴을 그리고 정점 색을 먹여 가로·세로로 동시에 번지게 한다.
|
||||
func _draw_open_water(cols: int) -> void:
|
||||
var tw := rect_size.x / cols
|
||||
for col in cols:
|
||||
var f0 := float(col) / cols
|
||||
var f1 := float(col + 1) / cols
|
||||
var x0 := col * tw
|
||||
var x1 := x0 + tw + 1.0
|
||||
draw_polygon(
|
||||
PackedVector2Array([
|
||||
Vector2(x0, 0.0), Vector2(x1, 0.0),
|
||||
Vector2(x1, grid_top), Vector2(x0, grid_top),
|
||||
]),
|
||||
PackedColorArray([
|
||||
_far_color(f0), _far_color(f1),
|
||||
_near_color(f1), _near_color(f0),
|
||||
]))
|
||||
|
||||
|
||||
## 격자 바로 위에 얹히는 먼 바닥. 물결치는 윗선이 수평선 노릇을 한다.
|
||||
func _draw_ridge(cols: int) -> void:
|
||||
var base_y := grid_top - RIDGE_HEIGHT
|
||||
var steps := maxi(int(rect_size.x / 26.0), 12)
|
||||
|
||||
var points := PackedVector2Array()
|
||||
var colors := PackedColorArray()
|
||||
for i in steps + 1:
|
||||
var f := float(i) / steps
|
||||
var x := rect_size.x * f
|
||||
var y := base_y - sin(x / 96.0) * 3.4 - cos(x / 41.0) * 1.6
|
||||
points.append(Vector2(x, y))
|
||||
# 능선 마루는 조금 밝게. 빛을 받는 면처럼 보인다.
|
||||
colors.append(_near_color(f).lightened(0.06))
|
||||
for i in range(steps, -1, -1):
|
||||
var f := float(i) / steps
|
||||
points.append(Vector2(rect_size.x * f, grid_top + 1.0))
|
||||
colors.append(_near_color(f))
|
||||
draw_polygon(points, colors)
|
||||
|
||||
|
||||
## 능선 위에 서 있는 먼 것들. 무엇인지 알아볼 필요는 없고,
|
||||
## 시선이 닿는 곳에 뭔가 더 있다는 신호만 주면 된다.
|
||||
func _draw_silhouettes() -> void:
|
||||
var zones := Catalog.zone_count()
|
||||
var zone_w := rect_size.x / zones
|
||||
var base_y := grid_top - RIDGE_HEIGHT + 1.0
|
||||
|
||||
for z in zones:
|
||||
var tint := Catalog.ramp_color("tint", (z + 0.5) / float(zones))
|
||||
for i in SILHOUETTES_PER_ZONE:
|
||||
var seed_id := z * 31 + i
|
||||
var x := zone_w * (z + 0.12 + _hash(seed_id, 1) * 0.76)
|
||||
# 낮고 넓게. 가늘고 높으면 비석처럼 서 버려서 먼 풍경으로 안 읽힌다.
|
||||
# 띠가 아무리 높아도 실루엣은 능선 언저리에 낮게 깔린다.
|
||||
# 띠 높이에 그대로 비례시키면 감상 모드에서 산맥이 되어버린다.
|
||||
var band := minf(grid_top, 96.0)
|
||||
var h := band * (0.16 + _hash(seed_id, 2) * 0.26)
|
||||
var w := 5.0 + _hash(seed_id, 3) * 11.0
|
||||
var shade := tint.darkened(0.16)
|
||||
shade.a = 0.42
|
||||
|
||||
if _hash(seed_id, 4) < 0.5:
|
||||
# 먼 수초. 가늘게 위로 뻗는다.
|
||||
for blade in 5:
|
||||
var bx := x + (blade - 2.0) * w * 0.34
|
||||
var bh := h * (0.55 + _hash(seed_id, 10 + blade) * 0.7)
|
||||
draw_polygon(
|
||||
PackedVector2Array([
|
||||
Vector2(bx - w * 0.16, base_y),
|
||||
Vector2(bx + w * 0.16, base_y),
|
||||
Vector2(bx + _hash(seed_id, 20 + blade) * 4.0 - 2.0, base_y - bh),
|
||||
]),
|
||||
PackedColorArray([shade, shade, Color(shade, 0.0)]))
|
||||
else:
|
||||
# 먼 바위. 낮고 둥근 덩어리.
|
||||
var pts := PackedVector2Array()
|
||||
var sides := 7
|
||||
for s in sides + 1:
|
||||
var a := PI + PI * (float(s) / sides)
|
||||
pts.append(Vector2(x + cos(a) * w * 1.6, base_y + sin(a) * h * 0.62))
|
||||
pts.append(Vector2(x + w * 1.6, base_y))
|
||||
pts.append(Vector2(x - w * 1.6, base_y))
|
||||
draw_colored_polygon(pts, shade)
|
||||
@@ -0,0 +1 @@
|
||||
uid://be6jcjj4707om
|
||||
@@ -1,13 +1,16 @@
|
||||
extends Node2D
|
||||
class_name Seabed
|
||||
## 탑다운 바다의 바닥. 격자 칸마다 그 자리 구역의 모래색을 조금씩 달리해 깔고,
|
||||
## 격자가 깔리는 바닥. 칸마다 그 자리 구역의 모래색을 조금씩 달리해 깔고,
|
||||
## 그 위에 아주 옅은 격자선을 얹어 "어디에 놓을 수 있는지"가 보이게 한다.
|
||||
##
|
||||
## 위쪽 띠(grid_top 위)는 Backdrop이 먼 바다로 그리므로 여기서는 손대지 않는다.
|
||||
|
||||
var rect_size := Vector2(1280, 220)
|
||||
## 위쪽 정보 바 자리. 여기에는 격자를 긋지 않는다.
|
||||
var grid_top := 30.0
|
||||
## 이 높이 위쪽은 먼 바다다. 격자는 여기서부터 시작한다.
|
||||
var grid_top := 46.0
|
||||
|
||||
const GRID_LINE := Color(0, 0, 0, 0.13)
|
||||
## 격자선. 감상 모드에서는 옅게 눌러 그림이 앞에 오게 한다.
|
||||
var grid_alpha := 0.13
|
||||
const SPECK := Color(0, 0, 0, 0.06)
|
||||
|
||||
|
||||
@@ -28,13 +31,15 @@ func _draw() -> void:
|
||||
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))
|
||||
var f := (col + 0.5) / cols
|
||||
var base := Catalog.ramp_color("sand", f)
|
||||
var tint := Catalog.ramp_color("tint", f)
|
||||
|
||||
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)
|
||||
# 공기원근. 뒷줄(위쪽)일수록 물빛에 먹혀 멀어 보인다.
|
||||
c = c.lerp(tint, (1.0 - float(row) / maxf(rows - 1, 1)) * 0.22)
|
||||
var y := grid_top + row * th
|
||||
# 칸 경계에 실선이 비지 않도록 1px씩 겹쳐 그린다.
|
||||
draw_rect(Rect2(col * tw, y, tw + 1.0, th + 1.0), c)
|
||||
@@ -45,8 +50,9 @@ func _draw() -> void:
|
||||
var py := y + _hash(col, row, 20 + s) * th
|
||||
draw_circle(Vector2(px, py), 1.0 + _hash(col, row, 30 + s), SPECK)
|
||||
|
||||
var line := Color(0, 0, 0, grid_alpha)
|
||||
for col in range(1, cols):
|
||||
draw_line(Vector2(col * tw, grid_top), Vector2(col * tw, rect_size.y), GRID_LINE, 1.0)
|
||||
draw_line(Vector2(col * tw, grid_top), Vector2(col * tw, rect_size.y), 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)
|
||||
draw_line(Vector2(0, y), Vector2(rect_size.x, y), line, 1.0)
|
||||
|
||||
@@ -224,9 +224,15 @@ func _draw_glow() -> void:
|
||||
var pulse := 0.88 + sin(_t * 1.5 + _phase) * 0.12
|
||||
var bell := Vector2(0, -body_height * 0.45)
|
||||
|
||||
# 스스로 빛나는 것은 밤에 오히려 도드라진다. 바다 전체에 걸린 어둠을
|
||||
# 색과 후광으로 되밀어서, 어두울수록 또렷해 보이게 한다.
|
||||
var dark := 1.0 - DayCycle.light
|
||||
var lume := color.lightened(dark * 0.45)
|
||||
var halo := 0.08 + dark * 0.10
|
||||
|
||||
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))
|
||||
draw_circle(bell, r * (float(i) / 5.0) * (2.1 + dark * 0.8) * pulse,
|
||||
Color(lume.r, lume.g, lume.b, halo))
|
||||
|
||||
# 아래로 늘어진 촉수를 먼저 그려 갓 뒤로 보내둔다.
|
||||
for i in 4:
|
||||
@@ -237,7 +243,7 @@ func _draw_glow() -> void:
|
||||
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),
|
||||
draw_polyline(strand, Color(lume.r, lume.g, lume.b, 0.50),
|
||||
maxf(r * 0.16, 1.2), true)
|
||||
|
||||
# 반구형 갓.
|
||||
@@ -247,8 +253,8 @@ func _draw_glow() -> void:
|
||||
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))
|
||||
_fill(cap, lume, false)
|
||||
draw_circle(bell + Vector2(0, -r * 0.25), r * 0.32 * pulse, lume.lightened(0.45))
|
||||
|
||||
|
||||
## 바위·구조물. 아래가 넓고 위로 갈수록 좁아지는 덩어리.
|
||||
@@ -350,13 +356,19 @@ func _draw_wreck() -> void:
|
||||
var deck := -h * 0.52
|
||||
|
||||
# 선체. 아래는 둥글고 위는 갑판으로 열려 있다.
|
||||
var hull := PackedVector2Array()
|
||||
var steps := 12
|
||||
for i in steps + 1:
|
||||
var a := PI + PI * float(i) / steps
|
||||
hull.append(Vector2(cos(a) * w, sin(a) * h * 0.46))
|
||||
hull.append(Vector2(w + lean, deck))
|
||||
hull.append(Vector2(-w + lean * 0.4, deck * 0.72))
|
||||
# 갑판(왼→오른)에서 시작해 오른쪽 뱃전을 타고 내려간 뒤,
|
||||
# 둥근 배 밑을 훑어 왼쪽으로 돌아온다. 한 방향으로만 돌아야
|
||||
# 폴리곤이 제 몸을 가로지르지 않는다 -- 그러면 삼각분할이 실패한다.
|
||||
var belly := -h * 0.12
|
||||
var hull := PackedVector2Array([
|
||||
Vector2(-w + lean * 0.4, deck * 0.72),
|
||||
Vector2(w + lean, deck),
|
||||
Vector2(w, belly),
|
||||
])
|
||||
var steps := 10
|
||||
for i in range(1, steps + 1):
|
||||
var a := PI * float(i) / steps
|
||||
hull.append(Vector2(cos(a) * w, belly + sin(a) * h * 0.12))
|
||||
_fill(hull, color)
|
||||
|
||||
# 갈비뼈. 사이의 빈틈이 물고기가 숨는 자리로 읽힌다.
|
||||
|
||||
+68
-8
@@ -8,6 +8,7 @@ enum MenuId {
|
||||
SAVE_NOW = 2,
|
||||
RESET = 3,
|
||||
QUIT = 4,
|
||||
FULLSCREEN = 5,
|
||||
}
|
||||
|
||||
## 높이 프리셋. 메뉴에서 고른다.
|
||||
@@ -34,12 +35,17 @@ 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))
|
||||
_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))
|
||||
@@ -60,6 +66,7 @@ func _ready() -> void:
|
||||
# 격자는 정보 바 아래에서 시작해야 한다. 바 높이는 HUD가 재어서 알려준다.
|
||||
hud.reserved_top_changed.connect(func(px: float): aquarium.grid_top = px)
|
||||
aquarium.grid_top = hud.reserved_top()
|
||||
aquarium.current_zone = saved_layer
|
||||
|
||||
hud.shop_pressed.connect(_toggle_shop)
|
||||
hud.menu_pressed.connect(_open_menu)
|
||||
@@ -70,6 +77,11 @@ func _ready() -> void:
|
||||
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)
|
||||
|
||||
@@ -90,12 +102,23 @@ func _apply_dock() -> void:
|
||||
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
|
||||
win.size = Vector2i(usable.size.x, h)
|
||||
win.position = Vector2i(
|
||||
usable.position.x,
|
||||
usable.end.y - h if _edge_bottom else usable.position.y
|
||||
)
|
||||
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()
|
||||
@@ -104,6 +127,7 @@ func _apply_dock() -> void:
|
||||
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["edge_bottom"] = _edge_bottom
|
||||
SaveManager.window_state["height"] = _strip_height
|
||||
SaveManager.window_state["screen"] = _screen
|
||||
@@ -112,7 +136,8 @@ func _remember_window() -> void:
|
||||
## 클릭 통과를 켜면 안쪽 모서리 하나만 입력을 받는 영역으로 남긴다.
|
||||
## 빈 배열을 넘기면 통과가 꺼지고 창 전체가 다시 입력을 받는다.
|
||||
func _apply_passthrough() -> void:
|
||||
if not _click_through:
|
||||
# 감상 중에는 통과시키지 않는다. 화면을 다 덮은 채 클릭이 새 나가면 곤란하다.
|
||||
if _fullscreen or not _click_through:
|
||||
DisplayServer.window_set_mouse_passthrough(PackedVector2Array())
|
||||
return
|
||||
# 통과 영역은 창의 물리 픽셀 좌표로 준다.
|
||||
@@ -131,9 +156,40 @@ func _apply_passthrough() -> void:
|
||||
|
||||
# --- 게임 입력 ---
|
||||
|
||||
## 감상 모드로 들어가고 나온다. 창을 화면 전체로 키우고,
|
||||
## 바다는 아래에 깔고 그 위를 물기둥으로 채운다.
|
||||
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") and aquarium.is_building():
|
||||
aquarium.cancel_build()
|
||||
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()
|
||||
|
||||
|
||||
@@ -199,6 +255,7 @@ 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()
|
||||
for h in HEIGHTS:
|
||||
menu.add_radio_check_item(h.label, h.id)
|
||||
@@ -221,6 +278,7 @@ func _build_menu() -> void:
|
||||
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))
|
||||
_check(EDGE_BOTTOM, _edge_bottom)
|
||||
@@ -270,6 +328,8 @@ func _on_menu_id(id: int) -> void:
|
||||
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()
|
||||
|
||||
+22
-4
@@ -10,7 +10,7 @@ signal menu_pressed
|
||||
signal reserved_top_changed(px: float)
|
||||
|
||||
## 바 아래로 이만큼은 띄워야 첫 줄 칸이 바에 닿지 않는다.
|
||||
const BAR_CLEARANCE := 6.0
|
||||
const BAR_CLEARANCE := 22.0
|
||||
|
||||
@onready var control_bar: PanelContainer = $ControlBar
|
||||
@onready var bubbles_label: Label = %BubblesLabel
|
||||
@@ -20,6 +20,7 @@ const BAR_CLEARANCE := 6.0
|
||||
@onready var shop_button: Button = %ShopButton
|
||||
@onready var menu_button: Button = %MenuButton
|
||||
@onready var toast: Label = %Toast
|
||||
@onready var layer_bar: LayerBar = %LayerBar
|
||||
@onready var trash: PanelContainer = %Trash
|
||||
@onready var trash_label: Label = %TrashLabel
|
||||
|
||||
@@ -27,8 +28,13 @@ 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)
|
||||
# 바를 납작하게 눌러 둔다. 이 높이만큼 격자가 밀리고,
|
||||
# 남는 자리에 먼 바다 배경이 드러난다.
|
||||
var bar_style := UIStyle.panel(UIStyle.PANEL_BG, 11)
|
||||
bar_style.content_margin_top = 6
|
||||
bar_style.content_margin_bottom = 6
|
||||
control_bar.add_theme_stylebox_override("panel", bar_style)
|
||||
UIStyle.label(bubbles_label, 20, UIStyle.INK)
|
||||
UIStyle.label(bps_label, 13, UIStyle.INK_DIM)
|
||||
UIStyle.label(beauty_label, 13, UIStyle.GOLD)
|
||||
UIStyle.label(zone_label, 13, UIStyle.ACCENT)
|
||||
@@ -44,7 +50,10 @@ func _ready() -> void:
|
||||
shop_button.pressed.connect(func(): shop_pressed.emit())
|
||||
menu_button.pressed.connect(func(): menu_pressed.emit())
|
||||
|
||||
control_bar.resized.connect(func(): reserved_top_changed.emit(reserved_top()))
|
||||
control_bar.resized.connect(func():
|
||||
_place_toast()
|
||||
reserved_top_changed.emit(reserved_top()))
|
||||
_place_toast()
|
||||
|
||||
GameState.zone_unlocked.connect(func(_i: int): _refresh_zone_label())
|
||||
GameState.state_changed.connect(_refresh_zone_label)
|
||||
@@ -83,6 +92,15 @@ func _on_offline_earned(amount: float, seconds: int) -> void:
|
||||
], 4.5)
|
||||
|
||||
|
||||
## 알림은 정보 바 바로 아래 왼쪽에 붙인다.
|
||||
## 가운데에 두면 바가 길어졌을 때 글자가 겹쳐 둘 다 못 읽는다.
|
||||
func _place_toast() -> void:
|
||||
# 층 선택기가 왼쪽 아래를 쓰므로 알림은 그 폭만큼 비켜 앉힌다.
|
||||
toast.position = Vector2(
|
||||
control_bar.position.x + layer_bar.size.x + 14.0,
|
||||
control_bar.position.y + control_bar.size.y + 6.0)
|
||||
|
||||
|
||||
func show_toast(text: String, duration := 2.0) -> void:
|
||||
toast.text = text
|
||||
if _toast_tween and _toast_tween.is_valid():
|
||||
|
||||
+22
-12
@@ -1,6 +1,7 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/ui/hud.gd" id="1_hud"]
|
||||
[ext_resource type="Script" path="res://scenes/ui/layer_bar.gd" id="2_layers"]
|
||||
|
||||
[node name="HUD" type="Control"]
|
||||
layout_mode = 3
|
||||
@@ -16,7 +17,7 @@ script = ExtResource("1_hud")
|
||||
layout_mode = 1
|
||||
anchors_preset = 0
|
||||
offset_left = 12.0
|
||||
offset_top = 10.0
|
||||
offset_top = 7.0
|
||||
offset_right = 12.0
|
||||
offset_bottom = 10.0
|
||||
mouse_filter = 2
|
||||
@@ -80,6 +81,20 @@ layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
text = "···"
|
||||
|
||||
[node name="LayerBar" type="VBoxContainer" parent="."]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 2
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 12.0
|
||||
offset_top = -146.0
|
||||
offset_right = 86.0
|
||||
offset_bottom = -10.0
|
||||
grow_vertical = 0
|
||||
mouse_filter = 2
|
||||
script = ExtResource("2_layers")
|
||||
|
||||
[node name="Trash" type="PanelContainer" parent="."]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
@@ -106,16 +121,11 @@ 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
|
||||
layout_mode = 0
|
||||
offset_left = 100.0
|
||||
offset_top = 70.0
|
||||
offset_right = 700.0
|
||||
offset_bottom = 108.0
|
||||
mouse_filter = 2
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
autowrap_mode = 3
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
extends VBoxContainer
|
||||
class_name LayerBar
|
||||
## 층 선택기. 위에서 아래로 얕은 바다 → 열수구 순으로 늘어선다.
|
||||
##
|
||||
## 배치는 실제로는 옆으로 늘어서 있지만, 플레이어에게는 깊이가 위아래다.
|
||||
## 그래서 이 목록의 순서가 곧 깊이의 순서이고, 아래로 갈수록 어두워진다.
|
||||
|
||||
signal layer_picked(index: int)
|
||||
|
||||
## 잠긴 층에 붙는 표시. 이름을 감추는 것은 잠긴 층의 안내가 바다 쪽에 이미 뜨기 때문.
|
||||
const LOCKED_MARK := "🔒"
|
||||
|
||||
var _buttons: Array[Button] = []
|
||||
var _current := 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_theme_constant_override("separation", 3)
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
GameState.zone_unlocked.connect(func(_i: int): rebuild())
|
||||
GameState.reloaded.connect(rebuild)
|
||||
rebuild()
|
||||
|
||||
|
||||
## 층마다 단추 하나. 잠긴 층도 눌러서 조건을 보러 갈 수 있어야 한다.
|
||||
func rebuild() -> void:
|
||||
for child in get_children():
|
||||
child.queue_free()
|
||||
_buttons.clear()
|
||||
|
||||
for i in Catalog.zone_count():
|
||||
var zone := Catalog.zone(i)
|
||||
var open := GameState.is_zone_unlocked(i)
|
||||
|
||||
var b := Button.new()
|
||||
b.text = String(zone.name) if open else LOCKED_MARK
|
||||
b.custom_minimum_size = Vector2(74, 24)
|
||||
b.tooltip_text = String(zone.name) if open else "%s · 아직 잠겨 있어요" % zone.name
|
||||
b.focus_mode = Control.FOCUS_NONE
|
||||
UIStyle.style_button(b, 7)
|
||||
b.add_theme_font_size_override("font_size", 11)
|
||||
# 층 색을 단추에 그대로 입혀, 목록만 봐도 깊이가 읽히게 한다.
|
||||
b.add_theme_stylebox_override("normal", _skin(i, 0.62))
|
||||
b.add_theme_stylebox_override("hover", _skin(i, 0.80))
|
||||
b.add_theme_stylebox_override("pressed", _skin(i, 0.90))
|
||||
b.pressed.connect(func(): layer_picked.emit(i))
|
||||
add_child(b)
|
||||
_buttons.append(b)
|
||||
|
||||
set_current(_current)
|
||||
|
||||
|
||||
func _skin(index: int, alpha: float) -> StyleBoxFlat:
|
||||
var sb := UIStyle.row(Color(0, 0, 0, 0), 7)
|
||||
var c: Color = Catalog.zone(index).tint
|
||||
sb.bg_color = Color(c.r, c.g, c.b, alpha)
|
||||
sb.content_margin_top = 3
|
||||
sb.content_margin_bottom = 3
|
||||
return sb
|
||||
|
||||
|
||||
## 지금 보고 있는 층을 표시한다. 고른 것만 또렷하고 나머지는 물러난다.
|
||||
func set_current(index: int) -> void:
|
||||
_current = clampi(index, 0, _buttons.size() - 1)
|
||||
for i in _buttons.size():
|
||||
var here := i == _current
|
||||
_buttons[i].modulate = Color.WHITE if here else Color(1, 1, 1, 0.55)
|
||||
_buttons[i].add_theme_color_override(
|
||||
"font_color", UIStyle.INK if here else UIStyle.INK_DIM)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bdayflw4cn1co
|
||||
Reference in New Issue
Block a user