먼 바다 배경·하루의 빛·층 구조·감상 모드

네 가지 작업이 같은 함수들을 겹쳐 고쳐서 한 커밋으로 묶는다.
쪼개려면 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:
2026-09-02 12:41:36 +09:00
parent 89069b06f0
commit 519e4980de
19 changed files with 942 additions and 170 deletions
+186 -42
View File
@@ -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
+14 -38
View File
@@ -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"]
+133
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://be6jcjj4707om
+15 -9
View File
@@ -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)
+24 -12
View File
@@ -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)
# 갈비뼈. 사이의 빈틈이 물고기가 숨는 자리로 읽힌다.