375b340c47
집 둘레를 타원으로 돌던 것을 걷어냈다. 관계는 분명했지만 꾸미기 게임에는 어울리지 않았다 -- 말뚝에 묶인 것처럼 보였고, 집을 여러 채 지으면 화면이 회전목마 여러 대가 된다. 집은 정원을 세는 단위로만 남고, 헤엄치는 범위는 그 층의 격자 전체다. 제 집 앞에서 시작해 거기서부터 어디로든 간다. 알고리즘은 방향 흔들기다. 목표 지점을 따로 두지 않고 진행 각도만 매 틀 조금씩 흔든다. 그것만으로 충분히 어슬렁거리는 것처럼 보이고 목표를 찍는 방식보다 싸다. 테두리는 닿기 전에 미리 각도를 반사시킨다. 종류마다 다니는 방식을 갈랐다. 같은 식으로 움직이면 여러 마리가 한 화면에 있을 때 움직임이 무늬가 된다. - 물고기: 빠르고 크게 돌아다닌다 - 게: 세로 성분을 12%로 눌러 바닥을 짚고 걷는다 - 해마: 가로 성분을 35%로 눌러 거의 제자리에서 오르내린다 - 새우: 1~3초마다 0.35초 동안 3.2배로 튄다 - 해파리: 물살에 밀리듯 아주 천천히 화면 위쪽(뒷줄)일수록 작고 흐리게 그려 앞뒤를 읽게 했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
851 lines
30 KiB
GDScript
851 lines
30 KiB
GDScript
extends Control
|
|
class_name Aquarium
|
|
## 화면 하단에 깔리는 3/4 부감 바다.
|
|
## 한 화면에 한 층만 담는다. 위층(얕은 바다)에서 아래층(열수구)으로 내려갈수록 깊어지고,
|
|
## 층은 왼쪽 아래 선택기나 휠·위아래 키로 갈아탄다.
|
|
##
|
|
## 층은 내부적으로 옆으로 늘어서 있고(층 n은 열 n*ZONE_COLUMNS부터), 화면은 그 중
|
|
## 한 구간만 비춘다. 갈아탈 때만 위아래로 미끄러뜨려 깊이가 세로로 읽히게 한다.
|
|
##
|
|
## 격자 칸을 차지하는 것은 집뿐이다. 생물은 칸이 아니라 집에 살기 때문에
|
|
## 그릴 때마다 구역별로 집에 차례로 배정해서 그 집 둘레에 세운다.
|
|
|
|
## 알림을 띄워야 할 때 (해금 실패, 건축 실패, 환불 등). main이 받아 HUD로 넘긴다.
|
|
signal message(text: String)
|
|
## 빈 곳을 눌렀을 때. 거품이 퍼지는 연출에만 쓴다 — 거품은 생물이 번다.
|
|
signal clicked(local_position: Vector2)
|
|
## 집 짓기 모드가 켜지거나 꺼졌을 때. 상점이 선택 상태를 표시하는 데 쓴다.
|
|
signal build_mode_changed(active: bool, id: String)
|
|
## 이미 놓인 것을 집었거나 놓았을 때. HUD가 휴지통을 보였다 감추는 데 쓴다.
|
|
signal drag_changed(dragging: bool)
|
|
## 물 위에서 우클릭. main이 메뉴를 연다.
|
|
signal context_menu_requested
|
|
## 보고 있는 층이 바뀌었을 때. 층 선택기가 표시를 맞춘다.
|
|
signal layer_changed(index: int)
|
|
|
|
## 드래그한 것을 여기에 떨구면 치운다. main이 HUD의 휴지통 위치를 넣어준다.
|
|
var trash_rect := Rect2()
|
|
|
|
@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
|
|
|
|
## 위쪽 이만큼은 정보 바가 앉는 자리라 격자에서 뺀다.
|
|
## 여기를 비워두지 않으면 첫 줄 칸이 바에 가려 보이지도, 눌리지도 않는다.
|
|
## 바 높이는 글꼴과 담긴 내용에 따라 달라지므로 main이 HUD에서 재어 넣어준다.
|
|
var grid_top := 30.0:
|
|
set(value):
|
|
if is_equal_approx(grid_top, value):
|
|
return
|
|
grid_top = value
|
|
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
|
|
|
|
var _rng := RandomNumberGenerator.new()
|
|
var _lock_reasons := {} ## 구역 인덱스 -> 사유 Label
|
|
var _base_material: ShaderMaterial
|
|
|
|
## 지금 짓는 중인 집의 id. 비어 있으면 짓는 중이 아니다.
|
|
var _building_id := ""
|
|
var _hover := Vector2i(-1, -1)
|
|
var _press_position := Vector2.ZERO
|
|
## 누르거나 끌고 있는 대상. {} 이면 없음. TileItem의 ref_* 를 그대로 담는다.
|
|
var _press_ref := {}
|
|
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:
|
|
_rng.randomize()
|
|
_base_material = preload("res://assets/shaders/water_material.tres")
|
|
items.y_sort_enabled = true
|
|
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)
|
|
GameState.placed.connect(_on_placed)
|
|
|
|
_on_layout_changed()
|
|
|
|
|
|
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 cell_size() -> Vector2:
|
|
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 _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(p.x / c.x)), int(floor((p.y - 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_rect().position.y + 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(id: String, hover: Vector2i) -> Vector2i:
|
|
var s := Catalog.size_of("house", id)
|
|
return hover - Vector2i((s.x - 1) / 2, (s.y - 1) / 2)
|
|
|
|
|
|
# --- 화면 구성 ---
|
|
|
|
## 크기가 바뀌거나 구역이 열리면 전부 다시 만든다.
|
|
## 스트립은 자주 바뀌지 않으므로 부분 갱신보다 통째로 다시 만드는 쪽이 단순하다.
|
|
func _on_layout_changed() -> void:
|
|
# 배경과 바닥은 화면이 아니라 바다 전체 너비로 그린다.
|
|
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_zone_labels()
|
|
_build_locks()
|
|
_rebuild_items()
|
|
|
|
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:
|
|
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("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 _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:
|
|
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()
|
|
# 새로 열린 곳은 보통 화면 밖이다. 바로 보여준다.
|
|
current_zone = index
|
|
|
|
|
|
|
|
|
|
# --- 집과 생물 그리기 ---
|
|
|
|
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
|
|
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.houses.size():
|
|
_make_house(i)
|
|
for entry in _assign_fish():
|
|
_make_fish(entry)
|
|
|
|
|
|
func _house_rect(index: int) -> Rect2:
|
|
var h := GameState.houses[index]
|
|
return cell_rect(int(h.col), int(h.row), Catalog.size_of("house", String(h.id)))
|
|
|
|
|
|
func _make_house(index: int) -> TileItem:
|
|
var id := String(GameState.houses[index].id)
|
|
var d := Catalog.house(id)
|
|
if d.is_empty():
|
|
return null
|
|
var node := TileItem.new()
|
|
items.add_child(node)
|
|
node.setup(d, "house", index, _house_rect(index), cell_size(), _rng)
|
|
return node
|
|
|
|
|
|
func _make_fish(entry: Dictionary) -> void:
|
|
var d := Catalog.producer(String(entry.id))
|
|
if d.is_empty():
|
|
return
|
|
var home := _house_rect(int(entry.house))
|
|
var cell := cell_size()
|
|
var slot := int(entry.slot)
|
|
|
|
var node := TileItem.new()
|
|
items.add_child(node)
|
|
node.setup(d, "producer", -1, _fish_area(home, cell, slot), cell, _rng)
|
|
|
|
# 집은 몇 마리를 들일 수 있는지 세는 단위일 뿐, 헤엄치는 자리는 층 전체다.
|
|
# 제 집 앞에서 시작해 거기서부터 어디로든 돌아다닌다.
|
|
node.set_roam(_roam_bounds(home, cell), _rng)
|
|
|
|
|
|
## 어느 생물이 어느 집에 사는지 그릴 때마다 정한다. 집은 정원만 주므로 배정을
|
|
## 저장할 필요가 없다. 구역마다 집을 격자 순서로, 생물을 카탈로그 순서로 세워 채운다.
|
|
## 돌려주는 것: [{id, house, slot}, ...]. 정원을 넘겨 사는 것들은 빠진다.
|
|
func _assign_fish() -> Array:
|
|
var out: Array = []
|
|
for z in Catalog.zone_count():
|
|
var homes := _homes_in_zone(z)
|
|
if homes.is_empty():
|
|
continue
|
|
var hi := 0
|
|
var used := 0
|
|
for d in Catalog.PRODUCERS:
|
|
if int(d.get("zone", 0)) != z:
|
|
continue
|
|
for n in GameState.fish_count(String(d.id)):
|
|
while hi < homes.size() \
|
|
and used >= Catalog.capacity_of(String(GameState.houses[homes[hi]].id)):
|
|
hi += 1
|
|
used = 0
|
|
if hi >= homes.size():
|
|
break
|
|
out.append({"id": String(d.id), "house": homes[hi], "slot": used})
|
|
used += 1
|
|
return out
|
|
|
|
|
|
## 그 구역의 집들을 격자 순서(왼쪽 위부터)로. 배정이 매번 같아야 그림이 안 튄다.
|
|
func _homes_in_zone(zone_index: int) -> Array[int]:
|
|
var homes: Array[int] = []
|
|
for i in GameState.houses.size():
|
|
if Catalog.zone_of_column(int(GameState.houses[i].col)) == zone_index:
|
|
homes.append(i)
|
|
homes.sort_custom(func(a: int, b: int) -> bool:
|
|
var ha := GameState.houses[a]
|
|
var hb := GameState.houses[b]
|
|
if int(ha.col) != int(hb.col):
|
|
return int(ha.col) < int(hb.col)
|
|
return int(ha.row) < int(hb.row))
|
|
return homes
|
|
|
|
|
|
## 물고기가 도는 궤도의 반지름. 집을 감싸되 3/4 부감이라 세로로 납작하다.
|
|
func _orbit_radius(home: Rect2, cell: Vector2) -> Vector2:
|
|
return Vector2(home.size.x * 0.5 + cell.x * 0.55, cell.y * 0.42)
|
|
|
|
|
|
## 궤도의 중심. 집 발치에 두되, 궤도가 통째로 바다 안에 들어오도록 밀어 넣는다.
|
|
## 중심을 옮기지 않고 매 프레임 위치만 자르면 물고기가 벽에 붙어 멈춘 것처럼 보인다.
|
|
func _orbit_center(home: Rect2, cell: Vector2) -> Vector2:
|
|
var r := _orbit_radius(home, cell)
|
|
var g := _home_bounds(home, cell)
|
|
return Vector2(
|
|
_squeeze(home.position.x + home.size.x * 0.5, g.position.x + r.x, g.end.x - r.x),
|
|
_squeeze(home.end.y - cell.y * 0.10,
|
|
g.position.y + cell.y * 0.9 + r.y, g.end.y - r.y))
|
|
|
|
|
|
## 그 집이 속한 층에서 헤엄칠 수 있는 범위. 층 전체를 쓰되 가장자리는 조금 남긴다.
|
|
func _roam_bounds(home: Rect2, cell: Vector2) -> Rect2:
|
|
return _home_bounds(home, cell).grow(-cell.x * 0.3)
|
|
|
|
|
|
## 그 집이 속한 층의 테두리. 생물을 여기 안에 가둔다.
|
|
## 예전에는 화면(grid_rect)에 맞춰 잘랐는데, 층이 옆으로 늘어선 지금 그렇게 하면
|
|
## 뒤쪽 층의 생물이 전부 첫 층 오른쪽 끝으로 끌려와 제 층에서 사라진다.
|
|
func _home_bounds(home: Rect2, cell: Vector2) -> Rect2:
|
|
var z := Catalog.zone_of_column(int(floor(home.position.x / maxf(cell.x, 1.0))))
|
|
var g := grid_rect()
|
|
var width := Catalog.ZONE_COLUMNS * cell.x
|
|
return Rect2(Vector2(z * width, g.position.y), Vector2(width, g.size.y))
|
|
|
|
|
|
## 범위가 뒤집힐 만큼 좁으면(작은 창) 가운데로 보낸다. clampf는 그때 엉뚱한 값을 준다.
|
|
func _squeeze(value: float, lo: float, hi: float) -> float:
|
|
if lo > hi:
|
|
return (lo + hi) * 0.5
|
|
return clampf(value, lo, hi)
|
|
|
|
|
|
## 집 둘레의 붙박이 자리 하나. 해초·거품돌처럼 바닥에 서는 것이 쓴다.
|
|
## 왼쪽 → 오른쪽 → 앞 순으로 돌고, 정원이 더 크면 한 겹 더 벌어진다.
|
|
func _fish_area(home: Rect2, cell: Vector2, slot: int) -> Rect2:
|
|
# 앞자리는 살짝 오른쪽으로 밀어 둔다. 바다 왼쪽 끝에 붙은 집은 왼쪽 자리가
|
|
# 안으로 밀려 들어오는데, 그때 앞자리와 겹쳐 한 마리처럼 보이기 때문이다.
|
|
const SPOTS := [Vector2(-0.5, -0.1), Vector2(0.5, -0.1), Vector2(0.12, 0.62)]
|
|
var spot: Vector2 = SPOTS[slot % SPOTS.size()]
|
|
var ring := float(slot / SPOTS.size())
|
|
|
|
var cx := home.position.x + home.size.x * 0.5
|
|
var x := cx + spot.x * (home.size.x + cell.x * 0.9) + ring * cell.x * 0.28
|
|
var base := home.end.y + spot.y * cell.y + ring * cell.y * 0.22
|
|
|
|
# 제 층 밖으로 새어 나가지 않게 잡아둔다.
|
|
var g := _home_bounds(home, cell)
|
|
x = clampf(x, g.position.x + cell.x * 0.5, g.end.x - cell.x * 0.5)
|
|
base = clampf(base, g.position.y + cell.y * 0.6, g.end.y)
|
|
return Rect2(Vector2(x - cell.x * 0.5, base - cell.y), cell)
|
|
|
|
|
|
func _on_placed(index: int) -> void:
|
|
# 집이 하나 늘면 생물 배정도 달라지므로 통째로 다시 그린다.
|
|
_rebuild_items()
|
|
var node := _node_for_house(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) -> Dictionary:
|
|
var best: TileItem = null
|
|
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(to_world(pos)) and it.position.y > best_y:
|
|
best_y = it.position.y
|
|
best = it
|
|
if best == null:
|
|
# 그림에 안 걸렸으면 발자국 칸으로 한 번 더 본다. 칸을 가진 것은 집뿐이다.
|
|
var cell := cell_at(pos)
|
|
var index := GameState.house_at(cell.x, cell.y)
|
|
if index < 0:
|
|
return {}
|
|
return {"kind": "house", "index": index, "id": String(GameState.houses[index].id)}
|
|
return {"kind": best.ref_kind, "index": best.ref_index, "id": best.ref_id}
|
|
|
|
|
|
func _node_for_house(index: int) -> TileItem:
|
|
for child in items.get_children():
|
|
var it := child as TileItem
|
|
if it and it.ref_kind == "house" and it.ref_index == index:
|
|
return it
|
|
return null
|
|
|
|
|
|
func _node_for(ref: Dictionary) -> TileItem:
|
|
if ref.is_empty():
|
|
return null
|
|
if String(ref.kind) == "house":
|
|
return _node_for_house(int(ref.index))
|
|
for child in items.get_children():
|
|
var it := child as TileItem
|
|
if it and it.ref_kind == "producer" and it.ref_id == String(ref.id):
|
|
return it
|
|
return null
|
|
|
|
|
|
# --- 집 짓기 모드 ---
|
|
|
|
func begin_build(id: String) -> void:
|
|
if Catalog.house(id).is_empty():
|
|
return
|
|
_cancel_drag()
|
|
_building_id = id
|
|
# 그 구역이 화면 밖이면 지을 자리를 찍을 수가 없다. 먼저 데려다 놓는다.
|
|
current_zone = Catalog.zone_of("house", id)
|
|
build_mode_changed.emit(true, id)
|
|
_update_ghost()
|
|
|
|
|
|
func cancel_build() -> void:
|
|
if _building_id.is_empty():
|
|
return
|
|
_building_id = ""
|
|
ghost.hide()
|
|
build_mode_changed.emit(false, "")
|
|
|
|
|
|
func is_building() -> bool:
|
|
return not _building_id.is_empty()
|
|
|
|
|
|
func _update_ghost() -> void:
|
|
if not is_building() or _hover.x < 0:
|
|
ghost.hide()
|
|
return
|
|
var anchor := _anchor_for(_building_id, _hover)
|
|
var span := Catalog.size_of("house", _building_id)
|
|
var ok := GameState.can_build_at(_building_id, anchor.x, anchor.y) \
|
|
and GameState.bubbles >= GameState.cost_of("house", _building_id)
|
|
ghost.show_at(cell_rect(anchor.x, anchor.y, span), ok)
|
|
|
|
|
|
func _try_build() -> void:
|
|
var id := _building_id
|
|
var anchor := _anchor_for(id, _hover)
|
|
|
|
if GameState.bubbles < GameState.cost_of("house", id):
|
|
message.emit("거품이 모자라요")
|
|
cancel_build()
|
|
return
|
|
if not GameState.can_build_at(id, anchor.x, anchor.y):
|
|
message.emit(_why_not(id, anchor))
|
|
return
|
|
|
|
GameState.build_house(id, anchor.x, anchor.y)
|
|
# 계속 지을 수 있으면 모드를 유지한다. 같은 집을 여러 채 깔 때 편하다.
|
|
if GameState.bubbles < GameState.cost_of("house", id):
|
|
cancel_build()
|
|
else:
|
|
_update_ghost()
|
|
|
|
|
|
func _why_not(id: String, anchor: Vector2i) -> String:
|
|
var item_zone := Catalog.zone_of("house", id)
|
|
if not GameState.is_zone_unlocked(item_zone):
|
|
return "%s 아직 잠겨 있어요" % Korean.i(String(Catalog.zone(item_zone).name))
|
|
var cells := GameState.footprint(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 not _drag_ref.is_empty():
|
|
_update_drag(event.position)
|
|
elif is_building():
|
|
_update_ghost()
|
|
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
|
|
|
|
if event is not InputEventMouseButton:
|
|
return
|
|
|
|
if event.button_index == MOUSE_BUTTON_RIGHT and event.pressed:
|
|
if is_building():
|
|
cancel_build()
|
|
elif not _drag_ref.is_empty():
|
|
_cancel_drag()
|
|
else:
|
|
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
|
|
|
|
if event.pressed:
|
|
_hover = cell_at(event.position)
|
|
if is_building():
|
|
_try_build()
|
|
return
|
|
_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)
|
|
elif not _press_was_drag:
|
|
# 끌지 않고 뗐으면 물을 만진 것으로 친다.
|
|
clicked.emit(event.position)
|
|
_press_ref = {}
|
|
_press_was_drag = false
|
|
_pressing = false
|
|
|
|
|
|
func _begin_drag() -> void:
|
|
if _press_ref.is_empty():
|
|
return
|
|
_drag_ref = _press_ref
|
|
_drag_node = _node_for(_drag_ref)
|
|
# 끄는 동안에는 궤도를 멈춘다. 안 그러면 손을 따라오지 않고 제 길을 간다.
|
|
if _drag_node:
|
|
_drag_node.roam_enabled = false
|
|
_press_was_drag = true
|
|
drag_changed.emit(true)
|
|
|
|
|
|
func _update_drag(pos: Vector2) -> void:
|
|
if _drag_node and is_instance_valid(_drag_node):
|
|
_drag_node.position = to_world(pos)
|
|
|
|
# 생물은 칸을 차지하지 않으므로 놓일 자리를 미리 보여줄 것이 없다.
|
|
if trash_rect.has_point(pos) or String(_drag_ref.kind) != "house":
|
|
ghost.hide()
|
|
return
|
|
|
|
var id := String(_drag_ref.id)
|
|
var anchor := _anchor_for(id, _hover)
|
|
var ok := GameState.can_build_at(id, anchor.x, anchor.y, int(_drag_ref.index))
|
|
ghost.show_at(cell_rect(anchor.x, anchor.y, Catalog.size_of("house", id)), ok)
|
|
|
|
|
|
func _drop(pos: Vector2) -> void:
|
|
var ref := _drag_ref
|
|
_drag_ref = {}
|
|
_drag_node = null
|
|
ghost.hide()
|
|
drag_changed.emit(false)
|
|
|
|
if trash_rect.has_point(pos):
|
|
if String(ref.kind) == "house":
|
|
_trash_house(int(ref.index))
|
|
else:
|
|
_release_fish(String(ref.id))
|
|
return
|
|
|
|
# 집은 끌어다 옮길 수 있다. 생물은 자기 집을 따라다니므로 제자리로 돌아간다.
|
|
if String(ref.kind) == "house":
|
|
var anchor := _anchor_for(String(ref.id), _hover)
|
|
if not GameState.move_house(int(ref.index), anchor.x, anchor.y):
|
|
message.emit("여기에는 놓을 수 없어요")
|
|
_rebuild_items()
|
|
|
|
|
|
func _trash_house(index: int) -> void:
|
|
if index < 0 or index >= GameState.houses.size():
|
|
_rebuild_items()
|
|
return
|
|
if not GameState.can_remove_house(index):
|
|
message.emit("살고 있는 생물이 갈 곳이 없어요")
|
|
_rebuild_items()
|
|
return
|
|
var house_name := String(Catalog.house(String(GameState.houses[index].id)).get("name", ""))
|
|
var refund := GameState.remove_house(index)
|
|
message.emit("%s 헐고 거품 %s를 돌려받았어요" % [
|
|
Korean.eul(house_name), NumberFormat.short(refund)])
|
|
|
|
|
|
func _release_fish(id: String) -> void:
|
|
if not GameState.can_release_fish(id):
|
|
message.emit("마지막 생물은 내보낼 수 없어요")
|
|
_rebuild_items()
|
|
return
|
|
var fish_name := String(Catalog.producer(id).get("name", ""))
|
|
var refund := GameState.release_fish(id)
|
|
message.emit("%s 내보내고 거품 %s를 돌려받았어요" % [
|
|
Korean.eul(fish_name), NumberFormat.short(refund)])
|
|
|
|
|
|
func _cancel_drag() -> void:
|
|
if _drag_ref.is_empty():
|
|
return
|
|
_drag_ref = {}
|
|
_drag_node = null
|
|
ghost.hide()
|
|
drag_changed.emit(false)
|
|
_rebuild_items()
|
|
|
|
|
|
## 만진 자리에서 거품이 퍼지는 연출.
|
|
## 스크롤해도 물에 남아 있도록 화면이 아니라 바다에 붙인다.
|
|
func pop_at(screen_pos: Vector2) -> void:
|
|
var burst := CPUParticles2D.new()
|
|
world.add_child(burst)
|
|
burst.position = to_world(screen_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)
|