헤엄치는 것을 바닥에서 띄워 물속에 있게

바닥은 부감, 물고기는 측면이라 물고기가 모래를 기어다니는 것처럼 보였다.
물고기를 설치물과 똑같이 바닥에 붙여 놨으니 당연한 결과였다.

- position은 "모래 위 어디", hover는 "거기서 물기둥 얼마나 위"로 분리.
  그림자는 바닥에 남고 몸만 뜬다. 이 간격이 물속으로 읽히게 한다
- 물고기 1~2.9칸, 해파리 1.8~3.6칸, 해마 0.5~1.7칸, 게·새우는 거의 0
- 이동을 시작할 때마다 높이를 새로 뽑아 천천히 오르내린다
- 뜰 자리를 위해 헤엄치는 것의 바닥 범위를 앞쪽으로. 뒷줄일수록 덜 뜬다
- 뜬 높이만큼 그림자를 넓고 옅게

draw_set_transform()이 쌓이지 않고 갈아치우는 탓에, 형태 함수가 좌우를 뒤집으며
Vector2.ZERO로 다시 부를 때 hover가 통째로 날아가고 있었다. 오프셋을
_body_offset에 담고 _xform()을 거치도록 고쳤다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 16:03:28 +09:00
parent c9b9145615
commit 868ce08f90
2 changed files with 111 additions and 30 deletions
+23
View File
@@ -469,3 +469,26 @@ F11이나 메뉴로 창을 화면 전체로 키운다. 그냥 창만 키우면
사이에서 뽑되 짧은 쪽으로 치우치게 해서(`pow(randf(), 1.6)`), 몇 칸만 가는 일이
잦고 화면을 가로지르는 일은 가끔 있게 했다. 방향도 좌우 어느 쪽인지 매번 새로
던진다 -- 가던 쪽을 이어 가게 했더니 오히려 한 방향으로 흐르는 것처럼 보였다.
### 물기둥 높이 (hover)
바닥은 위에서 내려다보고 물고기는 옆에서 본 모습으로 그리다 보니, 물고기가 모래
위를 기어다니는 것처럼 보였다. 원인은 물고기를 설치물과 똑같은 규칙으로 -- 원점을
바닥에 닿는 지점에 두고 -- 놓았기 때문이다. 바닥에 붙어 있으면 바닥에 붙은 것으로
읽히는 게 당연하다.
그래서 헤엄치는 것에는 높이를 하나 더 뒀다. `position`은 여전히 "모래 위 어디"를
가리키고 그림자도 거기 남지만, 몸은 `hover`만큼 위에 그린다. 이 간격이 벌어질수록
물속에 떠 있는 것으로 읽힌다. 물고기는 칸 높이의 1~2.9배를 뜨고, 이동할 때마다 그
높이를 새로 뽑아 천천히 오르내린다. 게와 새우는 거의 뜨지 않고 해파리는 가장 높이
뜬다. 뜬 높이만큼 그림자는 넓고 옅게 번진다.
뜰 자리를 확보하려고 헤엄치는 것들의 바닥 범위를 앞쪽(화면 아래)으로 옮겼다.
뒷줄에 있으면 화면 위 여유가 없어 높이 뜰 수 없는데, 이건 원근으로도 맞는 얘기라
`_hover_ceiling()`으로 자리에 따라 알아서 낮아지게 뒀다.
**주의**: `draw_set_transform()`은 쌓이지 않고 통째로 갈아치운다. 형태 함수 안에서
좌우를 뒤집을 때 `Vector2.ZERO`로 다시 부르면 `hover` 오프셋이 통째로 날아간다.
실제로 이 때문에 물고기·게·해마·새우가 전부 바닥에 붙어 있었다. 그래서 오프셋을
`_body_offset`에 담아 두고 `_xform()`을 거치도록 했다 -- 형태 함수에서 직접
`draw_set_transform`을 부르면 안 된다.
+88 -30
View File
@@ -5,6 +5,11 @@ class_name TileItem
## 바닥은 위에서 내려다보지만 물건은 "서 있는" 모습으로 그린다.
## 노드의 원점은 발이 바닥에 닿는 지점(발자국 아래 가운데)이고,
## 그림은 거기서 위(-Y)로 자란다. 그래서 키가 큰 것은 뒷줄을 가린다.
##
## 헤엄치는 것은 여기에 hover가 하나 더 붙는다. 원점은 여전히 "모래 위 어디"를
## 가리키고 그림자도 거기 남지만, 몸은 hover만큼 위에 그린다. 이 둘을 나누지
## 않으면 -- 즉 물고기를 설치물과 똑같이 바닥에 붙여 놓으면 -- 모래 위를 기어
## 다니는 것처럼 보인다. 뜬 높이만큼 몸과 그림자가 벌어져야 물속으로 읽힌다.
## 아직 아트 에셋이 없어서 형태별로 직접 그린다.
## assets/art/ 에 스프라이트가 생기면 Sprite2D + offset 기반으로 바꾸면 된다.
@@ -23,7 +28,16 @@ var span := Vector2(34, 32)
## 서 있는 높이(픽셀).
var body_height := 32.0
## 바닥에서 얼마나 떠 있는지(픽셀). 헤엄치는 것만 0보다 크다.
## 바닥 위치(position)와는 따로 논다 -- position은 "모래 위 어디"이고
## hover는 "그 자리에서 물기둥 얼마나 위". 그림자는 바닥에, 몸은 hover만큼 위에
## 그려지므로, 둘 사이가 벌어질수록 물속에 떠 있는 것으로 읽힌다.
var hover := 0.0
var _cell_h := 26.0
## 떠오를 높이의 (최소, 최대). 칸 높이의 배수. 헤엄치는 것만 0이 아니다.
var _hover_range := Vector2.ZERO
var _hover_goal := 0.0
## 이번 틀에 몸을 그릴 기준 오프셋. _xform()이 여기에 얹는다.
var _body_offset := Vector2.ZERO
## 헤엄치는 것만 돌아다닌다. 끌고 있는 동안에는 멈춘다.
var roam_enabled := true
@@ -83,6 +97,7 @@ func setup(item_data: Dictionary, kind: String, index: int, area: Rect2, cell: V
func set_area(area: Rect2, cell: Vector2, height_tiles: float) -> void:
span = area.size
var cell_h := maxf(cell.y, 1.0)
_cell_h = cell_h
body_height = cell_h * height_tiles
position = Vector2(area.position.x + area.size.x * 0.5, area.end.y)
@@ -132,6 +147,7 @@ func set_roam(area: Rect2, rng: RandomNumberGenerator) -> void:
_turn_rate = 1.6
_accel = 3.0
_band = Vector2(0.62, 1.0)
_hover_range = Vector2(0.0, 0.05)
"seahorse":
# 꼬리를 감고 선 채로 아주 천천히 오르내린다.
_speed = h * 0.07
@@ -143,7 +159,8 @@ func set_roam(area: Rect2, rng: RandomNumberGenerator) -> void:
_rest_odds = 0.75
_turn_rate = 1.0
_accel = 1.5
_band = Vector2(0.15, 0.92)
_band = Vector2(0.55, 0.96)
_hover_range = Vector2(0.5, 1.7)
"shrimp":
# 거의 멈춰 있다가 짧게 튄다. 튀는 순간만 빠르다.
_speed = h * 0.35
@@ -155,7 +172,8 @@ func set_roam(area: Rect2, rng: RandomNumberGenerator) -> void:
_rest_odds = 0.9
_turn_rate = 4.0
_accel = 10.0
_band = Vector2(0.5, 1.0)
_band = Vector2(0.55, 1.0)
_hover_range = Vector2(0.0, 0.35)
"glow":
# 해파리는 물살에 밀리듯 길게 흐르고 가끔 멎는다.
_speed = h * 0.07
@@ -167,7 +185,8 @@ func set_roam(area: Rect2, rng: RandomNumberGenerator) -> void:
_rest_odds = 0.45
_turn_rate = 0.6
_accel = 0.9
_band = Vector2(0.05, 0.78)
_band = Vector2(0.42, 0.86)
_hover_range = Vector2(1.8, 3.6)
_:
# 물고기는 거의 좌우로만 다닌다. spread가 0.1이면 6도쯤 --
# 한 번 나아가는 동안 수심이 눈에 띄게 바뀌지 않을 만큼이다.
@@ -180,7 +199,8 @@ func set_roam(area: Rect2, rng: RandomNumberGenerator) -> void:
_rest_odds = 0.6
_turn_rate = 1.2
_accel = 1.4
_band = Vector2(0.1, 0.95)
_band = Vector2(0.45, 0.98)
_hover_range = Vector2(1.0, 2.9)
# 돌아다니는 것이 큰 움직임을 맡으므로 제자리 흔들림은 줄인다.
_swim_range *= 0.22
@@ -195,6 +215,12 @@ func set_roam(area: Rect2, rng: RandomNumberGenerator) -> void:
else:
_start_leg()
_travelled = _leg_dist * rng.randf()
# 집 앞에서 시작하므로 제 바닥 범위 밖일 수 있다.
var band := _band_range()
position.y = clampf(position.y, band.x, band.y)
_pick_hover()
hover = _hover_goal
_apply_depth()
@@ -230,6 +256,21 @@ func _start_leg() -> void:
_leg_dist = _roam.size.y * lerpf(_leg.x, _leg.y, t)
_leg_speed = randf_range(0.6, 1.5)
_desired = _new_heading()
_pick_hover()
## 이 자리에서 띄울 수 있는 한계. 층 위로 솟구쳐 나가면 안 된다.
## 뒷줄에 있을수록 화면 위 여유가 적으니 덜 뜬다 -- 멀리 있는 물고기가
## 낮게 떠 있는 것은 원근으로도 맞다.
func _hover_ceiling() -> float:
return maxf(position.y - _roam.position.y - _cell_h * 0.35, 0.0)
func _pick_hover() -> void:
if _hover_range == Vector2.ZERO:
return
_hover_goal = minf(_cell_h * randf_range(_hover_range.x, _hover_range.y),
_hover_ceiling())
func _start_rest() -> void:
@@ -281,6 +322,12 @@ func _roam_step(delta: float) -> void:
# 벽을 따라 하염없이 미끄러지지 않도록 이번 이동을 곧 끝낸다.
_leg_dist = minf(_leg_dist, _travelled + _roam.size.y * 0.8)
# 물기둥에서 오르내리는 것은 바닥 위치와 따로 간다. 아주 천천히 움직여야
# 헤엄쳐 올라가는 것처럼 보인다.
if _hover_range != Vector2.ZERO:
_hover_goal = minf(_hover_goal, _hover_ceiling())
hover = move_toward(hover, _hover_goal, _cell_h * 0.55 * delta)
# 목표 각도로 부드럽게 돌아서고, 속도는 서서히 붙고 서서히 죽는다.
_heading = rotate_toward(_heading, _desired, _turn_rate * delta)
_throttle = move_toward(_throttle, 0.0 if _resting else 1.0, _accel * delta)
@@ -330,17 +377,28 @@ func _process(delta: float) -> void:
queue_redraw()
func _draw() -> void:
# 바닥 그림자. 3/4 시점에서 물건이 땅에 붙어 있다고 알려주는 신호다.
# 떠 있을수록 넓고 옅어진다.
var lift := clampf(hover / maxf(span.y, 1.0), 0.0, 1.0)
draw_set_transform(Vector2(0, -span.y * 0.12), 0.0, Vector2(1.0, 0.34))
draw_circle(Vector2.ZERO, span.x * (0.30 + lift * 0.10),
Color(0, 0, 0, 0.20 - lift * 0.09))
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
## 몸을 그릴 기준 변환. draw_set_transform은 쌓이지 않고 통째로 갈아치우므로,
## 형태 함수 안에서 좌우를 뒤집거나 윗면을 눕힐 때도 반드시 _body_offset을 다시
## 실어야 한다. 빠뜨리면 hover가 날아가 헤엄치는 것이 모래 위에 붙어 버린다.
func _xform(offset := Vector2.ZERO, scale := Vector2.ONE) -> void:
draw_set_transform(_body_offset + offset, 0.0, scale)
# 헤엄치는 것은 제 칸 위를 오간다.
draw_set_transform(Vector2(_swim.x, -hover + _swim.y), 0.0, Vector2.ONE)
func _draw() -> void:
# 몸을 얼마나 띄워 그릴지. 형태 함수들이 좌우를 뒤집을 때도 이 값을 다시
# 실어야 하므로 멤버에 담아 둔다.
_body_offset = Vector2(_swim.x, -hover + _swim.y)
# 바닥 그림자. 3/4 시점에서 이것이 바닥 어디에 있는지 알려주는 유일한 신호다.
# 헤엄치는 것은 그림자만 모래에 남고 몸은 hover만큼 위로 떠오른다.
# 이 간격이 "바닥을 기어간다"와 "물속에 떠 있다"를 가른다.
# 높이 뜰수록 그림자는 넓고 옅게 번진다.
var lift := clampf(hover / maxf(_cell_h * 3.0, 1.0), 0.0, 1.0)
draw_set_transform(Vector2(0, -span.y * 0.12), 0.0, Vector2(1.0, 0.34))
draw_circle(Vector2.ZERO, span.x * (0.30 + lift * 0.34),
Color(0, 0, 0, 0.22 - lift * 0.13))
_xform()
match shape:
"plant": _draw_plant()
"fish": _draw_fish()
@@ -387,7 +445,7 @@ func _draw_plant() -> void:
func _draw_fish() -> void:
var l := span.x * 0.34
var wag := sin(_t * 4.0 + _phase) * l * 0.30
draw_set_transform(Vector2.ZERO, 0.0, Vector2(_facing, 1.0))
_xform(Vector2.ZERO, Vector2(_facing, 1.0))
# 꼬리지느러미.
_fill(PackedVector2Array([
@@ -421,7 +479,7 @@ func _draw_fish() -> void:
]), color.lightened(0.28))
draw_circle(Vector2(l * 0.60, -l * 0.14), maxf(l * 0.11, 1.2), Color(0.06, 0.07, 0.09))
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
_xform()
## 스스로 빛나며 떠 있는 것. 후광과 아래로 늘어진 촉수.
@@ -487,9 +545,9 @@ func _draw_rock() -> void:
# 꼭대기 면. 부감이라 윗면이 살짝 보이지만, 몸통보다 넓으면 안 된다.
var top_w := w * _rock_profile(1.0, 0.0)
draw_set_transform(Vector2(0, -h), 0.0, Vector2(1.0, 0.34))
_xform(Vector2(0, -h), Vector2(1.0, 0.34))
draw_circle(Vector2.ZERO, top_w * 0.92, color.lightened(0.16))
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
_xform()
## 바위의 높이 f(0=밑동, 1=꼭대기)에서 옆으로 벌어지는 정도.
@@ -543,9 +601,9 @@ func _draw_vessel() -> void:
# 입구. 물고기가 드나드는 곳이라 확실히 어둡게 파 둔다.
var mouth := w * _vessel_profile(1.0)
draw_set_transform(Vector2(0, -h), 0.0, Vector2(1.0, 0.38))
_xform(Vector2(0, -h), Vector2(1.0, 0.38))
draw_circle(Vector2.ZERO, mouth * 1.05, color.darkened(0.72))
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
_xform()
## 밑동은 좁고, 중간이 부르고, 목에서 다시 좁아진다.
@@ -619,9 +677,9 @@ func _draw_pillar() -> void:
draw_line(Vector2(-half, y), Vector2(half, y), seam, maxf(span.x * 0.028, 1.0))
# 꼭대기 면. 부감이라 윗면이 살짝 보인다.
draw_set_transform(Vector2(0, -h), 0.0, Vector2(1.0, 0.34))
_xform(Vector2(0, -h), Vector2(1.0, 0.34))
draw_circle(Vector2.ZERO, w * 1.02, color.lightened(0.18))
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
_xform()
## 바닥을 걷는 게. 옆으로 벌어진 몸에 다리가 뻗고 집게가 앞을 짚는다.
@@ -629,7 +687,7 @@ func _draw_pillar() -> void:
func _draw_crab() -> void:
var r := span.x * 0.26
var step := sin(_t * 3.0 + _phase)
draw_set_transform(Vector2.ZERO, 0.0, Vector2(_facing, 1.0))
_xform(Vector2.ZERO, Vector2(_facing, 1.0))
# 다리. 좌우 세 쌍이 번갈아 짚는다.
for side: float in [-1.0, 1.0]:
@@ -655,14 +713,14 @@ func _draw_crab() -> void:
]), color.lightened(0.1))
# 등딱지. 가로로 넓은 타원.
draw_set_transform(Vector2.ZERO, 0.0, Vector2(_facing * 1.35, 0.92))
_xform(Vector2.ZERO, Vector2(_facing * 1.35, 0.92))
draw_circle(Vector2.ZERO, r, color)
draw_set_transform(Vector2.ZERO, 0.0, Vector2(_facing, 1.0))
_xform(Vector2.ZERO, Vector2(_facing, 1.0))
draw_circle(Vector2(-r * 0.1, -r * 0.22), r * 0.55, color.lightened(0.16))
for side: float in [-1.0, 1.0]:
draw_circle(Vector2(r * 0.5, side * r * 0.34), maxf(r * 0.12, 1.2),
Color(0.06, 0.07, 0.09))
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
_xform()
## 선 채로 흔들리는 해마. 세로로 긴 S자가 곧 실루엣이다.
@@ -670,7 +728,7 @@ func _draw_seahorse() -> void:
var h := body_height * 0.9
var w := span.x * 0.16
var sway := sin(_t * 1.3 + _phase) * 0.22
draw_set_transform(Vector2.ZERO, 0.0, Vector2(_facing, 1.0))
_xform(Vector2.ZERO, Vector2(_facing, 1.0))
# 몸통. 아래로 갈수록 가늘어지며 꼬리가 안으로 말린다.
var left := PackedVector2Array()
@@ -703,14 +761,14 @@ func _draw_seahorse() -> void:
Vector2(bend - w * 2.0, -h * (f + 0.06)),
Vector2(bend - w * 0.8, -h * (f + 0.12)),
]), color.darkened(0.3))
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
_xform()
## 새우. 등이 굽고 더듬이가 길다. 꼬리를 튕겨 조금씩 물러난다.
func _draw_shrimp() -> void:
var l := span.x * 0.3
var flick := sin(_t * 4.5 + _phase)
draw_set_transform(Vector2.ZERO, 0.0, Vector2(_facing, 1.0))
_xform(Vector2.ZERO, Vector2(_facing, 1.0))
# 더듬이. 실루엣의 절반을 차지한다.
for side: float in [-0.35, 0.15]:
@@ -742,4 +800,4 @@ func _draw_shrimp() -> void:
tail + Vector2(-l * 0.35, l * 0.35),
]), color.darkened(0.25))
draw_circle(Vector2(l * 0.68, -l * 0.12), maxf(l * 0.1, 1.2), Color(0.06, 0.07, 0.09))
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
_xform()