Compare commits

..

8 Commits

Author SHA1 Message Date
ttipo 868ce08f90 헤엄치는 것을 바닥에서 띄워 물속에 있게
바닥은 부감, 물고기는 측면이라 물고기가 모래를 기어다니는 것처럼 보였다.
물고기를 설치물과 똑같이 바닥에 붙여 놨으니 당연한 결과였다.

- 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>
2026-09-02 16:03:28 +09:00
ttipo c9b9145615 나아가는 거리와 속도를 매번 새로 뽑도록
"몇 초 동안 나아간다"로 잡았더니 시간 범위가 좁고 속도가 고정이라 매번 비슷한
거리를 갔다. 짜인 것처럼 보였다.

- 시간이 아니라 거리로 이동을 끊는다. 거리는 층 높이의 0.3~6배(1~22칸)에서
  뽑되 pow(randf(), 1.6)으로 짧은 쪽에 치우치게
- 속도 배수도 이동마다 0.6~1.5배로 새로 뽑는다
- 좌우 어느 쪽으로 갈지도 매번 새로. 가던 쪽을 잇게 했더니 한 방향으로 흘렀다
- 위아래로 가는 것은 off_odds로 따로 굴린다 (물고기 15%, 게 4%)
- 벽에서 돌아설 때는 이번 이동을 곧 끝내서 벽을 따라 미끄러지지 않게

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 15:20:38 +09:00
ttipo 7c52c34874 헤엄치는 속도를 낮추고 물고기를 좌우로만
방치형이라 화면이 늘 눈에 들어와 있고, 빠른 움직임은 몇 분만 지나도 산만하다.

- 속도를 종류별로 절반 이하로 (물고기 0.40 -> 0.15, 게 0.22 -> 0.10 등)
- 멈추는 시간을 늘리고(물고기 0.25~1.1초 -> 1.5~4.5초) 확률도 0.55 -> 0.65
- 물고기가 축에서 벗어나는 각도를 20도 -> 6도로. 수직으로 오르내리지 않는다
- 대신 12% 확률로 크게 틀어 수심을 바꾸게 해서 한 높이에 줄서지 않도록
- 위아래 벽에서 곧장 꺾지 않고 가던 쪽으로 비스듬히 벗어나게

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 15:07:29 +09:00
ttipo 9eaaabd3b3 헤엄치는 움직임에 쉬는 틈과 방향 전환을
매 틀 각도를 무작위로 흔들던 것을 나아감/멈춤 두 상태로 바꿨다. 속도가 늘
일정해 쉬는 틈이 없었고, 층이 가로로 길고 세로로 짧아 무작위 방향이 위아래
벽에 계속 부딪히며 튀었다.

- 종류마다 기본 축(물고기·게는 좌우, 해마·해파리는 위아래)에서 조금만 벗어나게
- 나아갈 때마다 목표 각도를 정하고 rotate_toward로 부드럽게 돌아서게
- 속도를 서서히 붙이고 죽여서 멈추고 출발하는 것이 기계처럼 보이지 않게
- 벽 앞에서 튕기는 대신 미리 안쪽으로 돌아서게
- 새우의 튀어 나가는 동작은 짧은 CRUISE + 긴 REST로 흡수

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:59:26 +09:00
ttipo 375b340c47 물고기가 집을 벗어나 층 전체를 헤엄치도록
집 둘레를 타원으로 돌던 것을 걷어냈다. 관계는 분명했지만 꾸미기 게임에는
어울리지 않았다 -- 말뚝에 묶인 것처럼 보였고, 집을 여러 채 지으면
화면이 회전목마 여러 대가 된다.

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 14:49:20 +09:00
ttipo 1ca381922f 고착생물을 집으로, 생물은 해양동물만
거품돌·해초·산호·켈프·관벌레는 물고기가 깃드는 자리지 그 자체가
일꾼이 아니다. 실제로 물고기는 바위 틈과 해초 사이를 보금자리로 삼는다.
전부 집으로 옮기고, 생물에는 움직이는 해양동물만 남겼다.

- 집이 자연 서식지(rock/plant)와 지어 올린 것(vessel/wreck/pillar) 두 갈래가
  되면서 정원에 폭이 생겼다. 거품돌 1, 해초·산호 2, 켈프·관벌레 4,
  지어 올린 것은 표준 3.
- 빈 자리를 메우려 게·해마·새우를 넣어 층마다 생물 둘을 맞췄다.
  소라게·해마·열수구 새우. 형태도 셋 새로 그렸다.
- 형태 계통이 그대로 경계가 된다. 생물 fish/glow/crab/seahorse/shrimp,
  집 rock/plant/vessel/wreck/pillar. 겹치지 않는 것을 테스트가 지킨다.
- 예전에 생물로 사 둔 돌·해초는 같은 id의 집으로 그만큼 지어 준다.
  그냥 버리면 사 둔 것이 조용히 사라진다.

버그 둘

_fish_area/_orbit_center가 생물 자리를 grid_rect(화면 테두리)로 잘랐다.
가로로 이어 스크롤하던 시절에는 화면 = 바다여서 맞았지만, 층이 옆으로
늘어선 지금은 뒤쪽 층 생물이 전부 첫 층 오른쪽 끝으로 끌려와 제 층에서
사라졌다. _home_bounds()가 그 집이 속한 층의 테두리를 돌려준다.

_make_fish가 fish와 glow만 궤도에 올려, 새로 넣은 게·해마·새우가
제자리에 굳어 있었다. 고착생물이 전부 집으로 갔으므로 가드를 걷어냈다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 14:16:04 +09:00
ttipo 711cd453ff 게임 안에서 바다의 하루를 정할 수 있게
메뉴 > 시간 하위 메뉴에 세 갈래를 둔다.
- 실제 시각: 바깥 시계 그대로 (기본)
- 빠르게: 하루를 5분으로 압축해 돌린다
- 한 시각에 멈추기: 새벽/아침/낮/저녁/밤

프리셋의 이름과 시각을 DayCycle.FIXED_PRESETS 한곳에 같이 두어,
메뉴 항목과 실제로 적용되는 시각이 어긋날 수 없게 했다.
고른 값은 window_state의 time_mode/time_hour에 저장한다.

모드를 바꿀 때 지금 보이던 시각을 먼저 잡아 둔다.
mode를 먼저 바꾼 뒤 hour()를 부르면 이미 새 모드의 시계를 읽어
0시로 튀었다 -- 낮에 "빠르게"를 켜면 한밤중으로 떨어지는 버그였다.

명령줄 --hour=21은 그대로 살아 있고 FIXED 모드의 지름길이 되었다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 13:58:30 +09:00
ttipo 519e4980de 먼 바다 배경·하루의 빛·층 구조·감상 모드
네 가지 작업이 같은 함수들을 겹쳐 고쳐서 한 커밋으로 묶는다.
쪼개려면 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>
2026-09-02 12:41:36 +09:00
21 changed files with 1858 additions and 332 deletions
+17 -5
View File
@@ -22,15 +22,23 @@
| 동작 | 방법 |
| --- | --- |
| 물 만지기 | 빈 곳 좌클릭 (거품이 퍼진다 · 거품은 생물이 번다) |
| 층 오가기 | 왼쪽 아래 층 선택기 · 마우스 휠 · ↑↓ 키 |
| 감상 모드 | **F11** 또는 메뉴 · Esc나 F11로 돌아온다 |
| 집 짓기 | 상점 **집** 탭에서 고른 뒤 칸을 클릭 (우클릭·Esc로 취소) |
| 생물 들이기 | 상점 **생물** 탭에서 클릭 — 그 자리에서 바로 들어온다 (빈 자리 필요) |
생물은 물고기·게·해마·새우처럼 **움직이는 해양동물**뿐이다.
돌과 해초·산호·켈프·관벌레는 물고기가 깃드는 **서식지**라서 집 쪽에 있다.
| 집 옮기기 | 집을 끌어다 다른 칸에 놓기 (공짜 · 살던 생물도 따라간다) |
| 집 헐기 | 집을 끌어서 아래 휴지통에 (절반 환불 · 세입자가 갈 곳이 있어야 한다) |
| 생물 내보내기 | 생물을 끌어서 아래 휴지통에 (절반 환불 · 마지막 한 마리는 안 된다) |
| 상점 열기 | 왼쪽 위 **상점** (패널은 오른쪽에 열린다) |
| 메뉴 | **···** 버튼 또는 물 위 우클릭 |
메뉴: 항상 위에 표시 · 클릭 통과 · 높이(낮게/보통/높게) · 위아래 도킹 · 화면 선택(모니터가 여러 대일 때) · 지금 저장 · 처음부터 다시 · 종료
메뉴: 항상 위에 표시 · 클릭 통과 · 전체화면으로 감상 · **시간** · 높이(낮게/보통/높게) · 위아래 도킹 · 화면 선택(모니터가 여러 대일 때) · 지금 저장 · 처음부터 다시 · 종료
**시간** 하위 메뉴에서 바다의 하루를 정한다 — 실제 시각(기본), 빠르게(하루 5분),
또는 새벽/아침/낮/저녁/밤 한 시각에 멈추기. 고른 값은 저장된다.
**클릭 통과**를 켜면 클릭이 바탕화면으로 지나가고, 안쪽 모서리(84×56px)만 계속 눌린다.
이 모서리를 눌러 메뉴로 돌아와 통과를 끌 수 있다. (아래 도킹이면 오른쪽 위, 위 도킹이면 오른쪽 아래)
@@ -41,11 +49,12 @@ project.godot 창 설정(투명·테두리 없음·항상 위·1:1 픽
run.ps1 / test.ps1 게임 실행 / 스모크 테스트 (find-godot.ps1이 Godot을 찾아준다)
autoload/
catalog.gd 밸런스 데이터 전부. 아이템 추가는 여기서만.
day_cycle.gd 실제 시각에 맞춘 하루의 빛 (새벽/아침/낮/저녁/밤)
game_state.gd 보유 자원·배치 상태와 파생 수치(bps, 아름다움, 아름다움 배율)
save_manager.gd 저장/불러오기, 30초 자동 저장, 오프라인 보상, 창 위치
scenes/
main.tscn/.gd 도킹(폭 전체·가장자리 스냅)·클릭 통과·메뉴와 화면 조립
aquarium/ 물빛 셰이더, 모래 바닥, 물속 오브젝트
aquarium/ 먼 바다 배경, 격자 바닥, 물빛 셰이더, 물속 오브젝트
ui/ 왼쪽 위 정보 바, 오른쪽 상점 패널
scripts/
korean.gd 받침을 보고 을/를·이/가를 고른다
@@ -58,7 +67,9 @@ tests/smoke.tscn/.gd 경제 로직 스모크 테스트
1. 바다에 있는 **생물**이 시간에 따라 알아서 **거품**을 모은다. 클릭으로 버는 길은 없다.
그래서 새로 시작하면 **항아리 집 한 채와 거품돌·구피 한 마리씩**이 이미 있고, 거품 20을 쥐고 시작한다.
2. 생물은 **집**에 산다. 한 채가 3마리를 들이므로, 더 키우려면 먼저 집을 지어야 한다.
2. 생물은 **집**에 산다. 다만 집은 몇 마리를 들일 수 있는지 세는 단위일 뿐,
헤엄치는 자리는 **층 전체**다. 집에는 두 갈래가 있다 — 돌·해초·산호처럼 **자연 서식지**(싸고 적게 들인다)와
항아리·난파선·석주처럼 **지어 올린 것**(비싸고 정원이 넓다). 더 키우려면 먼저 집을 지어야 한다.
집은 칸을 차지하고 거품을 벌지 않는다. 자리와 **아름다움**을 준다.
3. 생물을 들이면 초당 생산량과 아름다움이 오른다. 비싼 생물일수록 한 마리가 더 번다.
4. 아름다움은 다시 전체 생산량에 `1 + sqrt(아름다움) × 0.02` 배율로 되돌아온다.
@@ -68,7 +79,7 @@ tests/smoke.tscn/.gd 경제 로직 스모크 테스트
## 격자와 배치
바다는 **3/4 부감 격자**다. 구역당 9열 × 6행, 전체 45× 6행 = 270칸.
바다는 **3/4 부감 격자**다. **한 화면에 한 층**만 담기고, 층마다 36× 6행 = 216칸이다.
바닥은 위에서 내려다보지만 그 위에 선 것은 **서 있는 모습**으로 그린다.
러스티의 농장과 같은 방식이고, 완전한 탑다운과는 다르다.
@@ -108,7 +119,8 @@ tests/smoke.tscn/.gd 경제 로직 스모크 테스트
## 구역
가로가 다섯 구역으로 나뉘어 있고, **왼쪽에서 오른쪽으로 갈수록 깊어진다.**
다섯 층이 있고, **위층에서 아래층으로 내려갈수록 깊어진다.**
왼쪽 아래 층 선택기, 마우스 휠, ↑↓ 키로 오간다.
처음에는 맨 왼쪽 얕은 바다만 열려 있고 나머지는 어둡게 덮여 있다.
잠긴 구역을 클릭하면 해금 조건과 남은 양이 그 자리에 뜬다.
+22 -1
View File
@@ -12,6 +12,11 @@ uniform float murk_right : hint_range(0.0, 1.0) = 0.30;
uniform float x_offset = 0.0;
uniform vec2 rect_size = vec2(300.0, 220.0);
uniform float caustic_strength : hint_range(0.0, 0.4) = 0.13;
// 수면에서 내려오는 빛의 양. 밤에는 0에 가까워져 물결도 빛줄기도 잦아든다.
uniform float sun : hint_range(0.0, 1.0) = 1.0;
// 수면에서 내려오는 빛줄기. 얕은 구역에서만 뚜렷하고 깊어질수록 사라진다.
uniform float ray_left : hint_range(0.0, 1.0) = 0.7;
uniform float ray_right : hint_range(0.0, 1.0) = 0.6;
void fragment() {
vec2 px = vec2(x_offset, 0.0) + UV * rect_size;
@@ -22,11 +27,27 @@ void fragment() {
vec2 p = px / 78.0;
float c = sin(p.x + TIME * 0.45) * sin(p.y * 1.3 - TIME * 0.31);
c += sin(p.x * 1.7 - TIME * 0.38) * sin(p.y * 0.8 + TIME * 0.44);
float caustic = pow(max(c * 0.5 + 0.5, 0.0), 3.0);
float caustic = pow(max(c * 0.5 + 0.5, 0.0), 3.0) * sun;
// 밝은 물결 자리는 물이 얇아 보이도록 살짝 걷어낸다.
float alpha = clamp(murk - caustic * caustic_strength * 0.5, 0.0, 1.0);
vec3 col = tint + caustic * caustic_strength;
// 비스듬히 내려오는 빛줄기. 3/4 시점이라 화면 위쪽(먼 곳)에서 가장 밝고
// 앞으로 올수록 흩어진다. 물속임을 알려주는 가장 강한 신호다.
float rs = mix(ray_left, ray_right, UV.x) * sun;
if (rs > 0.001) {
// 거의 세로로 서되 살짝 기운다. y 계수를 크게 잡으면 화면이 높을 때
// 무늬가 가로로 누워버려 빛줄기로 안 보인다.
float g = px.x * 0.010 + px.y * 0.0035;
float r = (sin(g + TIME * 0.09) * 0.5 + 0.5)
* (sin(g * 2.3 - TIME * 0.06) * 0.5 + 0.5);
float rays = pow(r, 2.6) * rs * smoothstep(1.0, 0.02, UV.y);
// 빛줄기는 물을 걷어내는 게 아니라 물 속에 떠 있는 밝은 기둥이다.
// 알파를 낮추면 바닥만 밝아지고 물기둥은 텅 빈 채로 남는다.
col += rays * 0.38;
alpha = clamp(alpha + rays * 0.20, 0.0, 1.0);
}
COLOR = vec4(col, alpha);
}
+78 -54
View File
@@ -4,13 +4,14 @@ extends Node
# --- 격자 ---
## 바다는 3/4 부감 격자다. 바닥은 위에서 내려다보고, 그 위에 놓인 것은 서 있는 모습으로 그린다.
## 세로 행 수는 고정이고, 칸의 픽셀 크기는 스트립 크기에 맞춰 늘어난다.
## 이렇게 해야 창 높이를 바꿔도 저장해 둔 배치가 그대로 유효하다.
## 한 화면에 한 구역만 담기므로 열 수는 화면 가로를 나눠 갖는다.
const GRID_ROWS := 6
## 구역 하나가 차지하는 열 수. 구역당 GRID_ROWS * ZONE_COLUMNS 칸이 배치 공간이 된다.
const ZONE_COLUMNS := 9
## 스트립(가로로 길고 세로로 짧은 창)에서 칸이 지나치게 납작해지지 않는 수를 고른다.
const ZONE_COLUMNS := 36
## 구역. 왼쪽(얕은 바다)에서 오른쪽(열수구)으로 갈수록 깊어진다.
## 구역 = 층. 위층(얕은 바다)에서 아래층(열수구)으로 내려갈수록 깊어진다.
## 한 번에 한 층만 화면에 담기고, 층 사이는 왼쪽 층 선택기로 오간다.
## unlock_cost : 해금에 드는 거품
## require_beauty: 바로 앞 구역에 쌓인 아름다움이 이만큼은 되어야 한다.
## "앞 구역을 충분히 꾸며야 다음이 열린다"는 규칙.
@@ -53,77 +54,90 @@ const ZONES := [
## height: 칸 높이의 몇 배로 서 있는지
const PRODUCERS := [
# --- 얕은 바다 ---
{
"id": "bubble_stone", "name": "거품돌", "zone": 0, "shape": "rock", "height": 0.75,
"desc": "바닥에서 조용히 거품을 뿜는 돌.",
"color": Color(0.62, 0.68, 0.72), "base_cost": 15.0, "bps": 0.2, "beauty": 1.0,
},
{
"id": "guppy", "name": "구피", "zone": 0, "shape": "fish", "height": 0.8,
"desc": "집 둘레를 쉬지 않고 맴돈다.",
"color": Color(0.98, 0.72, 0.32), "base_cost": 40.0, "bps": 0.5, "beauty": 2.0,
"color": Color(0.98, 0.72, 0.32), "base_cost": 15.0, "bps": 0.35, "beauty": 2.0,
},
{
"id": "seaweed", "name": "해초", "zone": 0, "shape": "plant", "height": 0.95,
"desc": "물살에 흔들리는 초록 잎.",
"color": Color(0.35, 0.72, 0.42), "base_cost": 110.0, "bps": 1.0, "beauty": 3.0,
"id": "hermit_crab", "name": "소라게", "zone": 0, "shape": "crab", "height": 0.7,
"desc": "빈 껍데기를 지고 바닥을 훑는다.",
"color": Color(0.86, 0.46, 0.34), "base_cost": 130.0, "bps": 1.4, "beauty": 4.0,
},
# --- 산호초 ---
{
"id": "clownfish", "name": "흰동가리", "zone": 1, "shape": "fish", "height": 0.85,
"desc": "자리를 벗어나지 않고 맴돈다.",
"color": Color(0.96, 0.55, 0.18), "base_cost": 1200.0, "bps": 8.0, "beauty": 8.0,
"desc": "집을 벗어나지 않고 맴돈다.",
"color": Color(0.96, 0.55, 0.18), "base_cost": 1200.0, "bps": 9.0, "beauty": 8.0,
},
{
"id": "coral", "name": "산호", "zone": 1, "shape": "plant", "height": 0.85,
"desc": "느리게 자라지만 구역을 화사하게 만든다.",
"color": Color(0.95, 0.45, 0.62), "base_cost": 13000.0, "bps": 47.0, "beauty": 20.0,
"id": "seahorse", "name": "해마", "zone": 1, "shape": "seahorse", "height": 0.95,
"desc": "꼬리를 감고 선 채로 흔들린다.",
"color": Color(0.95, 0.78, 0.36), "base_cost": 14000.0, "bps": 55.0, "beauty": 20.0,
},
# --- 켈프 숲 ---
{
"id": "kelp", "name": "켈프", "zone": 2, "shape": "plant", "height": 1.35,
"desc": "수면을 향해 길게 뻗는 갈색 잎.",
"color": Color(0.34, 0.42, 0.15), "base_cost": 150000.0, "bps": 260.0, "beauty": 45.0,
"id": "turtle", "name": "바다거북", "zone": 2, "shape": "fish", "height": 0.75,
"desc": "느긋하게 제 집을 돈다.",
"color": Color(0.40, 0.60, 0.36), "base_cost": 170000.0, "bps": 300.0, "beauty": 45.0,
},
{
"id": "turtle", "name": "바다거북", "zone": 2, "shape": "fish", "height": 0.75,
"desc": "느긋하게 제 자리를 지킨다.",
"color": Color(0.40, 0.60, 0.36), "base_cost": 2000000.0, "bps": 1400.0, "beauty": 90.0,
"id": "snow_crab", "name": "대게", "zone": 2, "shape": "crab", "height": 0.85,
"desc": "긴 다리로 숲 바닥을 짚고 다닌다.",
"color": Color(0.85, 0.40, 0.30), "base_cost": 2100000.0, "bps": 1500.0, "beauty": 90.0,
},
# --- 심해 ---
{
"id": "jellyfish", "name": "해파리", "zone": 3, "shape": "glow", "height": 1.1,
"desc": "은은하게 빛나며 떠다닌다.",
"color": Color(0.72, 0.60, 0.95), "base_cost": 26000000.0, "bps": 7800.0, "beauty": 170.0,
"color": Color(0.72, 0.60, 0.95), "base_cost": 26000000.0, "bps": 8200.0, "beauty": 170.0,
},
{
"id": "squid", "name": "대왕오징어", "zone": 3, "shape": "fish", "height": 0.95,
"desc": "어둠 속에서 잠깐 모습을 드러낸다.",
"color": Color(0.62, 0.32, 0.42), "base_cost": 400000000.0, "bps": 44000.0, "beauty": 320.0,
"color": Color(0.62, 0.32, 0.42), "base_cost": 400000000.0, "bps": 46000.0, "beauty": 320.0,
},
# --- 열수구 ---
{
"id": "tubeworm", "name": "관벌레", "zone": 4, "shape": "plant", "height": 1.1,
"desc": "햇빛 없이 열기만으로 살아간다.",
"color": Color(0.92, 0.28, 0.30), "base_cost": 7e9, "bps": 260000.0, "beauty": 600.0,
"id": "vent_shrimp", "name": "열수구 새우", "zone": 4, "shape": "shrimp", "height": 0.7,
"desc": "끓는 물 언저리에 떼로 붙어 산다.",
"color": Color(0.94, 0.62, 0.52), "base_cost": 7e9, "bps": 275000.0, "beauty": 600.0,
},
{
"id": "anglerfish", "name": "심해 아귀", "zone": 4, "shape": "glow", "height": 1.0,
"desc": "칠흑 속에서 등불을 흔든다.",
"color": Color(0.95, 0.85, 0.45), "base_cost": 1.2e11, "bps": 1600000.0, "beauty": 1100.0,
"color": Color(0.95, 0.85, 0.45), "base_cost": 1.2e11, "bps": 1700000.0, "beauty": 1100.0,
},
]
## 집 — 생물이 사는 곳. 격자 칸을 차지하는 것은 이쪽뿐이다.
## 한 채당 HOUSE_CAPACITY마리를 들이고, 같은 종류라도 몇 채든 지을 수 있다.
## 자연 서식지(돌·해초·산호·켈프·관벌레)와 지어 올린 것(항아리·난파선·석주…)이 함께 들어간다.
## 물고기가 바위 틈이나 해초 사이를 보금자리로 삼는 것을 그대로 옮겼다.
## 자연 서식지는 싸지만 적게 들이고, 지어 올린 것은 비싼 대신 정원이 넓다.
##
## 한 채당 capacity마리를 들이고, 같은 종류라도 몇 채든 지을 수 있다.
## 지을수록 값이 오르는 것은 생물과 같다(COST_GROWTH).
## 집 자체는 거품을 벌지 않는다. 자리와 아름다움을 준다.
## size : 바닥에서 차지하는 칸 수 (열, 행). 넓은 집일수록 아름다움이 크다.
## shape: vessel(항아리·병) / wreck(난파선·뼈대) / pillar(기둥·석상).
## 집은 이 셋만 쓴다. 생물의 형태(plant/rock/fish/glow)와 섞지 않아야
## 한눈에 "건물"과 "사는 것"이 갈린다.
## size : 바닥에서 차지하는 칸 수 (열, 행). 넓은 집일수록 아름다움이 크다.
## capacity: 들일 수 있는 생물 수. 없으면 HOUSE_CAPACITY.
## shape : rock/plant(자연) · vessel/wreck/pillar(지은 것).
## 생물의 형태(fish/glow/crab/seahorse/shrimp)와 섞지 않아야
## 한눈에 "사는 곳"과 "사는 것"이 갈린다.
const HOUSES := [
# --- 얕은 바다 ---
{
"id": "bubble_stone", "name": "거품돌", "zone": 0, "shape": "rock",
"size": Vector2i(1, 1), "height": 0.75, "capacity": 1,
"desc": "틈이 하나뿐이라 한 마리만 깃든다. 가장 싼 자리.",
"color": Color(0.62, 0.68, 0.72), "base_cost": 15.0, "beauty": 4.0,
},
{
"id": "seaweed", "name": "해초", "zone": 0, "shape": "plant",
"size": Vector2i(1, 1), "height": 0.95, "capacity": 2,
"desc": "잎 사이에 몸을 숨긴다.",
"color": Color(0.35, 0.72, 0.42), "base_cost": 120.0, "beauty": 9.0,
},
{
"id": "amphora", "name": "침몰한 항아리", "zone": 0, "shape": "vessel",
"size": Vector2i(2, 1), "height": 1.2,
@@ -137,6 +151,12 @@ const HOUSES := [
"color": Color(0.78, 0.92, 0.72), "base_cost": 1200.0, "beauty": 20.0,
},
# --- 산호초 ---
{
"id": "coral", "name": "산호", "zone": 1, "shape": "plant",
"size": Vector2i(1, 1), "height": 0.85, "capacity": 2,
"desc": "가지 사이가 곧 물고기의 골목이다.",
"color": Color(0.95, 0.45, 0.62), "base_cost": 9000.0, "beauty": 26.0,
},
{
"id": "anchor", "name": "낡은 닻", "zone": 1, "shape": "wreck",
"size": Vector2i(2, 1), "height": 1.15,
@@ -150,6 +170,12 @@ const HOUSES := [
"color": Color(0.42, 0.32, 0.24), "base_cost": 60000.0, "beauty": 110.0,
},
# --- 켈프 숲 ---
{
"id": "kelp", "name": "켈프", "zone": 2, "shape": "plant",
"size": Vector2i(1, 2), "height": 1.35, "capacity": 4,
"desc": "수면까지 뻗은 줄기 사이에 여럿이 머문다.",
"color": Color(0.34, 0.42, 0.15), "base_cost": 900000.0, "beauty": 120.0,
},
{
"id": "submarine", "name": "침몰한 잠수정", "zone": 2, "shape": "wreck",
"size": Vector2i(3, 2), "height": 1.7,
@@ -176,6 +202,12 @@ const HOUSES := [
"color": Color(0.55, 0.54, 0.50), "base_cost": 1.2e9, "beauty": 1900.0,
},
# --- 열수구 ---
{
"id": "tubeworm", "name": "관벌레", "zone": 4, "shape": "plant",
"size": Vector2i(1, 1), "height": 1.1, "capacity": 4,
"desc": "빽빽한 관 다발이 통째로 보금자리가 된다.",
"color": Color(0.92, 0.28, 0.30), "base_cost": 3.5e10, "beauty": 1600.0,
},
{
"id": "vent_chimney", "name": "열수 굴뚝", "zone": 4, "shape": "pillar",
"size": Vector2i(2, 2), "height": 1.95,
@@ -220,11 +252,11 @@ const STARTER_HOUSES := [
## 새로 시작할 때 이미 살고 있는 생물. id -> 마리 수.
## 거품은 생물만 벌기 때문에 최소 한 마리는 처음부터 있어야 한다.
## 바닥에 붙는 것(거품돌)과 헤엄치는 것(구피)을 하나씩 두어,
## 켜자마자 "제자리에 서는 것"과 "집을 도는 것"이 둘 다 보이게 했다.
const STARTER_FISH := {"bubble_stone": 1, "guppy": 1}
## 헤엄치는 것(구피)과 바닥을 걷는 것(소라게)을 하나씩 두어,
## 켜자마자 두 가지 움직임이 다 보이게 했다.
const STARTER_FISH := {"guppy": 1, "hermit_crab": 1}
## 시작 거품. 두 번째 거품돌 값(ceil(15 × 1.15) = 18)보다 조금 넉넉하게 두어,
## 시작 거품. 두 번째 구피 값(ceil(15 × 1.15) = 18)보다 조금 넉넉하게 두어,
## 시작하자마자 남은 한 자리를 직접 채워보며 구조를 익히게 한다.
const STARTER_BUBBLES := 20.0
@@ -328,25 +360,17 @@ func first_id_of_zone(kind: String, zone_index: int) -> String:
# --- 색과 깊이 ---
## 스트립 가로 위치 f(0~1)에서의 값.
## 구역 중심에서는 그 구역 값 그대로, 경계에서는 이웃과 반씩 섞인다.
## 바닥과 물빛이 같은 함수를 쓰기 때문에 경계가 어긋나지 않는다.
func _ramp_bounds(f: float) -> Array:
## 바다 전체 가로 위치 f(0~1)가 어느 층인지.
## 예전에는 층끼리 이어 붙어 있어 경계에서 색을 섞었지만, 이제 한 화면에
## 한 층만 보이므로 섞을 이유가 없다. 층마다 제 색을 그대로 쓴다.
func zone_at(f: float) -> int:
var n := ZONES.size()
var z := clampf(f, 0.0, 1.0) * n - 0.5
if z <= 0.0:
return [0, 0, 0.0]
if z >= n - 1:
return [n - 1, n - 1, 0.0]
var i := int(floor(z))
return [i, i + 1, z - i]
return clampi(int(floor(clampf(f, 0.0, 0.9999) * n)), 0, n - 1)
func ramp_color(key: String, f: float) -> Color:
var b := _ramp_bounds(f)
return Color(ZONES[b[0]][key]).lerp(ZONES[b[1]][key], b[2])
return ZONES[zone_at(f)][key]
func ramp_float(key: String, f: float) -> float:
var b := _ramp_bounds(f)
return lerpf(float(ZONES[b[0]][key]), float(ZONES[b[1]][key]), b[2])
return float(ZONES[zone_at(f)][key])
+162
View File
@@ -0,0 +1,162 @@
extends Node
## 실제 시계에 맞춰 바다의 빛을 바꾼다.
##
## 게임 안의 시간을 따로 돌리지 않고 바깥 시각을 그대로 쓴다.
## 이 위젯은 하루 종일 화면 아래에 켜져 있는 물건이라, 밤에 켰는데 바다가 한낮이면
## 오히려 어색하다. 새벽에 일하면 바다도 새벽이어야 창밖처럼 읽힌다.
##
## 이름(새벽/아침/낮/저녁/밤)은 구간으로 끊지만 빛은 끊지 않는다.
## 색과 밝기는 시각 사이를 계속 보간해서, 해가 지는 동안 서서히 물든다.
## 이름이 바뀐 순간에만 나온다. 색이 바뀔 때마다 나오지는 않는다.
signal phase_changed(phase_name: String)
## 구간의 시작 시각. 마지막(밤)은 자정을 넘겨 다음 새벽까지 이어진다.
const PHASES := [
{"hour": 4.0, "name": "새벽"},
{"hour": 6.5, "name": "아침"},
{"hour": 10.0, "name": ""},
{"hour": 17.0, "name": "저녁"},
{"hour": 20.0, "name": ""},
]
## 하루의 빛. 사이 시각은 이 표를 보간해서 만든다.
## tint : 바다 전체에 곱하는 색. 흰색이면 한낮 그대로.
## light: 수면에서 내려오는 빛의 양. 물결 무늬와 빛줄기의 세기가 여기 붙는다.
const LIGHT_KEYS := [
{"hour": 0.0, "tint": Color(0.26, 0.33, 0.62), "light": 0.08},
{"hour": 4.0, "tint": Color(0.48, 0.44, 0.80), "light": 0.18},
{"hour": 6.0, "tint": Color(0.88, 0.68, 0.74), "light": 0.42},
{"hour": 7.5, "tint": Color(1.00, 0.87, 0.73), "light": 0.72},
{"hour": 11.0, "tint": Color(1.00, 1.00, 1.00), "light": 1.00},
{"hour": 16.0, "tint": Color(1.00, 0.98, 0.92), "light": 0.95},
{"hour": 18.5, "tint": Color(1.00, 0.72, 0.50), "light": 0.55},
{"hour": 20.5, "tint": Color(0.44, 0.44, 0.72), "light": 0.20},
{"hour": 24.0, "tint": Color(0.26, 0.33, 0.62), "light": 0.08},
]
## 빛은 천천히 변하므로 매 프레임 다시 잴 이유가 없다.
const REFRESH_INTERVAL := 0.5
## 시간을 어떻게 굴릴지.
## REAL 바깥 시계를 그대로 (기본)
## FIXED 한 시각에 멈춰 둔다. 밤바다만 보고 싶을 때.
## FAST 하루를 몇 분으로 압축해 돌린다. 하루가 지나가는 걸 보고 싶을 때.
enum Mode { REAL, FIXED, FAST }
## 메뉴에 올리는 고정 시각들. 이름과 시각을 한곳에 둬서 메뉴와 어긋나지 않게 한다.
const FIXED_PRESETS := [
{"name": "새벽", "hour": 4.5},
{"name": "아침", "hour": 8.0},
{"name": "", "hour": 13.0},
{"name": "저녁", "hour": 18.7},
{"name": "", "hour": 22.0},
]
## 빠르게 돌릴 때 하루가 몇 분인지.
const FAST_DAY_MINUTES := 5.0
var mode := Mode.REAL:
set(value):
if mode == value:
return
# 지금 보이던 시각을 먼저 잡아 둔다. mode를 바꾼 뒤에 hour()를 부르면
# 이미 새 모드의 시계를 읽어 0시로 튀어 버린다.
var carry := hour()
mode = value
# 빠르게로 넘어갈 때는 보던 시각에서 이어 흐르게 한다.
# 0시로 떨어지면 한밤중으로 뚝 끊긴다.
if mode == Mode.FAST:
_fast_hour = carry
refresh()
## 고정 모드일 때 멈춰 있을 시각.
var fixed_hour := 13.0:
set(value):
fixed_hour = fposmod(value, 24.0)
refresh()
var tint := Color.WHITE
var light := 1.0
var phase := ""
var _fast_hour := 0.0
var _timer := 0.0
func _ready() -> void:
# 개발 중 특정 시각을 눈으로 확인할 때: godot --path . -- --hour=21
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--hour="):
fixed_hour = float(arg.trim_prefix("--hour="))
mode = Mode.FIXED
refresh()
func _process(delta: float) -> void:
# 압축한 시계는 매 틀 굴려야 매끄럽게 흐른다. 화면 갱신만 뜸하게 한다.
if mode == Mode.FAST:
_fast_hour = fposmod(_fast_hour + delta * 24.0 / (FAST_DAY_MINUTES * 60.0), 24.0)
_timer += delta
if _timer < REFRESH_INTERVAL:
return
_timer = 0.0
refresh()
## 지금 몇 시인지. 소수점까지 있는 시각(13.5 = 오후 1시 30분).
func hour() -> float:
match mode:
Mode.FIXED: return fixed_hour
Mode.FAST: return _fast_hour
var t := Time.get_time_dict_from_system()
return float(t.hour) + float(t.minute) / 60.0 + float(t.second) / 3600.0
## "13:24" 처럼 보여줄 문자열.
func clock_text() -> String:
var h := hour()
return "%02d:%02d" % [int(h), int(fposmod(h * 60.0, 60.0))]
func refresh() -> void:
var h := hour()
tint = tint_at(h)
light = light_at(h)
var next_phase := phase_at(h)
if next_phase != phase:
phase = next_phase
phase_changed.emit(phase)
## 그 시각이 어느 구간인지. 자정부터 첫 구간 전까지는 전날 밤이 이어진다.
func phase_at(h: float) -> String:
var found := String(PHASES[PHASES.size() - 1].name)
for p in PHASES:
if h >= float(p.hour):
found = String(p.name)
return found
func tint_at(h: float) -> Color:
var b := _bounds(h)
return Color(LIGHT_KEYS[b[0]].tint).lerp(LIGHT_KEYS[b[1]].tint, b[2])
func light_at(h: float) -> float:
var b := _bounds(h)
return lerpf(float(LIGHT_KEYS[b[0]].light), float(LIGHT_KEYS[b[1]].light), b[2])
## 그 시각을 감싸는 두 열쇠와 사이 비율. [앞, 뒤, 0~1]
func _bounds(h: float) -> Array:
var t := clampf(h, 0.0, 24.0)
for i in range(LIGHT_KEYS.size() - 1):
var a := float(LIGHT_KEYS[i].hour)
var b := float(LIGHT_KEYS[i + 1].hour)
if t <= b:
return [i, i + 1, inverse_lerp(a, b, maxf(t, a))]
var last := LIGHT_KEYS.size() - 1
return [last, last, 0.0]
+1
View File
@@ -0,0 +1 @@
uid://bw8bwvut3qhox
+11 -1
View File
@@ -576,7 +576,17 @@ func _restore_house(id: String, col: int, row: int) -> void:
## 정원을 넘겨도 이미 살던 생물을 내쫓지는 않는다. 자리가 빌 때까지 더 못 들일 뿐이다.
func _restore_fish(id: String, count: int) -> void:
if Catalog.producer(id).is_empty() or count <= 0:
if count <= 0:
return
# 돌·해초처럼 예전에 생물이었다가 집이 된 것은 그만큼 집으로 지어 준다.
# 그냥 버리면 플레이어가 사 둔 것이 조용히 사라진다.
if Catalog.producer(id).is_empty():
if not Catalog.house(id).is_empty():
for i in count:
var spot := first_free_spot(id)
if spot.x < 0:
break
_restore_house(id, spot.x, spot.y)
return
fish[id] = fish_count(id) + count
recalc()
+261 -1
View File
@@ -119,7 +119,8 @@
| 구역 수 | 5 (구역당 생물 2 + 집 2) | `Catalog.ZONES` |
| 구역 해금 비용 | 30.0K / 3.00M / 400M / 40.0B | `Catalog.ZONES[i].unlock_cost` |
| 구역 해금 아름다움 | 앞 구역에 90 / 500 / 2.20K / 8.00K | `Catalog.ZONES[i].require_beauty` |
| 격자 | 구역당 9× 6행 (총 45×6 = 270칸) | `Catalog.ZONE_COLUMNS` / `GRID_ROWS` |
| 격자 | 구역당 16× 6행 (총 80×6 = 480칸) | `Catalog.ZONE_COLUMNS` / `GRID_ROWS` |
| 칸 가로:세로 | 1.15 | `Aquarium.TILE_ASPECT` |
| 물체 높이 | 칸 높이의 0.75~1.95배 | 각 항목의 `height` |
| 집 값 | 그 구역 싼 생물의 약 6배 | 각 집의 `base_cost` |
| 헐거나 내보낼 때 환불 | 50% | `Catalog.REFUND_RATE` |
@@ -198,6 +199,26 @@
바닥은 `seabed.gd`가 칸마다 그리고, 그 위에 `water.gdshader`가 물빛과
일렁이는 빛 그물을 덮는다. 깊이감은 이 오버레이의 `murk`가 만든다.
## 먼 바다 배경
3/4 부감에서 화면 위쪽은 "더 먼 곳"이다. 거기까지 모래를 깔면 바다가 아니라
사막처럼 보였다. 그래서 격자 위쪽 띠를 `backdrop.gd`가 물로 채운다.
- **먼 물** — 위로 갈수록 어두워지는 세로 그라디언트. 열마다 사각형을 그리고
정점 색을 먹여 가로(구역)·세로(거리) 양쪽으로 동시에 번지게 한다.
- **능선** — 격자 바로 위에 물결치는 먼 바닥. 이 선이 수평선 노릇을 한다.
- **먼 실루엣** — 능선 위의 낮고 넓은 덩어리들. 무엇인지 알아볼 필요는 없고
"저 너머에도 뭔가 있다"는 신호만 주면 된다. 가늘고 높게 그리면 비석처럼
서 버려서 먼 풍경으로 안 읽힌다.
- **공기원근** — `seabed.gd`가 뒷줄(위쪽) 칸일수록 색을 물빛 쪽으로 22%까지
당겨서 멀어 보이게 한다.
- **빛줄기** — `water.gdshader`가 비스듬히 내려오는 빛을 얹는다. 세기는
`murk`의 반대(`_ray_strength()`)라 얕은 바다에서만 뚜렷하고 심해에서는 사라진다.
띠의 높이는 HUD가 `reserved_top()`으로 알려주는 값이다. 정보 바가 그 안에 앉고,
`BAR_CLEARANCE`만큼 남는 자리에 배경이 드러난다. 바를 키우면 배경이 가려지고
격자가 좁아지므로, 이 셋은 같이 조정해야 한다.
## 창 동작 (Windows)
- 투명·테두리 없음: `project.godot``per_pixel_transparency/allowed` + `size/transparent`
@@ -232,3 +253,242 @@
`SaveManager.persistence_enabled`를 끄면 저장/자동저장/종료 시 저장이 모두 멎는다.
스모크 테스트가 이걸 끈다. 끄지 않으면 테스트가 끝나면서 초기화된 상태를
실제 저장 파일에 덮어써 진행도가 날아간다.
## 화면보다 넓은 바다
칸 크기를 화면 너비에서 나누지 않고 **행 높이에서 정한다**(`cell_size()`).
세로는 격자 높이 ÷ 행 수, 가로는 거기에 `TILE_ASPECT`를 곱한 값이다.
그래서 구역의 열을 늘리면 칸이 작아지는 대신 **바다가 화면 밖으로 넓어진다.**
좌표계가 둘로 갈린다.
- **화면 좌표** — 입력(`event.position`), 휴지통 영역
- **바다 좌표** — 격자·집·생물·배경이 사는 곳
`World` 노드 하나가 이 둘을 잇는다. `scroll_x`만큼 `World.position.x`를 음수로
밀어 두고, 입력이 들어오면 `to_world()`로 되돌린다. 바다에 속한 것은 전부
`World` 아래에 있으므로 스크롤에 자동으로 따라온다 — 배경, 바닥, 집, 생물,
물빛, 구분선, 구역 이름, 잠금 베일, 배치 미리보기, 물 만짐 연출까지.
**둘러보는 법**: 빈 바닥을 끌거나(`_begin_pan()`) 마우스 휠. 무언가를 누르고
끌면 그것을 옮기는 것이고, 빈 곳을 누르고 끌면 바다를 미는 것이다 —
`_press_ref`가 비었는지로 갈린다.
**자동으로 따라가는 곳**: 구역이 새로 열렸을 때와, 상점에서 집을 골랐을 때.
후자가 없으면 화면 밖 구역에는 지을 자리를 찍을 수가 없다.
보던 위치는 `window_state["scroll_x"]`에 저장한다. 격자 크기가 정해지기 전에
복원값이 들어올 수 있어서, 클램프 전의 요청값을 `_wanted_scroll`에 남겨 두었다가
`_on_layout_changed()`에서 다시 적용한다.
## 하루의 빛
`autoload/day_cycle.gd`가 **실제 시각**에 맞춰 바다의 빛을 바꾼다.
게임 안의 시간을 따로 돌리지 않은 이유는 이것이 하루 종일 화면 아래에 켜져 있는
물건이기 때문이다. 새벽에 일하는데 바다가 한낮이면 창밖처럼 읽히지 않는다.
**이름은 끊고 빛은 잇는다.** 새벽/아침/낮/저녁/밤은 시각으로 구간을 나누지만
(`PHASES`), 색과 밝기는 `LIGHT_KEYS`를 계속 보간한다. 해가 지는 동안 서서히
물들어야지, 19시가 되는 순간 화면이 딸깍 바뀌면 안 된다.
- `tint` — 바다에 **곱하는** 색. 밤에는 전체가 어두워지고 저녁에는 주황이 돈다.
- `light` — 수면에서 오는 빛의 양. 물결 무늬와 빛줄기(`sun` 유니폼)의 세기가 여기 붙는다.
`Aquarium._apply_daylight()``_daylit`에 담긴 층에만 `modulate`를 건다.
**배치 미리보기(Ghost)는 일부러 뺐다** — 밤이라고 초록/빨강 판정이 흐려지면
집을 어디에 놓을지 알 수 없다. HUD와 상점도 World 밖이라 늘 또렷하다.
**밤의 발광**: 전체에 어둠이 곱해지므로 스스로 빛나는 것(`glow`)은 그대로 두면
같이 묻힌다. `_draw_glow()``1 - light`만큼 색을 끌어올리고 후광을 넓혀,
어두울수록 오히려 도드라지게 했다. 심해 해파리가 밤에 살아나는 것이 이 때문이다.
**게임 안에서 정하기**: `···` 메뉴의 **시간** 하위 메뉴에 세 갈래가 있다.
- `Mode.REAL` — 바깥 시계 그대로. 기본값이다.
- `Mode.FIXED` — 한 시각에 멈춘다. 밤바다만 보고 싶을 때.
메뉴에 올리는 프리셋은 `FIXED_PRESETS`에 이름과 시각을 같이 둬서
메뉴 항목과 실제 시각이 어긋나지 않게 했다.
- `Mode.FAST` — 하루를 `FAST_DAY_MINUTES`(5분)로 압축해 돌린다.
압축한 시계는 매 틀 굴리고 화면 갱신만 뜸하게 한다.
모드를 바꿀 때 **지금 보이던 시각을 먼저 잡아 둔다.** `mode`를 먼저 바꾼 뒤
`hour()`를 부르면 이미 새 모드의 시계를 읽어 0시로 튄다 -- 빠르게로 넘어갈 때마다
한밤중으로 뚝 떨어지는 버그였다.
고른 값은 `window_state["time_mode"]/["time_hour"]`에 저장된다.
명령줄 `--hour=21`은 FIXED 모드의 지름길이고, 테스트도 같은 문을 쓴다.
## 감상 모드 (전체화면)
F11이나 메뉴로 창을 화면 전체로 키운다. 그냥 창만 키우면 안 되는 이유가 있다 —
칸 크기가 격자 높이에서 나오기 때문에, 화면이 높아질수록 **타일만 거대해지고
보이는 열은 오히려 줄어든다.** 감상하려고 켰는데 볼 수 있는 바다가 좁아진다.
그래서 세로로 남는 공간을 격자가 아니라 **물기둥**에 준다.
`Aquarium.grid_rect()`가 감상 모드에서는 아래쪽 `SHOWCASE_GRID_RATIO`(0.45)만
격자로 쓰고, 나머지 위쪽은 Backdrop이 먼 바다로 채운다. 바닥은 크게 보이면서
위로는 빛줄기가 내려오는 깊은 물이 열린다.
**물기둥을 비어 보이지 않게 하는 것들**
- 빛줄기를 *더하는* 방향으로 바꿨다. 전에는 알파를 낮춰 물을 걷어냈는데,
그러면 바닥만 밝아지고 물기둥은 텅 빈 채로 남는다. 이제는 알파를 올려
물 속에 떠 있는 밝은 기둥으로 그린다.
- 빛줄기 무늬의 y 계수를 줄였다. 크게 두면 화면이 높을 때 무늬가 가로로 누워
빛줄기로 안 보인다.
- 부유물은 바다 전체가 아니라 **지금 보이는 곳**에만 뿌린다(`_follow_view()`).
넓은 바다에 고르게 뿌리면 화면당 밀도가 낮아진다. `local_coords = false`
이미 나온 것은 그 자리에 머물러, 화면을 밀어도 물이 같이 끌려오지 않는다.
- 격자선은 옅게 누른다. 지금은 놓는 시간이 아니라 보는 시간이다.
**창 크기의 함정**: 창을 화면과 픽셀까지 똑같이 맞추면 Windows가 이를
독점 전체화면(`MODE_EXCLUSIVE_FULLSCREEN`)으로 승격시켜 버리고, 그 뒤로는
`size`를 넣어도 먹지 않아 **감상 모드에서 빠져나올 수 없다.** 그래서 감상 모드도
작업표시줄을 뺀 `usable_rect`까지만 쓴다. 보기에는 거의 같고 복귀는 확실하다.
칸 크기가 통째로 달라지므로 스크롤 픽셀값을 그대로 두면 엉뚱한 곳으로 튄다.
보고 있던 열을 기억했다가(`_center_column()`) 그 자리로 되돌린다(`look_at_column()`).
## 층 (한 화면에 한 지역)
예전에는 다섯 구역이 옆으로 이어져 있고 가로 스크롤로 훑었다. 지금은
**한 화면이 한 층**이고, 층은 위에서 아래로 얕은 바다 → 열수구 순으로 쌓인다.
**구현은 여전히 가로다.** 층 n은 열 `n * ZONE_COLUMNS`부터 시작하고, 화면은 그중
한 구간만 비춘다(`layer_offset()`). 저장 형식(전역 열 번호)을 건드리지 않아도 되고,
격자 계산도 그대로 쓸 수 있다.
**보이는 것만 세로다.** 층을 갈아탈 때 `_slide_to_layer()`가 가로 위치는 즉시
새 층으로 옮기고 세로만 화면 높이만큼 밀어 넣었다가 0으로 당긴다. 아래층으로 가면
아래에서 올라오고, 위층으로 가면 위에서 내려온다. 깊이가 세로로 읽히는 것은
이 연출뿐이지만, 그것으로 충분하다.
**칸 크기가 뒤집혔다.** 예전에는 높이에서 칸을 정하고 넘치는 만큼 옆으로 스크롤했다.
이제는 한 층이 화면을 꽉 채워야 하므로 **가로에서 정한다**
`cell_w = 화면너비 / ZONE_COLUMNS`, `cell_h = cell_w / TILE_ASPECT`.
격자는 언제나 화면 아래쪽에 붙고, 남는 위쪽은 물기둥이 된다.
스트립처럼 세로가 모자라면 `cell_h`를 눌러 넣는다.
`ZONE_COLUMNS`는 이 식 때문에 36으로 올렸다. 너무 적으면 스트립(가로로 길고
세로로 짧은 창)에서 칸이 3:1로 납작해진다. 대신 층당 칸이 96 → 216으로 늘었으므로
**정원과 해금 곡선은 다시 봐야 한다.**
**색은 층마다 평평하다.** 예전에는 구역 경계에서 색을 섞어 이음매를 감췄지만,
이제 한 화면에 한 층만 보이므로 섞을 이유가 없다. 섞으면 오히려 한 화면 안에서
좌우로 색이 흐르는데, 깊이는 더 이상 가로가 아니다. `Catalog.ramp_color()`
그 자리의 층 색을 그대로 돌려준다.
**층 선택기**(`scenes/ui/layer_bar.gd`)는 왼쪽 아래에 세로로 선다. 단추마다 그 층의
물빛을 입혀서, 목록만 봐도 위에서 아래로 어두워지는 것이 보인다. 잠긴 층도 눌러서
해금 조건을 보러 갈 수 있다.
함께 사라진 것: 가로 패닝, 구역 구분선(한 화면에 둘이 안 보이므로 무의미),
`window_state["scroll_x"]`(→ `["layer"]`).
## 사는 것과 사는 곳
처음에는 거품돌·해초·산호·켈프·관벌레가 **생물** 쪽에 있었다. 하지만 이것들은
물고기가 깃드는 자리지 그 자체가 일꾼은 아니다. 실제로 물고기는 바위 틈과
해초 사이를 보금자리로 삼는다. 그래서 전부 **집**으로 옮겼다.
- **생물** — 물고기·게·해마·새우처럼 움직이는 해양동물만. 거품을 버는 것은 이쪽뿐이다.
- **집** — 자연 서식지(rock/plant)와 지어 올린 것(vessel/wreck/pillar).
칸을 차지하고 정원과 아름다움을 준다.
집이 두 갈래가 되면서 정원에 폭이 생겼다. `capacity`를 아이템별로 둘 수 있게 해서
자연 서식지는 싸지만 적게 들이고(거품돌 1, 해초 2, 산호 2), 지어 올린 것은
비싼 대신 표준 정원(3)을 준다. 켈프와 관벌레는 4까지 들인다.
**형태 계통이 그대로 경계가 된다.** 생물은 `fish/glow/crab/seahorse/shrimp`,
집은 `rock/plant/vessel/wreck/pillar`. 두 집합이 겹치지 않는 것을 테스트가 지킨다.
겹치면 화면에서 "사는 것"과 "사는 곳"이 구분되지 않는다.
**옛 저장본**: 예전에 생물로 사 둔 돌·해초는 `_restore_fish()`가 같은 id의 집으로
그만큼 지어 준다. 그냥 버리면 플레이어가 사 둔 것이 조용히 사라진다.
### 이때 드러난 버그
생물의 자리를 `grid_rect()`(화면 테두리)로 잘라내고 있었다. 가로로 이어 스크롤하던
시절에는 화면 = 바다여서 맞았지만, 층이 옆으로 늘어선 지금은 **뒤쪽 층의 생물이
전부 첫 층 오른쪽 끝으로 끌려와** 제 층에서 사라졌다. 집은 멀쩡히 보이는데
생물만 없어서 한참 헤맸다. `_home_bounds()`가 그 집이 속한 층의 테두리를 돌려주고,
자를 때 그것을 쓴다.
`_make_fish()``fish``glow`만 궤도에 올리고 있었다. 고착생물이 생물 쪽에
있던 시절의 가드인데, 새로 넣은 게·해마·새우가 여기 걸려 제자리에 굳어 있었다.
고착생물이 전부 집으로 갔으므로 가드 자체를 걷어냈다.
## 생물의 움직임
처음에는 생물이 제 집 둘레를 타원으로 돌았다. 집과 생물의 관계는 분명해졌지만
꾸미기 게임에는 어울리지 않았다 — 물고기가 말뚝에 묶인 것처럼 보였고,
집을 여러 채 지으면 화면이 회전목마 여러 대가 된다.
지금은 **층 전체를 자유롭게 돌아다닌다.** 집은 정원을 세는 단위로만 남고,
헤엄치는 범위는 그 층의 격자 전체다(`_roam_bounds`). 제 집 앞에서 시작해
거기서부터 어디로든 간다.
**알고리즘은 방향 흔들기(wander)다.** 목표 지점을 따로 두지 않고 진행 각도만
매 틀 조금씩 흔든다. 그것만으로 충분히 어슬렁거리는 것처럼 보이고,
목표를 찍는 방식보다 훨씬 싸다. 테두리는 닿기 전에 미리 각도를 반사시킨다 --
벽에 붙어 비비는 것보다 자연스럽다.
**종류마다 다니는 방식이 다르다.** 같은 식으로 움직이면 여러 마리가 한 화면에
있을 때 움직임이 무늬가 되어 버린다.
| | 속도 | 노는 높이 | 특징 |
| --- | --- | --- | --- |
| 물고기 | 빠름 | 물속 전체 | 크게 돌아다닌다 |
| 게 | 보통 | 바닥께 | 세로 성분을 12%로 눌러 바닥을 짚고 걷는다 |
| 해마 | 느림 | 위아래 넓게 | 가로 성분을 35%로 눌러 거의 제자리에서 오르내린다 |
| 새우 | 느림 + 돌진 | 바닥께 | 1~3초마다 0.35초 동안 3.2배로 튄다 |
| 해파리 | 아주 느림 | 위쪽 | 물살에 밀리듯 |
**앞뒤 단서**: 화면 위쪽(뒷줄)일수록 조금 작고 흐리게 그린다(`_apply_depth`).
3/4 시점에서 깊이를 읽게 해주는 유일한 신호라 없으면 평면처럼 보인다.
### 헤엄치는 리듬
돌아다니는 생물은 두 상태를 오간다 — 한동안 나아가다가(CRUISE) 잠시 멈춘다(REST).
방향은 매 틀 흔들지 않고, 나아갈 때마다 목표 각도를 하나 정해 그쪽으로 부드럽게 돌아선다.
매 틀 각도를 무작위로 흔드는 방식을 먼저 써 봤는데 두 가지가 어색했다. 하나는
속도가 늘 일정해서 쉬는 틈이 없었고, 다른 하나는 층이 가로로 아주 길고 세로로
짧다 보니 무작위 방향이 위아래 벽에 계속 부딪혀 통통 튀는 것처럼 보였다.
그래서 종류마다 기본으로 삼는 축(물고기·게는 좌우, 해마·해파리는 위아래)을 두고
거기서 조금만 벗어나게 했고, 벽 앞에서는 튕기는 대신 미리 안쪽으로 돌아서게 했다.
속도는 전반적으로 느리게 잡았다. 방치형이라 늘 눈에 들어와 있는 화면이고,
빠른 움직임은 몇 분만 지나도 산만해진다. 물고기가 벗어나는 각도도 6도 남짓으로
좁혀 두었다 -- 실제로 물고기가 수직으로 오르내리는 일은 드물다. 대신 열 번에
한 번쯤은 크게 틀어 수심을 바꾸게 해서, 모두가 제 높이에 줄지어 있지 않도록 했다.
한동안은 "몇 초 동안 나아간다"로 잡았는데, 시간이 좁은 범위 안에서만 흔들리고
속도가 고정이라 결국 매번 비슷한 거리를 갔다. 짜인 것처럼 보였다. 지금은 나아갈
때마다 **거리와 속도를 각각 새로 뽑는다**. 거리는 층 높이의 0.3~6배(대략 1~22칸)
사이에서 뽑되 짧은 쪽으로 치우치게 해서(`pow(randf(), 1.6)`), 몇 칸만 가는 일이
잦고 화면을 가로지르는 일은 가끔 있게 했다. 방향도 좌우 어느 쪽인지 매번 새로
던진다 -- 가던 쪽을 이어 가게 했더니 오히려 한 방향으로 흐르는 것처럼 보였다.
### 물기둥 높이 (hover)
바닥은 위에서 내려다보고 물고기는 옆에서 본 모습으로 그리다 보니, 물고기가 모래
위를 기어다니는 것처럼 보였다. 원인은 물고기를 설치물과 똑같은 규칙으로 -- 원점을
바닥에 닿는 지점에 두고 -- 놓았기 때문이다. 바닥에 붙어 있으면 바닥에 붙은 것으로
읽히는 게 당연하다.
그래서 헤엄치는 것에는 높이를 하나 더 뒀다. `position`은 여전히 "모래 위 어디"를
가리키고 그림자도 거기 남지만, 몸은 `hover`만큼 위에 그린다. 이 간격이 벌어질수록
물속에 떠 있는 것으로 읽힌다. 물고기는 칸 높이의 1~2.9배를 뜨고, 이동할 때마다 그
높이를 새로 뽑아 천천히 오르내린다. 게와 새우는 거의 뜨지 않고 해파리는 가장 높이
뜬다. 뜬 높이만큼 그림자는 넓고 옅게 번진다.
뜰 자리를 확보하려고 헤엄치는 것들의 바닥 범위를 앞쪽(화면 아래)으로 옮겼다.
뒷줄에 있으면 화면 위 여유가 없어 높이 뜰 수 없는데, 이건 원근으로도 맞는 얘기라
`_hover_ceiling()`으로 자리에 따라 알아서 낮아지게 뒀다.
**주의**: `draw_set_transform()`은 쌓이지 않고 통째로 갈아치운다. 형태 함수 안에서
좌우를 뒤집을 때 `Vector2.ZERO`로 다시 부르면 `hover` 오프셋이 통째로 날아간다.
실제로 이 때문에 물고기·게·해마·새우가 전부 바닥에 붙어 있었다. 그래서 오프셋을
`_body_offset`에 담아 두고 `_xform()`을 거치도록 했다 -- 형태 함수에서 직접
`draw_set_transform`을 부르면 안 된다.
+1
View File
@@ -15,6 +15,7 @@ run/max_fps=30
[autoload]
Catalog="*res://autoload/catalog.gd"
DayCycle="*res://autoload/day_cycle.gd"
GameState="*res://autoload/game_state.gd"
SaveManager="*res://autoload/save_manager.gd"
+208 -58
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,9 +47,14 @@ 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
var _rng := RandomNumberGenerator.new()
var _lock_reasons := {} ## 구역 인덱스 -> 사유 Label
@@ -58,6 +70,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 +107,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 +119,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 +240,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 +285,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 +400,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 +410,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
@@ -330,16 +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 shape := String(d.get("shape", "rock"))
if shape != "fish" and shape != "glow":
return
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)
## 어느 생물이 어느 집에 사는지 그릴 때마다 정한다. 집은 정원만 주므로 배정을
@@ -392,13 +516,28 @@ func _orbit_radius(home: Rect2, cell: Vector2) -> Vector2:
## 중심을 옮기지 않고 매 프레임 위치만 자르면 물고기가 벽에 붙어 멈춘 것처럼 보인다.
func _orbit_center(home: Rect2, cell: Vector2) -> Vector2:
var r := _orbit_radius(home, cell)
var g := grid_rect()
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:
@@ -419,8 +558,8 @@ func _fish_area(home: Rect2, cell: Vector2, slot: int) -> Rect2:
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 := grid_rect()
# 제 층 밖으로 새어 나가지 않게 잡아둔다.
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)
@@ -450,7 +589,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 +629,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 +700,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 +717,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 +734,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 +743,7 @@ func _gui_input(event: InputEvent) -> void:
clicked.emit(event.position)
_press_ref = {}
_press_was_drag = false
_pressing = false
func _begin_drag() -> void:
@@ -604,14 +753,14 @@ 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)
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 +831,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)
+461 -64
View File
@@ -5,6 +5,11 @@ class_name TileItem
## 바닥은 위에서 내려다보지만 물건은 "서 있는" 모습으로 그린다.
## 노드의 원점은 발이 바닥에 닿는 지점(발자국 아래 가운데)이고,
## 그림은 거기서 위(-Y)로 자란다. 그래서 키가 큰 것은 뒷줄을 가린다.
##
## 헤엄치는 것은 여기에 hover가 하나 더 붙는다. 원점은 여전히 "모래 위 어디"를
## 가리키고 그림자도 거기 남지만, 몸은 hover만큼 위에 그린다. 이 둘을 나누지
## 않으면 -- 즉 물고기를 설치물과 똑같이 바닥에 붙여 놓으면 -- 모래 위를 기어
## 다니는 것처럼 보인다. 뜬 높이만큼 몸과 그림자가 벌어져야 물속으로 읽힌다.
## 아직 아트 에셋이 없어서 형태별로 직접 그린다.
## assets/art/ 에 스프라이트가 생기면 Sprite2D + offset 기반으로 바꾸면 된다.
@@ -23,14 +28,47 @@ 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
## 집 둘레를 도는 것(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 _band := Vector2(0.1, 0.95)
var _heading := 0.0
var _desired := 0.0
## 기본으로 삼는 축. 0이면 좌우, 1이면 위아래.
var _vertical := 0.0
var _spread := 0.35
## 기본 축을 벗어난 쪽(물고기라면 위아래)으로 갈 확률.
var _off_odds := 0.15
## 한 번에 나아가는 거리의 (최소, 최대). 층 높이의 배수.
var _leg := Vector2(0.3, 6.0)
## 쉬는 시간의 (최소, 최대).
var _rest := Vector2(0.8, 6.0)
var _rest_odds := 0.6
var _accel := 2.5
var _turn_rate := 2.2
## 지금 쉬는 중인지, 그리고 얼마나 더 쉬는지.
var _resting := false
var _rest_left := 0.0
## 이번에 갈 거리, 지금까지 온 거리, 이번만의 속도 배수.
var _leg_dist := 0.0
var _travelled := 0.0
var _leg_speed := 1.0
## 0에서 1로 오가는 속도 배수. 서서히 붙고 서서히 죽는다.
var _throttle := 0.0
var _t := 0.0
var _phase := 0.0
@@ -59,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)
@@ -75,35 +114,246 @@ 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)
## 층 전체를 자유롭게 돌아다니게 한다.
## 집은 정원을 세는 단위일 뿐이고, 헤엄치는 자리는 층 전체다.
##
## 움직임은 두 상태를 오간다 — 한동안 나아가다가(CRUISE) 잠시 멈춘다(REST).
## 실제 물고기가 그렇고, 무엇보다 쉬는 틈이 있어야 여러 마리가 한 화면에 있을 때
## 전부 같은 리듬으로 흐르지 않는다.
##
## 방향은 매 틀 흔들지 않는다. 나아갈 때마다 목표 각도를 하나 정하고 그쪽으로
## 부드럽게 돌아선다. 매 틀 흔들면 술 취한 것처럼 떨리고, 층이 가로로 길고
## 세로로 짧아서 위아래 벽에 계속 부딪히며 통통 튀게 된다.
func set_roam(area: Rect2, rng: RandomNumberGenerator) -> void:
_roam = area
_roaming = true
var h := _roam.size.y
# 종류별 성격.
# vertical : 0이면 좌우, 1이면 위아래를 기본 축으로 삼는다.
# off_odds : 그 축을 벗어난 쪽으로 갈 확률.
# leg : 한 번에 나아가는 거리의 (최소, 최대). 층 높이의 배수다.
# rest : 쉬는 시간의 (최소, 최대).
match shape:
"crab":
# 바닥을 짚고 좌우로만. 몇 칸 종종거리다 오래 멈춰 선다.
_speed = h * 0.10
_vertical = 0.0
_off_odds = 0.04
_spread = 0.05
_leg = Vector2(0.2, 2.5)
_rest = Vector2(1.0, 6.0)
_rest_odds = 0.8
_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
_vertical = 1.0
_off_odds = 0.3
_spread = 0.3
_leg = Vector2(0.15, 1.5)
_rest = Vector2(1.5, 7.0)
_rest_odds = 0.75
_turn_rate = 1.0
_accel = 1.5
_band = Vector2(0.55, 0.96)
_hover_range = Vector2(0.5, 1.7)
"shrimp":
# 거의 멈춰 있다가 짧게 튄다. 튀는 순간만 빠르다.
_speed = h * 0.35
_vertical = 0.0
_off_odds = 0.15
_spread = 0.15
_leg = Vector2(0.2, 1.2)
_rest = Vector2(1.5, 6.0)
_rest_odds = 0.9
_turn_rate = 4.0
_accel = 10.0
_band = Vector2(0.55, 1.0)
_hover_range = Vector2(0.0, 0.35)
"glow":
# 해파리는 물살에 밀리듯 길게 흐르고 가끔 멎는다.
_speed = h * 0.07
_vertical = 1.0
_off_odds = 0.35
_spread = 0.5
_leg = Vector2(0.5, 4.0)
_rest = Vector2(1.0, 5.0)
_rest_odds = 0.45
_turn_rate = 0.6
_accel = 0.9
_band = Vector2(0.42, 0.86)
_hover_range = Vector2(1.8, 3.6)
_:
# 물고기는 거의 좌우로만 다닌다. spread가 0.1이면 6도쯤 --
# 한 번 나아가는 동안 수심이 눈에 띄게 바뀌지 않을 만큼이다.
_speed = h * 0.16
_vertical = 0.0
_off_odds = 0.15
_spread = 0.1
_leg = Vector2(0.3, 6.0)
_rest = Vector2(0.8, 6.0)
_rest_odds = 0.6
_turn_rate = 1.2
_accel = 1.4
_band = Vector2(0.45, 0.98)
_hover_range = Vector2(1.0, 2.9)
# 돌아다니는 것이 큰 움직임을 맡으므로 제자리 흔들림은 줄인다.
_swim_range *= 0.22
_speed *= rng.randf_range(0.82, 1.2)
_heading = _new_heading()
_desired = _heading
# 시작을 흩어 놓는다. 안 그러면 모두 같은 박자로 멈추고 나아간다.
if rng.randf() < 0.5:
_start_rest()
_rest_left *= rng.randf()
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()
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:
_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)
## 헤엄칠 수 있는 세로 범위. 종류마다 물속에서 노는 높이가 다르다.
func _band_range() -> Vector2:
return Vector2(
_roam.position.y + _roam.size.y * _band.x,
_roam.position.y + _roam.size.y * _band.y)
## 다음에 나아갈 방향. 좌우든 위아래든 매번 새로 던진다.
## 가던 쪽을 이어 가게 해 봤더니 오히려 한 방향으로 흐르는 것처럼 보다.
func _new_heading() -> float:
var vertical := _vertical > 0.5
if randf() < _off_odds:
vertical = not vertical
var axis: float = PI * 0.5 if vertical else 0.0
var back: float = PI if randf() < 0.5 else 0.0
return axis + back + randf_range(-_spread, _spread)
## 새로 나아가기 시작한다. 거리도 속도도 매번 새로 뽑는다 --
## 한 번은 화면을 가로지르고 다음엔 몇 칸만 가야 짜인 것처럼 보이지 않는다.
func _start_leg() -> void:
_resting = false
_travelled = 0.0
# 짧은 이동이 더 잦고 긴 이동은 가끔. 고르게 뽑으면 늘 멀리까지만 간다.
var t := pow(randf(), 1.6)
_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:
_resting = true
_rest_left = randf_range(_rest.x, _rest.y)
func _roam_step(delta: float) -> void:
if _resting:
_rest_left -= delta
if _rest_left <= 0.0:
_start_leg()
elif _travelled >= _leg_dist:
if randf() < _rest_odds:
_start_rest()
else:
_start_leg()
var band := _band_range()
# 벽이 가까우면 안쪽으로 돌아선다. 부딪혀 튕기면 통통 튀는 것처럼 보인다.
# 쉬는 중이라면 어차피 벗어날 일이 없으니 그냥 둔다.
if not _resting:
var mx := _roam.size.y * 0.35
var my := (band.y - band.x) * 0.18
var away := Vector2.ZERO
if position.x < _roam.position.x + mx:
away.x += 1.0
elif position.x > _roam.end.x - mx:
away.x -= 1.0
if position.y < band.x + my:
away.y += 1.0
elif position.y > band.y - my:
away.y -= 1.0
if away != Vector2.ZERO:
# 좌우로 다니는 것은 위아래 벽에서도 곧장 꺾지 않는다. 가던 쪽으로
# 나아가면서 비스듬히 벗어난다 -- 수직으로 솟구치면 물고기 같지 않다.
if _vertical < 0.5 and is_zero_approx(away.x):
away.x = signf(cos(_heading))
if is_zero_approx(away.x):
away.x = 1.0
away.y *= 0.45
elif _vertical > 0.5 and is_zero_approx(away.y):
away.y = signf(sin(_heading))
if is_zero_approx(away.y):
away.y = 1.0
away.x *= 0.45
_desired = away.angle()
# 벽을 따라 하염없이 미끄러지지 않도록 이번 이동을 곧 끝낸다.
_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)
if _throttle > 0.001:
var step := Vector2.RIGHT.rotated(_heading) * _speed * _leg_speed * _throttle * delta
position += step
# 제 범위를 벗어나지 않게 잡아 둔다. 위 steering이 대부분 막아 주고,
# 이건 크기가 갑자기 바뀌었을 때를 위한 마지막 그물이다.
position.x = clampf(position.x, _roam.position.x, _roam.end.x)
position.y = clampf(position.y, band.x, band.y)
_travelled += step.length()
if absf(step.x) > 0.02:
_facing = signf(step.x)
_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,32 +366,46 @@ 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()
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()
"glow": _draw_glow()
"crab": _draw_crab()
"seahorse": _draw_seahorse()
"shrimp": _draw_shrimp()
"vessel": _draw_vessel()
"wreck": _draw_wreck()
"pillar": _draw_pillar()
@@ -181,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([
@@ -215,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()
## 스스로 빛나며 떠 있는 것. 후광과 아래로 늘어진 촉수.
@@ -224,9 +488,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 +507,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 +517,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))
## 바위·구조물. 아래가 넓고 위로 갈수록 좁아지는 덩어리.
@@ -275,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=꼭대기)에서 옆으로 벌어지는 정도.
@@ -331,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()
## 밑동은 좁고, 중간이 부르고, 목에서 다시 좁아진다.
@@ -350,13 +620,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)
# 갈비뼈. 사이의 빈틈이 물고기가 숨는 자리로 읽힌다.
@@ -401,6 +677,127 @@ 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()
## 바닥을 걷는 게. 옆으로 벌어진 몸에 다리가 뻗고 집게가 앞을 짚는다.
## 물고기와 실루엣이 확실히 달라야 한눈에 갈린다.
func _draw_crab() -> void:
var r := span.x * 0.26
var step := sin(_t * 3.0 + _phase)
_xform(Vector2.ZERO, Vector2(_facing, 1.0))
# 다리. 좌우 세 쌍이 번갈아 짚는다.
for side: float in [-1.0, 1.0]:
for i in 3:
var a := -0.35 + i * 0.42
var lift := 0.12 if i % 2 == 0 else -0.12
var swing := step * lift * side
var knee := Vector2(cos(a + swing) * r * 1.05, side * r * (0.55 + i * 0.06))
var foot := knee + Vector2(cos(a + swing) * r * 0.5, side * r * 0.55)
draw_polyline(PackedVector2Array([Vector2(0, side * r * 0.25), knee, foot]),
color.darkened(0.34), maxf(r * 0.14, 1.2), true)
# 집게 둘.
for side: float in [-1.0, 1.0]:
var base := Vector2(r * 0.85, side * r * 0.5)
var tip := base + Vector2(r * 0.62, side * r * 0.18 + step * r * 0.1)
draw_polyline(PackedVector2Array([Vector2(r * 0.3, side * r * 0.3), base, tip]),
color.darkened(0.2), maxf(r * 0.18, 1.4), true)
_fill(PackedVector2Array([
tip + Vector2(-r * 0.1, -r * 0.22),
tip + Vector2(r * 0.34, -r * 0.05),
tip + Vector2(-r * 0.05, r * 0.22),
]), color.lightened(0.1))
# 등딱지. 가로로 넓은 타원.
_xform(Vector2.ZERO, Vector2(_facing * 1.35, 0.92))
draw_circle(Vector2.ZERO, r, color)
_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))
_xform()
## 선 채로 흔들리는 해마. 세로로 긴 S자가 곧 실루엣이다.
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
_xform(Vector2.ZERO, Vector2(_facing, 1.0))
# 몸통. 아래로 갈수록 가늘어지며 꼬리가 안으로 말린다.
var left := PackedVector2Array()
var right := PackedVector2Array()
var steps := 8
for i in steps + 1:
var f := float(i) / steps
var bend := sin(f * PI * 1.15 + sway) * w * 1.5
var p := Vector2(bend, -h * f)
var half := w * (0.45 + sin(f * PI) * 0.75)
left.append(p + Vector2(half, 0))
right.append(p - Vector2(half, 0))
right.reverse()
_fill(left + right, color)
# 등지느러미와 주둥이, 눈.
var head := Vector2(sin(PI * 1.15 + sway) * w * 1.5, -h)
_fill(PackedVector2Array([
head + Vector2(w * 0.4, w * 0.2),
head + Vector2(w * 2.1, w * 0.05),
head + Vector2(w * 0.5, -w * 0.5),
]), color.darkened(0.15))
draw_circle(head + Vector2(w * 0.35, -w * 0.15), maxf(w * 0.42, 1.2),
Color(0.06, 0.07, 0.09))
for i in 3:
var f := 0.25 + i * 0.2
var bend := sin(f * PI * 1.15 + sway) * w * 1.5
_fill(PackedVector2Array([
Vector2(bend - w * 0.8, -h * f),
Vector2(bend - w * 2.0, -h * (f + 0.06)),
Vector2(bend - w * 0.8, -h * (f + 0.12)),
]), color.darkened(0.3))
_xform()
## 새우. 등이 굽고 더듬이가 길다. 꼬리를 튕겨 조금씩 물러난다.
func _draw_shrimp() -> void:
var l := span.x * 0.3
var flick := sin(_t * 4.5 + _phase)
_xform(Vector2.ZERO, Vector2(_facing, 1.0))
# 더듬이. 실루엣의 절반을 차지한다.
for side: float in [-0.35, 0.15]:
draw_polyline(PackedVector2Array([
Vector2(l * 0.6, side * l),
Vector2(l * 1.3, side * l - l * 0.25 + flick * l * 0.06),
Vector2(l * 1.9, side * l - l * 0.55 + flick * l * 0.12),
]), Color(color.r, color.g, color.b, 0.7), maxf(l * 0.07, 1.0), true)
# 굽은 몸통.
var body := PackedVector2Array()
var steps := 7
for i in steps + 1:
var f := float(i) / steps
var a := PI * 0.15 + f * PI * 0.75
body.append(Vector2(cos(a) * l * 0.95, -sin(a) * l * 0.5 + l * 0.1))
for i in range(steps, -1, -1):
var f := float(i) / steps
var a := PI * 0.15 + f * PI * 0.75
body.append(Vector2(cos(a) * l * 0.62, -sin(a) * l * 0.16 + l * 0.1))
_fill(body, color)
# 부채꼴 꼬리.
var tail := Vector2(-l * 0.9, l * 0.05 + flick * l * 0.12)
_fill(PackedVector2Array([
Vector2(-l * 0.6, l * 0.08),
tail + Vector2(-l * 0.4, -l * 0.3),
tail + Vector2(-l * 0.5, l * 0.05),
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))
_xform()
+127 -8
View File
@@ -8,6 +8,7 @@ enum MenuId {
SAVE_NOW = 2,
RESET = 3,
QUIT = 4,
FULLSCREEN = 5,
}
## 높이 프리셋. 메뉴에서 고른다.
@@ -19,6 +20,10 @@ const HEIGHTS := [
const EDGE_BOTTOM := 20
const EDGE_TOP := 21
const SCREEN_ID_BASE := 100
## 시간 하위 메뉴. 200 실제, 201 빠르게, 210+i 고정 프리셋.
const TIME_REAL := 200
const TIME_FAST := 201
const TIME_FIXED_BASE := 210
## 클릭 통과 중에도 이 크기의 모서리는 계속 클릭을 받는다.
## 이게 없으면 클릭 통과를 켠 순간 위젯으로 돌아올 방법이 없어진다.
@@ -28,18 +33,26 @@ const ESCAPE_HATCH := Vector2(84, 56)
@onready var hud: HUDLayer = $HUD
@onready var shop: ShopPanel = $ShopPanel
@onready var menu: PopupMenu = $ContextMenu
@onready var time_menu: PopupMenu = $ContextMenu/TimeMenu
var _always_on_top := true
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))
var saved_time_mode := int(state.get("time_mode", DayCycle.Mode.REAL))
var saved_time_hour := float(state.get("time_hour", 13.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 +73,9 @@ 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
DayCycle.fixed_hour = saved_time_hour
DayCycle.mode = saved_time_mode as DayCycle.Mode
hud.shop_pressed.connect(_toggle_shop)
hud.menu_pressed.connect(_open_menu)
@@ -70,6 +86,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 +111,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 +136,9 @@ 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["time_mode"] = int(DayCycle.mode)
SaveManager.window_state["time_hour"] = DayCycle.fixed_hour
SaveManager.window_state["edge_bottom"] = _edge_bottom
SaveManager.window_state["height"] = _strip_height
SaveManager.window_state["screen"] = _screen
@@ -112,7 +147,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 +167,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 +266,10 @@ 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()
_build_time_menu()
menu.add_submenu_node_item("시간", time_menu)
menu.add_separator()
for h in HEIGHTS:
menu.add_radio_check_item(h.label, h.id)
@@ -218,11 +289,57 @@ func _build_menu() -> void:
_sync_menu()
## 시간을 바깥 시계에 맡길지, 한 시각에 멈출지, 빠르게 돌릴지.
func _build_time_menu() -> void:
time_menu.clear()
time_menu.add_radio_check_item("실제 시각", TIME_REAL)
time_menu.add_radio_check_item(
"빠르게 (하루 %d분)" % int(DayCycle.FAST_DAY_MINUTES), TIME_FAST)
time_menu.add_separator("한 시각에 멈추기")
for i in DayCycle.FIXED_PRESETS.size():
var p: Dictionary = DayCycle.FIXED_PRESETS[i]
time_menu.add_radio_check_item(String(p.name), TIME_FIXED_BASE + i)
time_menu.id_pressed.connect(_on_time_menu_id)
func _sync_time_menu() -> void:
_check_in(time_menu, TIME_REAL, DayCycle.mode == DayCycle.Mode.REAL)
_check_in(time_menu, TIME_FAST, DayCycle.mode == DayCycle.Mode.FAST)
for i in DayCycle.FIXED_PRESETS.size():
var fixed := DayCycle.mode == DayCycle.Mode.FIXED
var same := is_equal_approx(
DayCycle.fixed_hour, float(DayCycle.FIXED_PRESETS[i].hour))
_check_in(time_menu, TIME_FIXED_BASE + i, fixed and same)
func _on_time_menu_id(id: int) -> void:
if id == TIME_REAL:
DayCycle.mode = DayCycle.Mode.REAL
hud.show_toast("시간을 실제 시각에 맞췄어요 · 지금 %s" % DayCycle.clock_text())
elif id == TIME_FAST:
DayCycle.mode = DayCycle.Mode.FAST
hud.show_toast("하루가 %d분에 한 바퀴 돕니다" % int(DayCycle.FAST_DAY_MINUTES))
elif id >= TIME_FIXED_BASE:
var p: Dictionary = DayCycle.FIXED_PRESETS[id - TIME_FIXED_BASE]
DayCycle.fixed_hour = float(p.hour)
DayCycle.mode = DayCycle.Mode.FIXED
hud.show_toast("%s로 멈춰 뒀어요 · %s" % [p.name, DayCycle.clock_text()])
_sync_time_menu()
func _check_in(target: PopupMenu, id: int, on: bool) -> void:
var index := target.get_item_index(id)
if index >= 0:
target.set_item_checked(index, on)
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))
_sync_time_menu()
_check(EDGE_BOTTOM, _edge_bottom)
_check(EDGE_TOP, not _edge_bottom)
if DisplayServer.get_screen_count() > 1:
@@ -270,6 +387,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()
+2
View File
@@ -26,3 +26,5 @@ visible = false
layout_mode = 1
[node name="ContextMenu" type="PopupMenu" parent="."]
[node name="TimeMenu" type="PopupMenu" parent="ContextMenu"]
+22 -4
View File
@@ -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
View File
@@ -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
+69
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://bdayflw4cn1co
+230 -77
View File
@@ -25,13 +25,15 @@ func _ready() -> void:
_test_zone_gating()
_test_zone_unlock()
_test_zone_beauty_is_per_zone()
_test_ramp_continuity()
_test_zone_colors()
_test_serialization_round_trip()
_test_legacy_save_migration()
_test_number_format()
_test_korean_particles()
_test_shape_families()
_test_upgrades_are_all_production()
_test_day_cycle()
_test_time_modes()
if _failures == 0:
print("모든 검사 통과")
@@ -56,9 +58,15 @@ func _rich_and_open() -> void:
## 그 구역에 집을 한 채 세워 자리를 만든다. 세운 집의 인덱스를 돌려준다.
## 정원이 표준(HOUSE_CAPACITY)인 집을 고른다 -- 거품돌처럼 정원이 다른 집을
## 기준으로 삼으면 자리 계산 검사가 통째로 흔들린다.
func _house_in(zone_index: int, id := "") -> int:
if id.is_empty():
id = Catalog.first_id_of_zone("house", zone_index)
for d in Catalog.HOUSES:
var same_zone := int(d.get("zone", 0)) == zone_index
if same_zone and Catalog.capacity_of(String(d.id)) == Catalog.HOUSE_CAPACITY:
id = String(d.id)
break
var spot := GameState.first_free_spot(id)
return GameState.build_house(id, spot.x, spot.y)
@@ -80,8 +88,8 @@ func _test_starting_state() -> void:
check("정원이 생물보다 넉넉하다", GameState.free_slots_in_zone(0) > 0,
"%d / %d" % [GameState.fish_total, GameState.capacity_total()])
check("시작 거품으로 한 마리를 더 들일 수 있다",
GameState.bubbles >= GameState.cost_of("producer", "bubble_stone")
and GameState.can_take_fish("bubble_stone"))
GameState.bubbles >= GameState.cost_of("producer", "guppy")
and GameState.can_take_fish("guppy"))
var s: Dictionary = Catalog.STARTER_HOUSES[0]
check("집이 정해 둔 칸에 선다",
@@ -98,11 +106,11 @@ func _test_auto_income() -> void:
check("집만으로는 거품이 나지 않는다", is_equal_approx(GameState.bps, 0.0),
str(GameState.bps))
GameState.buy_fish("seaweed")
GameState.buy_fish("hermit_crab")
check("생물이 들어와야 거품이 난다", GameState.bps > 0.0, str(GameState.bps))
var before_bps := GameState.bps
GameState.buy_fish("seaweed")
GameState.buy_fish("hermit_crab")
check("생물이 늘면 초당 생산도 는다", GameState.bps > before_bps,
"%f -> %f" % [before_bps, GameState.bps])
@@ -122,9 +130,9 @@ func _test_auto_income() -> void:
func _test_cost_curves() -> void:
print("가격 곡선")
_rich_and_open()
check("거품돌은 15",
is_equal_approx(GameState.cost_of("producer", "bubble_stone"), 15.0),
str(GameState.cost_of("producer", "bubble_stone")))
check("구피는 15",
is_equal_approx(GameState.cost_of("producer", "guppy"), 15.0),
str(GameState.cost_of("producer", "guppy")))
check("첫 항아리는 90",
is_equal_approx(GameState.cost_of("house", "amphora"), 90.0),
str(GameState.cost_of("house", "amphora")))
@@ -134,8 +142,8 @@ func _test_cost_curves() -> void:
check("집도 지을수록 비싸진다 (x1.15)",
is_equal_approx(house_second, ceil(90.0 * 1.15)), str(house_second))
GameState.buy_fish("bubble_stone")
var fish_second := GameState.cost_of("producer", "bubble_stone")
GameState.buy_fish("guppy")
var fish_second := GameState.cost_of("producer", "guppy")
check("생물도 들일수록 비싸진다 (x1.15)",
is_equal_approx(fish_second, ceil(15.0 * 1.15)), str(fish_second))
check("서로 값을 간섭하지 않는다",
@@ -205,10 +213,10 @@ func _test_zone_boundaries() -> void:
func _test_capacity() -> void:
print("정원")
_rich_and_open()
check("집이 없으면 한 마리도 못 들인다", not GameState.can_take_fish("bubble_stone"))
check("사유를 알려준다", GameState.fish_blocker("bubble_stone").contains("빈 자리"),
GameState.fish_blocker("bubble_stone"))
check("거품이 넘쳐도 못 들인다", not GameState.buy_fish("bubble_stone"))
check("집이 없으면 한 마리도 못 들인다", not GameState.can_take_fish("guppy"))
check("사유를 알려준다", GameState.fish_blocker("guppy").contains("빈 자리"),
GameState.fish_blocker("guppy"))
check("거품이 넘쳐도 못 들인다", not GameState.buy_fish("guppy"))
_house_in(0)
check("집을 지으면 정원이 생긴다",
@@ -216,14 +224,14 @@ func _test_capacity() -> void:
str(GameState.free_slots_in_zone(0)))
for i in Catalog.HOUSE_CAPACITY:
check("%d번째 자리가 찬다" % (i + 1), GameState.buy_fish("bubble_stone"))
check("%d번째 자리가 찬다" % (i + 1), GameState.buy_fish("guppy"))
check("정원이 다 찼다", GameState.free_slots_in_zone(0) == 0,
str(GameState.free_slots_in_zone(0)))
check("정원이 차면 더 못 들인다", not GameState.buy_fish("bubble_stone"))
check("종류를 바꿔도 마찬가지", not GameState.buy_fish("seaweed"))
check("정원이 차면 더 못 들인다", not GameState.buy_fish("guppy"))
check("종류를 바꿔도 마찬가지", not GameState.buy_fish("hermit_crab"))
_house_in(0)
check("집을 더 지으면 다시 들일 수 있다", GameState.buy_fish("seaweed"))
check("집을 더 지으면 다시 들일 수 있다", GameState.buy_fish("hermit_crab"))
check("정원은 집 수 x %d" % Catalog.HOUSE_CAPACITY,
GameState.capacity_in_zone(0) == 2 * Catalog.HOUSE_CAPACITY,
str(GameState.capacity_in_zone(0)))
@@ -251,7 +259,7 @@ func _test_move_house() -> void:
check("실패했으면 제자리", GameState.house_at(4, 2) == index)
check("제자리에 그대로 놓는 건 된다", GameState.move_house(index, 4, 2))
GameState.buy_fish("bubble_stone")
GameState.buy_fish("guppy")
check("살던 생물은 그대로 따라간다", GameState.move_house(index, 6, 4)
and GameState.fish_total == 1)
@@ -277,7 +285,7 @@ func _test_remove_house() -> void:
# 살고 있는 생물이 갈 곳이 없으면 헐 수 없다.
for i in Catalog.HOUSE_CAPACITY:
GameState.buy_fish("bubble_stone")
GameState.buy_fish("guppy")
check("세입자가 꽉 찬 마지막 집은 못 헌다", not GameState.can_remove_house(0))
check("헐려고 해도 아무것도 돌아오지 않는다",
is_equal_approx(GameState.remove_house(0), 0.0))
@@ -294,36 +302,39 @@ func _test_release_fish() -> void:
print("생물 내보내기")
_rich_and_open()
_house_in(0)
GameState.buy_fish("seaweed")
var second_cost := GameState.cost_of("producer", "seaweed")
GameState.buy_fish("seaweed")
GameState.buy_fish("hermit_crab")
var second_cost := GameState.cost_of("producer", "hermit_crab")
GameState.buy_fish("hermit_crab")
var before := GameState.bubbles
var expected: float = floor(second_cost * Catalog.REFUND_RATE)
var refund := GameState.release_fish("seaweed")
var refund := GameState.release_fish("hermit_crab")
check("절반을 돌려받는다", is_equal_approx(refund, expected),
"%f vs %f" % [refund, expected])
check("잔액에 더해진다", is_equal_approx(GameState.bubbles, before + expected))
check("마릿수가 줄었다", GameState.fish_count("seaweed") == 1)
check("마릿수가 줄었다", GameState.fish_count("hermit_crab") == 1)
check("자리가 다시 빈다",
GameState.free_slots_in_zone(0) == Catalog.HOUSE_CAPACITY - 1,
str(GameState.free_slots_in_zone(0)))
check("마지막 한 마리는 못 내보낸다", not GameState.can_release_fish("seaweed"))
check("마지막 한 마리는 못 내보낸다", not GameState.can_release_fish("hermit_crab"))
check("내보내려 해도 아무것도 돌아오지 않다",
is_equal_approx(GameState.release_fish("seaweed"), 0.0))
is_equal_approx(GameState.release_fish("hermit_crab"), 0.0))
check("그대로 남는다", GameState.fish_total == 1, str(GameState.fish_total))
GameState.buy_fish("bubble_stone")
check("둘이 되면 내보낼 수 있다", GameState.can_release_fish("seaweed"))
check("실제로 나간다", GameState.release_fish("seaweed") > 0.0)
GameState.buy_fish("guppy")
check("둘이 되면 내보낼 수 있다", GameState.can_release_fish("hermit_crab"))
check("실제로 나간다", GameState.release_fish("hermit_crab") > 0.0)
check("한 마리가 남는다", GameState.fish_total == 1, str(GameState.fish_total))
check("없는 종류는 못 내보낸다", not GameState.can_release_fish("seaweed"))
check("없는 종류는 못 내보낸다", not GameState.can_release_fish("hermit_crab"))
func _test_space_is_the_limit() -> void:
print("공간이 곧 한계")
_rich_and_open()
# 한 구역을 끝까지 채우려면 값이 1.15배씩 오르는 집을 200채 넘게 지어야 한다.
# 거품이 먼저 떨어지면 "칸이 차서" 멈춘 것인지 알 수 없다.
GameState.bubbles = 1e30
var cells := Catalog.GRID_ROWS * Catalog.ZONE_COLUMNS
check("구역 하나는 %d" % cells, GameState.free_cells_in_zone(0) == cells,
str(GameState.free_cells_in_zone(0)))
@@ -351,8 +362,9 @@ func _test_zone_gating() -> void:
GameState.reset(false)
GameState.bubbles = 1e15
check("시작은 1구역만 열림", GameState.unlocked_zones == 1)
check("0구역 물건은 보인다", GameState.is_revealed("producer", "bubble_stone")
and GameState.is_revealed("house", "amphora"))
# 각 구역의 첫 항목은 값을 벌기 전에도 보여야 한다. 안 그러면 상점이 비어 시작한다.
check("0구역 물건은 보인다", GameState.is_revealed("producer", "guppy")
and GameState.is_revealed("house", "bubble_stone"))
check("1구역 물건은 안 보인다", not GameState.is_revealed("producer", "clownfish")
and not GameState.is_revealed("house", "anchor"))
check("돈이 넘쳐도 잠긴 구역에는 못 짓는다",
@@ -360,7 +372,7 @@ func _test_zone_gating() -> void:
check("실패했으면 잔액 그대로", is_equal_approx(GameState.bubbles, 1e15))
GameState.unlocked_zones = 2
check("열린 뒤에는 보인다", GameState.is_revealed("house", "anchor"))
check("열린 뒤에는 보인다", GameState.is_revealed("house", "coral"))
check("열린 뒤에는 지어진다",
GameState.build_house("anchor", Catalog.ZONE_COLUMNS, 0) >= 0)
@@ -384,7 +396,7 @@ func _test_zone_unlock() -> void:
var spot := GameState.first_free_spot("amphora")
if spot.x < 0 or GameState.build_house("amphora", spot.x, spot.y) < 0:
break
elif not GameState.buy_fish("seaweed"):
elif not GameState.buy_fish("hermit_crab"):
break
check("칸 안에서 조건을 채울 수 있다", GameState.beauty_in_zone(0) >= need_beauty,
"%f / %f" % [GameState.beauty_in_zone(0), need_beauty])
@@ -400,42 +412,50 @@ func _test_zone_beauty_is_per_zone() -> void:
print("구역별 아름다움")
_rich_and_open()
_house_in(0) # 항아리, 아름다움 12
GameState.buy_fish("seaweed") # 0구역, 아름다움 3
GameState.buy_fish("hermit_crab") # 0구역, 아름다움 4
_house_in(1) # 낡은 닻, 아름다움 40
GameState.buy_fish("clownfish") # 1구역, 아름다움 8
check("0구역에 15", is_equal_approx(GameState.beauty_in_zone(0), 15.0),
check("0구역에 16", is_equal_approx(GameState.beauty_in_zone(0), 16.0),
str(GameState.beauty_in_zone(0)))
check("1구역에 48", is_equal_approx(GameState.beauty_in_zone(1), 48.0),
str(GameState.beauty_in_zone(1)))
check("총합은 63", is_equal_approx(GameState.beauty, 63.0), str(GameState.beauty))
check("총합은 64", is_equal_approx(GameState.beauty, 64.0), str(GameState.beauty))
check("건드리지 않은 구역은 0", is_equal_approx(GameState.beauty_in_zone(4), 0.0))
func _test_ramp_continuity() -> void:
print("색 램프")
## 한 화면에 한 층만 보이므로 층 색은 이웃과 섞지 않고 제 색을 그대로 쓴다.
func _test_zone_colors() -> void:
print("층 색")
var n := Catalog.zone_count()
check("왼쪽 끝은 첫 구역",
check("왼쪽 끝은 첫 ",
Catalog.ramp_color("tint", 0.0).is_equal_approx(Catalog.ZONES[0].tint))
check("오른쪽 끝은 마지막 구역",
check("오른쪽 끝은 마지막 ",
Catalog.ramp_color("tint", 1.0).is_equal_approx(Catalog.ZONES[n - 1].tint))
# 램프가 끊기지 않아야 구역 경계에 이음매가 보이지 않는다.
var max_jump := 0.0
var prev := Catalog.ramp_color("tint", 0.0)
for i in range(1, 201):
var c := Catalog.ramp_color("tint", float(i) / 200.0)
max_jump = maxf(max_jump, absf(c.r - prev.r) + absf(c.g - prev.g) + absf(c.b - prev.b))
prev = c
check("램프에 색이 튀는 곳이 없다", max_jump < 0.05, "최대 변화 %f" % max_jump)
# 구역 중심에서는 그 구역 값 그대로여야 한다.
var center_ok := true
# 층 안 어디를 찍어도 그 층의 색이어야 한다. 안 그러면 한 화면 안에서
# 좌우로 색이 흐르는데, 이제 깊이는 가로가 아니라 층으로 표현한다.
var flat := true
for i in n:
var f := (float(i) + 0.5) / n
if not Catalog.ramp_color("sand", f).is_equal_approx(Catalog.ZONES[i].sand):
center_ok = false
if not is_equal_approx(Catalog.ramp_float("murk", f), float(Catalog.ZONES[i].murk)):
center_ok = false
check("구역 중심은 그 구역 값 그대로", center_ok)
for k in 9:
var f := (float(i) + 0.05 + 0.9 * k / 8.0) / n
if not Catalog.ramp_color("sand", f).is_equal_approx(Catalog.ZONES[i].sand):
flat = false
if not is_equal_approx(Catalog.ramp_float("murk", f), float(Catalog.ZONES[i].murk)):
flat = false
check("층 안에서는 색이 평평하다", flat)
var deepens := true
for i in range(1, n):
if Catalog.ZONES[i].murk <= Catalog.ZONES[i - 1].murk:
deepens = false
check("아래층일수록 흐리다", deepens)
var picks := true
for i in n:
if Catalog.zone_at((float(i) + 0.5) / n) != i:
picks = false
check("가로 위치로 층을 바르게 찾는다", picks)
func _test_serialization_round_trip() -> void:
@@ -443,8 +463,8 @@ func _test_serialization_round_trip() -> void:
_rich_and_open()
GameState.build_house("amphora", 1, 1)
GameState.build_house("shipwreck", Catalog.ZONE_COLUMNS + 1, 2)
GameState.buy_fish("seaweed")
GameState.buy_fish("seaweed")
GameState.buy_fish("hermit_crab")
GameState.buy_fish("hermit_crab")
GameState.buy_fish("clownfish")
GameState.upgrades["u_ripple"] = true
# 배치가 끝난 뒤에 잔액을 맞춘다. 짓고 들이는 데 값이 들기 때문이다.
@@ -464,7 +484,7 @@ func _test_serialization_round_trip() -> void:
GameState.house_at(1, 1) >= 0 and GameState.house_at(wreck, 2) >= 0)
check("난파선 발자국도 복원",
GameState.house_at(wreck + 2, 3) == GameState.house_at(wreck, 2))
check("생물 마릿수 복원", GameState.fish_count("seaweed") == 2
check("생물 마릿수 복원", GameState.fish_count("hermit_crab") == 2
and GameState.fish_count("clownfish") == 1)
check("정원도 그대로", GameState.capacity_in_zone(0) == Catalog.HOUSE_CAPACITY,
str(GameState.capacity_in_zone(0)))
@@ -478,6 +498,7 @@ func _test_serialization_round_trip() -> void:
func _test_legacy_save_migration() -> void:
print("옛 저장본 이전")
# 생물도 칸을 차지하던 시절. 장식이 지금의 집이 되고, 생물은 마릿수만 남는다.
# 돌·해초처럼 그때는 생물이었다가 지금은 집이 된 것은 그만큼 집으로 지어 준다.
GameState.reset(false)
GameState.from_dict({
"bubbles": 500.0,
@@ -490,26 +511,32 @@ func _test_legacy_save_migration() -> void:
{"kind": "ornament", "id": "anchor", "col": Catalog.ZONE_COLUMNS + 2, "row": 0},
],
})
check("장식이 집이 된다", GameState.houses.size() == 2, str(GameState.houses.size()))
# 항아리 + 닻 + 해초 두 포기 = 네 채.
check("장식이 집이 된다", GameState.houses.size() == 4, str(GameState.houses.size()))
check("집이 선 칸은 그대로", GameState.house_at(3, 0) >= 0)
check("생물은 마릿수로 남는", GameState.fish_count("seaweed") == 2,
str(GameState.fish_count("seaweed")))
check("생물은 칸을 놓아준", GameState.house_at(0, 0) == -1)
check("각자 제 구역에 남는다", GameState.fish_in_zone(0) == 2
and GameState.fish_in_zone(1) == 1)
check("해초는 집으로 넘어온", GameState.house_count("seaweed") == 2,
str(GameState.house_count("seaweed")))
check("해초는 더 이상 생물이 아니", GameState.fish_count("seaweed") == 0)
check("헤엄치던 것은 마릿수로 남는다", GameState.fish_count("clownfish") == 1,
str(GameState.fish_count("clownfish")))
check("생물은 칸을 놓아준다", GameState.fish_total == 1, str(GameState.fish_total))
check("각자 제 구역에 남는다", GameState.fish_in_zone(1) == 1,
str(GameState.fish_in_zone(1)))
check("보유량은 그대로", is_equal_approx(GameState.bubbles, 500.0))
# 정원보다 많이 살고 있으면 집을 세워 다 들어가게 해준다.
# 개수만 있던 저장본도 같은 규칙으로 옮겨온다.
GameState.reset(false)
GameState.from_dict({
"bubbles": 1e9,
"producers": {"seaweed": 10},
"producers": {"seaweed": 10, "guppy": 4},
"ornaments": {"amphora": true},
})
check("옛 개수 저장본도 옮겨온다", GameState.fish_count("seaweed") == 10,
str(GameState.fish_count("seaweed")))
check("한 마리도 내쫓지 않는다", GameState.fish_total == 10, str(GameState.fish_total))
check("정원이 모자라면 집을 더 세워준다", GameState.capacity_in_zone(0) >= 10,
check("옛 개수 저장본도 옮겨온다", GameState.house_count("seaweed") == 10,
str(GameState.house_count("seaweed")))
check("헤엄치던 것은 그대로 마릿수", GameState.fish_count("guppy") == 4,
str(GameState.fish_count("guppy")))
check("한 마리도 내쫓지 않는다", GameState.fish_total == 4, str(GameState.fish_total))
check("정원이 모자라면 집을 더 세워준다", GameState.capacity_in_zone(0) >= 4,
"정원 %d / 생물 %d" % [GameState.capacity_in_zone(0), GameState.fish_total])
check("보이지 않는 생물이 남지 않는다", GameState.free_slots_in_zone(0) >= 0,
str(GameState.free_slots_in_zone(0)))
@@ -549,8 +576,9 @@ func _test_shape_families() -> void:
print("형태 계통")
# 집과 생물이 같은 도형을 쓰면 화면에서 "건물"과 "사는 것"이 구분되지 않는다.
# 그래서 두 계통을 아예 겹치지 않게 갈라 두고, 그것을 여기서 지킨다.
const CREATURE_SHAPES := ["plant", "rock", "fish", "glow"]
const HOUSE_SHAPES := ["vessel", "wreck", "pillar"]
# 사는 것은 헤엄치거나 걷는 해양동물, 사는 곳은 자연 서식지와 지어 올린 것.
const CREATURE_SHAPES := ["fish", "glow", "crab", "seahorse", "shrimp"]
const HOUSE_SHAPES := ["rock", "plant", "vessel", "wreck", "pillar"]
var bad_creature := ""
for d in Catalog.PRODUCERS:
@@ -587,6 +615,24 @@ func _test_shape_families() -> void:
starter_swims = true
check("시작 생물에도 헤엄치는 것이 있다", starter_swims)
# 고착생물은 전부 집으로 옮겼다. 생물 목록에 다시 섞여 들어오면 안 된다.
var sessile := ""
for d in Catalog.PRODUCERS:
if String(d.shape) in ["plant", "rock"]:
sessile = String(d.name)
check("생물에 고착생물이 없다", sessile.is_empty(), sessile)
# 자연 서식지가 하나도 없는 구역이 있으면 값싼 첫 자리가 없어 시작이 막힌다.
var starter_home := true
for z in Catalog.zone_count():
var cheapest := INF
for d in Catalog.HOUSES:
if int(d.get("zone", 0)) == z:
cheapest = minf(cheapest, float(d.base_cost))
if not is_finite(cheapest):
starter_home = false
check("구역마다 지을 집이 있다", starter_home)
func _test_upgrades_are_all_production() -> void:
print("업그레이드")
@@ -598,7 +644,7 @@ func _test_upgrades_are_all_production() -> void:
_rich_and_open()
_house_in(0)
GameState.buy_fish("seaweed")
GameState.buy_fish("hermit_crab")
var before := GameState.bps
var id := String(Catalog.UPGRADES[0].id)
check("살 수 있다", GameState.buy_upgrade(id))
@@ -607,3 +653,110 @@ func _test_upgrades_are_all_production() -> void:
check("같은 것을 두 번은 못 산다", not GameState.buy_upgrade(id))
check("업그레이드는 칸도 자리도 안 먹는다",
GameState.free_cells_in_zone(0) > 0 and GameState.capacity_in_zone(0) > 0)
func _test_day_cycle() -> void:
print("하루의 빛")
var before_mode := DayCycle.mode
var before_hour := DayCycle.fixed_hour
DayCycle.mode = DayCycle.Mode.FIXED
DayCycle.fixed_hour = 5.0
check("새벽 5시는 새벽", DayCycle.phase == "새벽", DayCycle.phase)
DayCycle.fixed_hour = 8.0
check("아침 8시는 아침", DayCycle.phase == "아침", DayCycle.phase)
DayCycle.fixed_hour = 13.0
check("오후 1시는 낮", DayCycle.phase == "", DayCycle.phase)
DayCycle.fixed_hour = 18.0
check("저녁 6시는 저녁", DayCycle.phase == "저녁", DayCycle.phase)
DayCycle.fixed_hour = 22.0
check("밤 10시는 밤", DayCycle.phase == "", DayCycle.phase)
# 자정을 넘긴 시각은 전날 밤이 이어져야 한다. 여기서 구간이 비면 이름이 빈다.
DayCycle.fixed_hour = 1.0
check("새벽 1시도 밤", DayCycle.phase == "", DayCycle.phase)
# 가장 밝은 시각이 한낮 언저리에 있어야 한다. 정점 시각 자체는 표에서 조절한다.
var peak_hour := 0.0
var peak_light := -1.0
for i in 480:
var h := float(i) / 20.0
var l := DayCycle.light_at(h)
if l > peak_light:
peak_light = l
peak_hour = h
check("가장 밝은 때가 한낮 언저리", peak_hour >= 10.0 and peak_hour <= 14.0,
"%.1f" % peak_hour)
check("한낮 밝기는 1.0", is_equal_approx(peak_light, 1.0), str(peak_light))
DayCycle.fixed_hour = 0.0
var midnight := DayCycle.light
DayCycle.fixed_hour = 24.0
check("자정과 24시가 이어진다", is_equal_approx(DayCycle.light, midnight),
"%f vs %f" % [DayCycle.light, midnight])
# 빛이 튀면 해가 순간이동하는 것처럼 보인다. 하루를 훑으며 급변이 없는지 본다.
var max_jump := 0.0
DayCycle.fixed_hour = 0.0
var prev := DayCycle.light
for i in range(1, 481):
DayCycle.fixed_hour = float(i) / 20.0
max_jump = maxf(max_jump, absf(DayCycle.light - prev))
prev = DayCycle.light
check("빛이 튀는 곳이 없다", max_jump < 0.05, "최대 변화 %f" % max_jump)
var night := DayCycle.tint_at(2.0)
var noon := DayCycle.tint_at(12.0)
check("밤은 낮보다 어둡다", night.r + night.g + night.b < noon.r + noon.g + noon.b)
check("저녁은 붉은 쪽으로 기운다", DayCycle.tint_at(18.5).r > DayCycle.tint_at(18.5).b)
DayCycle.fixed_hour = before_hour
DayCycle.mode = before_mode
func _test_time_modes() -> void:
print("시간 설정")
var before_mode := DayCycle.mode
var before_hour := DayCycle.fixed_hour
DayCycle.fixed_hour = 22.0
DayCycle.mode = DayCycle.Mode.FIXED
check("고정하면 그 시각에 멈춘다", is_equal_approx(DayCycle.hour(), 22.0),
str(DayCycle.hour()))
check("고정한 시각의 이름이 맞다", DayCycle.phase == "", DayCycle.phase)
check("시계 문자열", DayCycle.clock_text() == "22:00", DayCycle.clock_text())
DayCycle.fixed_hour = 18.7
check("분까지 나온다", DayCycle.clock_text() == "18:42", DayCycle.clock_text())
# 24시를 넘겨 넣어도 하루 안으로 돌아와야 한다. 안 그러면 램프가 끝에 붙어 멈춘다.
DayCycle.fixed_hour = 26.0
check("24시를 넘기면 돌아 들어온다", is_equal_approx(DayCycle.hour(), 2.0),
str(DayCycle.hour()))
# 빠르게로 넘어갈 때 보던 시각에서 이어져야 한다. 0시로 튀면 한밤중으로 뚝 떨어진다.
DayCycle.fixed_hour = 15.0
DayCycle.mode = DayCycle.Mode.FIXED
DayCycle.mode = DayCycle.Mode.FAST
check("빠르게는 보던 시각에서 이어진다", absf(DayCycle.hour() - 15.0) < 0.01,
str(DayCycle.hour()))
var start := DayCycle.hour()
DayCycle._process(30.0)
var moved := DayCycle.hour() - start
# 하루 5분이면 30초에 2.4시간이 흐른다.
var expected := 30.0 * 24.0 / (DayCycle.FAST_DAY_MINUTES * 60.0)
check("빠르게는 압축한 만큼 흐른다", absf(moved - expected) < 0.05,
"%f vs %f" % [moved, expected])
DayCycle.mode = DayCycle.Mode.REAL
var now := Time.get_time_dict_from_system()
check("실제 시각으로 돌아온다", absf(DayCycle.hour() - float(now.hour)) < 1.01,
"%f vs %d" % [DayCycle.hour(), now.hour])
# 메뉴에 올리는 프리셋이 모두 제 이름의 구간에 들어 있어야 한다.
var named := true
for p in DayCycle.FIXED_PRESETS:
if DayCycle.phase_at(float(p.hour)) != String(p.name):
named = false
check("고정 프리셋 이름이 구간과 맞는다", named)
DayCycle.fixed_hour = before_hour
DayCycle.mode = before_mode