물고기가 집을 벗어나 층 전체를 헤엄치도록

집 둘레를 타원으로 돌던 것을 걷어냈다. 관계는 분명했지만 꾸미기 게임에는
어울리지 않았다 -- 말뚝에 묶인 것처럼 보였고, 집을 여러 채 지으면
화면이 회전목마 여러 대가 된다.

집은 정원을 세는 단위로만 남고, 헤엄치는 범위는 그 층의 격자 전체다.
제 집 앞에서 시작해 거기서부터 어디로든 간다.

알고리즘은 방향 흔들기다. 목표 지점을 따로 두지 않고 진행 각도만 매 틀
조금씩 흔든다. 그것만으로 충분히 어슬렁거리는 것처럼 보이고 목표를 찍는
방식보다 싸다. 테두리는 닿기 전에 미리 각도를 반사시킨다.

종류마다 다니는 방식을 갈랐다. 같은 식으로 움직이면 여러 마리가 한 화면에
있을 때 움직임이 무늬가 된다.
- 물고기: 빠르고 크게 돌아다닌다
- 게: 세로 성분을 12%로 눌러 바닥을 짚고 걷는다
- 해마: 가로 성분을 35%로 눌러 거의 제자리에서 오르내린다
- 새우: 1~3초마다 0.35초 동안 3.2배로 튄다
- 해파리: 물살에 밀리듯 아주 천천히

화면 위쪽(뒷줄)일수록 작고 흐리게 그려 앞뒤를 읽게 했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-02 14:49:20 +09:00
parent 1ca381922f
commit 375b340c47
4 changed files with 158 additions and 44 deletions
+2 -1
View File
@@ -67,7 +67,8 @@ tests/smoke.tscn/.gd 경제 로직 스모크 테스트
1. 바다에 있는 **생물**이 시간에 따라 알아서 **거품**을 모은다. 클릭으로 버는 길은 없다.
그래서 새로 시작하면 **항아리 집 한 채와 거품돌·구피 한 마리씩**이 이미 있고, 거품 20을 쥐고 시작한다.
2. 생물은 **집**에 산다. 집에는 두 갈래가 있다 — 돌·해초·산호처럼 **자연 서식지**(싸고 적게 들인다)와
2. 생물은 **집**에 산다. 다만 집은 몇 마리를 들일 수 있는지 세는 단위일 뿐,
헤엄치는 자리는 **층 전체**다. 집에는 두 갈래가 있다 — 돌·해초·산호처럼 **자연 서식지**(싸고 적게 들인다)와
항아리·난파선·석주처럼 **지어 올린 것**(비싸고 정원이 넓다). 더 키우려면 먼저 집을 지어야 한다.
집은 칸을 차지하고 거품을 벌지 않는다. 자리와 **아름다움**을 준다.
3. 생물을 들이면 초당 생산량과 아름다움이 오른다. 비싼 생물일수록 한 마리가 더 번다.
+29
View File
@@ -417,3 +417,32 @@ F11이나 메뉴로 창을 화면 전체로 키운다. 그냥 창만 키우면
`_make_fish()``fish``glow`만 궤도에 올리고 있었다. 고착생물이 생물 쪽에
있던 시절의 가드인데, 새로 넣은 게·해마·새우가 여기 걸려 제자리에 굳어 있었다.
고착생물이 전부 집으로 갔으므로 가드 자체를 걷어냈다.
## 생물의 움직임
처음에는 생물이 제 집 둘레를 타원으로 돌았다. 집과 생물의 관계는 분명해졌지만
꾸미기 게임에는 어울리지 않았다 — 물고기가 말뚝에 묶인 것처럼 보였고,
집을 여러 채 지으면 화면이 회전목마 여러 대가 된다.
지금은 **층 전체를 자유롭게 돌아다닌다.** 집은 정원을 세는 단위로만 남고,
헤엄치는 범위는 그 층의 격자 전체다(`_roam_bounds`). 제 집 앞에서 시작해
거기서부터 어디로든 간다.
**알고리즘은 방향 흔들기(wander)다.** 목표 지점을 따로 두지 않고 진행 각도만
매 틀 조금씩 흔든다. 그것만으로 충분히 어슬렁거리는 것처럼 보이고,
목표를 찍는 방식보다 훨씬 싸다. 테두리는 닿기 전에 미리 각도를 반사시킨다 --
벽에 붙어 비비는 것보다 자연스럽다.
**종류마다 다니는 방식이 다르다.** 같은 식으로 움직이면 여러 마리가 한 화면에
있을 때 움직임이 무늬가 되어 버린다.
| | 속도 | 노는 높이 | 특징 |
| --- | --- | --- | --- |
| 물고기 | 빠름 | 물속 전체 | 크게 돌아다닌다 |
| 게 | 보통 | 바닥께 | 세로 성분을 12%로 눌러 바닥을 짚고 걷는다 |
| 해마 | 느림 | 위아래 넓게 | 가로 성분을 35%로 눌러 거의 제자리에서 오르내린다 |
| 새우 | 느림 + 돌진 | 바닥께 | 1~3초마다 0.35초 동안 3.2배로 튄다 |
| 해파리 | 아주 느림 | 위쪽 | 물살에 밀리듯 |
**앞뒤 단서**: 화면 위쪽(뒷줄)일수록 조금 작고 흐리게 그린다(`_apply_depth`).
3/4 시점에서 깊이를 읽게 해주는 유일한 신호라 없으면 평면처럼 보인다.
+9 -11
View File
@@ -55,8 +55,6 @@ const LAYER_SLIDE := 0.32
## 높이를 전부 격자에 주면 칸만 거대해지고 보이는 열은 오히려 줄어든다.
const SHOWCASE_GRID_RATIO := 0.45
const DRAG_THRESHOLD := 5.0
## 물고기가 집을 한 바퀴 도는 속도(라디안/초). 위젯이라 눈에 거슬리지 않게 느리다.
const ORBIT_SPEED := 0.5
var _rng := RandomNumberGenerator.new()
var _lock_reasons := {} ## 구역 인덱스 -> 사유 Label
@@ -463,14 +461,9 @@ func _make_fish(entry: Dictionary) -> void:
items.add_child(node)
node.setup(d, "producer", -1, _fish_area(home, cell, slot), cell, _rng)
# 생물은 모두 집 둘레를 돈다. 고착생물(해초·거품돌)은 이제 집 쪽으로 옮겼으므로
# 여기 들어오는 것은 전부 헤엄치거나 걷는 해양동물이다.
var house_index := int(entry.house)
var cap: int = maxi(Catalog.capacity_of(String(GameState.houses[house_index].id)), 1)
# 집마다 도는 방향을 바꿔 여러 채가 한 화면에 있어도 단조롭지 않게 한다.
var dir := 1.0 if house_index % 2 == 0 else -1.0
node.set_orbit(_orbit_center(home, cell), _orbit_radius(home, cell),
TAU * float(slot) / float(cap), ORBIT_SPEED * dir)
# 집은 몇 마리를 들일 수 있는지 세는 단위일 뿐, 헤엄치는 자리는 층 전체다.
# 제 집 앞에서 시작해 거기서부터 어디로든 돌아다닌다.
node.set_roam(_roam_bounds(home, cell), _rng)
## 어느 생물이 어느 집에 사는지 그릴 때마다 정한다. 집은 정원만 주므로 배정을
@@ -530,6 +523,11 @@ func _orbit_center(home: Rect2, cell: Vector2) -> Vector2:
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)에 맞춰 잘랐는데, 층이 옆으로 늘어선 지금 그렇게 하면
## 뒤쪽 층의 생물이 전부 첫 층 오른쪽 끝으로 끌려와 제 층에서 사라진다.
@@ -755,7 +753,7 @@ func _begin_drag() -> void:
_drag_node = _node_for(_drag_ref)
# 끄는 동안에는 궤도를 멈춘다. 안 그러면 손을 따라오지 않고 제 길을 간다.
if _drag_node:
_drag_node.orbit_enabled = false
_drag_node.roam_enabled = false
_press_was_drag = true
drag_changed.emit(true)
+118 -32
View File
@@ -25,12 +25,19 @@ var body_height := 32.0
## 바닥에서 얼마나 떠 있는지(픽셀). 헤엄치는 것만 0보다 크다.
var hover := 0.0
## 집 둘레를 도는 것(fish/glow)만 궤도를 갖는다. 끌고 있는 동안에는 멈춘다.
var orbit_enabled := true
var _orbit_center := Vector2.ZERO
var _orbit_radius := Vector2.ZERO
var _orbit_speed := 0.0
var _orbit_phase := 0.0
## 헤엄치는 것만 돌아다닌다. 끌고 있는 동안에는 멈춘다.
var roam_enabled := true
var _roaming := false
var _roam := Rect2()
var _speed := 0.0
var _turn := 0.0
## 헤엄치는 세로 범위를 층 높이의 비율로. (위, 아래)
var _band := Vector2(0.1, 0.95)
var _heading := 0.0
## 0보다 크면 가끔 튀어 나간다(새우).
var _dart := 0.0
var _dart_timer := 0.0
var _dart_left := 0.0
var _t := 0.0
var _phase := 0.0
@@ -75,35 +82,114 @@ func set_area(area: Rect2, cell: Vector2, height_tiles: float) -> void:
queue_redraw()
## 집 둘레를 도는 궤도에 올린다.
## 원점이 궤도를 따라 움직이므로 Y 정렬이 알아서 앞뒤를 바꿔준다 —
## 뒤쪽으로 돌면 집에 가려 "들어간" 것처럼 보이고, 앞으로 나오면 다시 드러난다.
func set_orbit(center: Vector2, radius: Vector2, phase: float, speed: float) -> void:
_orbit_center = center
_orbit_radius = radius
_orbit_phase = phase
_orbit_speed = speed
# 궤도가 큰 움직임을 맡으므로 제자리 흔들림은 줄인다. 안 그러면 서로 싸운다.
_swim_range *= 0.35
_apply_orbit(0.0)
## 층 전체를 자유롭게 돌아다니게 한다.
## 집은 정원을 세는 단위일 뿐이고, 헤엄치는 자리는 층 전체다.
##
## 종류마다 다니는 방식이 다르다. 물고기는 크게 돌고, 게는 바닥을 짚고 걷고,
## 해마는 거의 제자리에서 오르내리고, 새우는 튀었다 멈춘다.
## 같은 식으로 움직이면 여러 마리가 한 화면에 있을 때 무늬가 되어 버린다.
func set_roam(area: Rect2, rng: RandomNumberGenerator) -> void:
_roam = area
_roaming = true
# 종류별 성격. speed는 초당 픽셀, turn은 초당 라디안.
match shape:
"crab":
# 바닥을 짚고 좌우로만. 위아래로는 거의 안 움직인다.
_speed = _roam.size.y * 0.22
_turn = 0.5
_band = Vector2(0.62, 1.0)
_dart = 0.0
"seahorse":
# 꼬리를 감고 선 채로 조금씩. 오르내림이 주된 움직임이다.
_speed = _roam.size.y * 0.10
_turn = 1.6
_band = Vector2(0.15, 0.9)
_dart = 0.0
"shrimp":
# 튀었다 멈춘다. 평소 속도는 느리고 가끔 확 나아간다.
_speed = _roam.size.y * 0.16
_turn = 1.1
_band = Vector2(0.5, 1.0)
_dart = 1.0
"glow":
# 해파리는 물살에 밀리듯 아주 천천히.
_speed = _roam.size.y * 0.13
_turn = 0.35
_band = Vector2(0.05, 0.75)
_dart = 0.0
_:
_speed = _roam.size.y * 0.38
_turn = 0.8
_band = Vector2(0.1, 0.95)
_dart = 0.0
# 돌아다니는 것이 큰 움직임을 맡으므로 제자리 흔들림은 줄인다. 안 그러면 서로 싸운다.
_swim_range *= 0.3
_speed *= rng.randf_range(0.8, 1.25)
_heading = rng.randf() * TAU
_dart_timer = rng.randf_range(0.5, 3.0)
_apply_depth()
func is_orbiting() -> bool:
return orbit_enabled and _orbit_radius != Vector2.ZERO
func is_roaming() -> bool:
return _roaming and roam_enabled and _roam.size.x > 1.0
func _apply_orbit(t: float) -> void:
var angle := _orbit_phase + t * _orbit_speed
var next := _orbit_center + Vector2(
cos(angle) * _orbit_radius.x, sin(angle) * _orbit_radius.y)
# 나아가는 쪽으로 몸을 돌린다.
if absf(next.x - position.x) > 0.01:
## 헤엄칠 수 있는 세로 범위. 종류마다 물속에서 노는 높이가 다르다.
func _band_range() -> Vector2:
return Vector2(
_roam.position.y + _roam.size.y * _band.x,
_roam.position.y + _roam.size.y * _band.y)
func _roam_step(delta: float) -> void:
# 방향을 조금씩 튼다. 목표를 따로 두지 않고 각도만 흔들어도
# 충분히 어슬렁거리는 것처럼 보인다.
_heading += (randf() - 0.5) * _turn * delta * 6.0
# 튀는 종류는 가끔 확 나아간다.
var boost := 1.0
if _dart > 0.0:
_dart_timer -= delta
if _dart_timer <= 0.0:
_dart_timer = randf_range(1.2, 3.5)
_dart_left = 0.35
if _dart_left > 0.0:
_dart_left -= delta
boost = 3.2
var dir := Vector2.RIGHT.rotated(_heading)
# 게는 좌우로만 걷는다. 위아래로 미끄러지면 헤엄치는 것처럼 보인다.
if shape == "crab":
dir.y *= 0.12
elif shape == "seahorse":
dir.x *= 0.35
var next := position + dir * _speed * boost * delta
# 테두리에 닿기 전에 미리 돌아선다. 벽에 붙어 비비는 것보다 자연스럽다.
var margin := _roam.size.y * 0.12
var band := _band_range()
if next.x < _roam.position.x + margin or next.x > _roam.end.x - margin:
_heading = PI - _heading
next.x = clampf(next.x, _roam.position.x + margin, _roam.end.x - margin)
if next.y < band.x or next.y > band.y:
_heading = -_heading
next.y = clampf(next.y, band.x, band.y)
if absf(next.x - position.x) > 0.02:
_facing = signf(next.x - position.x)
position = next
# 뒤(위쪽)로 돌수록 작고 흐려져 집 안으로 들어간 것처럼 보인다.
var depth := (sin(angle) + 1.0) * 0.5
scale = Vector2.ONE * lerpf(0.72, 1.0, depth)
modulate.a = lerpf(0.45, 1.0, depth)
_apply_depth()
## 뒷줄(화면 위쪽)일수록 멀리 있으므로 조금 작고 흐리게.
## 3/4 시점에서 앞뒤를 읽게 해주는 유일한 단서다.
func _apply_depth() -> void:
if _roam.size.y <= 1.0:
return
var f := clampf((position.y - _roam.position.y) / _roam.size.y, 0.0, 1.0)
scale = Vector2.ONE * lerpf(0.78, 1.06, f)
modulate.a = lerpf(0.62, 1.0, f)
## 그려지는 대략의 범위. 클릭으로 집을 때 쓴다.
@@ -116,12 +202,12 @@ func visual_rect() -> Rect2:
func _process(delta: float) -> void:
_t += delta
if is_orbiting():
_apply_orbit(_t)
if is_roaming():
_roam_step(delta)
if _swim_range != Vector2.ZERO:
var next_x := sin(_t * 0.55 + _phase) * _swim_range.x
# 방향을 튼 순간 몸도 돌아본다. 궤도를 돌 때는 궤도가 방향을 정한다.
if not is_orbiting() and absf(next_x - _swim.x) > 0.001:
if not is_roaming() and 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()