Compare commits

..

10 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
ttipo 89069b06f0 클리커에서 집·일꾼 방치형 구조로 전환
거품은 이제 클릭이 아니라 바다에 있는 생물이 시간에 따라 번다. 화면 가장자리에
깔아 두는 위젯인데 클릭이 주된 수입원이면 연타가 최적 플레이가 되어 형태와
어긋난다. 클릭 수익과 클릭 업그레이드를 걷어내고 업그레이드는 전부 생산 배율로
바꿨다. 물을 만지면 거품이 퍼지지만 얻는 것은 없다.

격자 칸을 차지하는 것은 집뿐이고, 생물은 칸이 아니라 집에 산다(한 채당 3마리).
칸 → 집 → 생물 → 거품으로 한계가 두 겹 걸린다. 예전에는 생물과 장식이 같은
자원(칸)을 놓고 경쟁해서 최적 플레이가 늘 "장식을 사지 말 것"이었는데, 집과
일꾼으로 나누니 그 경쟁이 의존으로 바뀐다. 집은 같은 종류도 여러 채 지을 수 있고
거품을 벌지 않는다 — 정원과 생산을 한 물건이 주면 곱연산이 두 번 걸린다.

어느 생물이 어느 집에 사는지는 저장하지 않는다. 집은 정원만 주므로 어디 살든
결과가 같고, 저장하면 재배정과 인덱스 관리만 붙는다. 그릴 때 구역마다 배정한다.
그래서 저장 형식에서 생물은 위치 없이 {id: 마리 수}다. 옛 저장본은 그때의
장식을 집으로, 생물을 마릿수로 옮겨오고 정원이 모자라면 집을 세워 메운다.

수입이 생물에서만 나오므로 막다른 길을 두 곳에서 막았다. 마지막 생물은 내보낼 수
없고, 살던 생물이 갈 곳이 없어지는 집은 헐 수 없다.

집과 생물의 그림 형태를 갈랐다. 집 10채 중 8채가 생물과 같은 rock이어서 항아리와
거품돌이 색만 다른 같은 덩어리로 보였다. 집은 vessel/wreck/pillar만, 생물은
plant/rock/fish/glow만 쓴다. 헤엄치는 생물은 집을 도는 타원 궤도에 올라가고,
원점이 궤도를 따라 움직이므로 Y 정렬이 앞뒤를 알아서 바꿔준다 — 뒤로 돌면 집에
가렸다가 앞으로 나온다. 얕은 바다 생물이 거품돌과 해초뿐이라 이 움직임을 볼 수
없어서 구피를 넣었다.

실행해 보고 잡은 버그 셋:
- 저장본이 없으면 load_game이 그냥 반환해 빈 바다로 시작했다. 집도 생물도 없어
  아무 일도 일어나지 않는다. 이제 새 게임을 시작한다.
- 창 높이·도킹·모니터 설정이 복원되지 않고 기본값으로 덮어써지고 있었다.
  window_state에 미리 있던 키만 복사하던 것을 저장본에 있는 것을 전부 싣도록 고쳤다.
- 정보 바가 격자 첫 줄을 덮어 왼쪽 위에 놓인 것이 보이지 않았다. GRID_TOP 상수를
  버리고 HUD가 잰 실제 바 높이를 받아 쓴다.
- main.gd가 초기화할 때 존재하지 않는 aquarium.rebuild()를 부르고 있었다.

스모크 테스트 159개.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uze4mF1NAGfFRio77cMFqg
2026-09-01 22:39:25 +09:00
ttipo b490e463b0 Godot 실행 스크립트가 설치 경로를 스스로 찾도록
run.ps1과 test.ps1이 %LOCALAPPDATA%\Programs\Godot 한 곳만 보고 있어서 다른 데
설치하면 "Godot을 찾을 수 없습니다"로 끝났다. find-godot.ps1을 두어 GODOT_BIN
환경 변수 → 알려진 설치 폴더들(바탕화면·다운로드 포함) → PATH 순으로 찾고,
필요한 판(콘솔/일반)을 가려 쓰게 했다. 이름으로 훑기 때문에 버전이 올라가도 잡힌다.

클론 직후에는 .godot이 없어 class_name이 등록되지 않는다. 그 상태로 실행하면
game_state.gd 파싱이 실패해 오토로드가 통째로 죽으므로, 두 스크립트가 먼저
--import를 한 번 돌린다.

세 파일 모두 UTF-8 BOM으로 저장했다. BOM이 없으면 Windows PowerShell 5.1이
cp949로 읽어 한글 메시지가 깨진다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uze4mF1NAGfFRio77cMFqg
2026-09-01 22:38:56 +09:00
26 changed files with 3350 additions and 778 deletions
+74 -29
View File
@@ -1,28 +1,44 @@
# Blub Blub
화면 아래 가장자리에 가로로 깔리는 바닷속 방치형 스트립.
다른 일을 하는 동안 화면 밑에서 조용히 돌아간다. 거품을 모아 바다를 꾸미고, 꾸밀수록 거품이 더 빨리 모인다.
다른 일을 하는 동안 화면 밑에서 조용히 돌아간다. **집을 지어 자리를 만들고, 그 자리에 생물을 들이면**
생물이 알아서 거품을 모은다. 모은 거품으로 바다를 꾸미면 더 빨리 모인다.
창은 화면 폭 전체를 차지하고 높이만 160~300px이다. 작업표시줄은 덮지 않는다.
## 실행
- 에디터: `%LOCALAPPDATA%\Programs\Godot\Godot_v4.7.2-stable_win64.exe` 를 실행해 이 폴더를 열거나, 이미 임포트되어 있으면 프로젝트 목록에서 **Blub Blub** 선택
- 게임만: `.\run.ps1`
- 테스트: `.\test.ps1`
- 에디터: Godot 실행 파일로 이 폴더를 연다. 이미 임포트되어 있으면 프로젝트 목록에서 **Blub Blub** 선택
`run.ps1``test.ps1`은 Godot을 `%LOCALAPPDATA%\Programs\Godot`, 바탕화면, 다운로드,
`%ProgramFiles%\Godot`, PATH 순으로 찾는다. 다른 데 있으면 `GODOT_BIN` 환경 변수에 경로를 넣으면 된다.
클론 직후처럼 `.godot`이 없으면 두 스크립트가 먼저 임포트를 한 번 돌린다
(임포트 전에는 `class_name`이 등록되지 않아 스크립트가 통째로 안 뜬다).
## 조작
| 동작 | 방법 |
| --- | --- |
| 거품 모으기 | 빈 곳 좌클릭 |
| 놓기 | 상점에서 고른 뒤 칸을 클릭 (우클릭·Esc로 취소) |
| 옮기기 | 놓인 것을 끌어다 다른 칸에 놓기 (공짜) |
| 치우기 | 끌어서 아래 휴지통에 떨구기 (절반 환불) |
| 물 만지기 | 빈 곳 좌클릭 (거품이 퍼진다 · 거품은 생물이 번다) |
| 층 오가기 | 왼쪽 아래 층 선택기 · 마우스 휠 · ↑↓ 키 |
| 감상 모드 | **F11** 또는 메뉴 · Esc나 F11로 돌아온다 |
| 집 짓기 | 상점 **집** 탭에서 고른 뒤 칸을 클릭 (우클릭·Esc로 취소) |
| 생물 들이기 | 상점 **생물** 탭에서 클릭 — 그 자리에서 바로 들어온다 (빈 자리 필요) |
생물은 물고기·게·해마·새우처럼 **움직이는 해양동물**뿐이다.
돌과 해초·산호·켈프·관벌레는 물고기가 깃드는 **서식지**라서 집 쪽에 있다.
| 집 옮기기 | 집을 끌어다 다른 칸에 놓기 (공짜 · 살던 생물도 따라간다) |
| 집 헐기 | 집을 끌어서 아래 휴지통에 (절반 환불 · 세입자가 갈 곳이 있어야 한다) |
| 생물 내보내기 | 생물을 끌어서 아래 휴지통에 (절반 환불 · 마지막 한 마리는 안 된다) |
| 상점 열기 | 왼쪽 위 **상점** (패널은 오른쪽에 열린다) |
| 메뉴 | **···** 버튼 또는 물 위 우클릭 |
메뉴: 항상 위에 표시 · 클릭 통과 · 높이(낮게/보통/높게) · 위아래 도킹 · 화면 선택(모니터가 여러 대일 때) · 지금 저장 · 처음부터 다시 · 종료
메뉴: 항상 위에 표시 · 클릭 통과 · 전체화면으로 감상 · **시간** · 높이(낮게/보통/높게) · 위아래 도킹 · 화면 선택(모니터가 여러 대일 때) · 지금 저장 · 처음부터 다시 · 종료
**시간** 하위 메뉴에서 바다의 하루를 정한다 — 실제 시각(기본), 빠르게(하루 5분),
또는 새벽/아침/낮/저녁/밤 한 시각에 멈추기. 고른 값은 저장된다.
**클릭 통과**를 켜면 클릭이 바탕화면으로 지나가고, 안쪽 모서리(84×56px)만 계속 눌린다.
이 모서리를 눌러 메뉴로 돌아와 통과를 끌 수 있다. (아래 도킹이면 오른쪽 위, 위 도킹이면 오른쪽 아래)
@@ -30,15 +46,18 @@
```
project.godot 창 설정(투명·테두리 없음·항상 위·1:1 픽셀)과 오토로드 등록
run.ps1 / test.ps1 게임 실행 / 스모크 테스트 (find-godot.ps1이 Godot을 찾아준다)
autoload/
catalog.gd 밸런스 데이터 전부. 아이템 추가는 여기서만.
game_state.gd 보유 자원·구매 상태와 파생 수치(bps, 아름다움, 등급)
day_cycle.gd 실제 시각에 맞춘 하루의 빛 (새벽/아침/낮/저녁/밤)
game_state.gd 보유 자원·배치 상태와 파생 수치(bps, 아름다움, 아름다움 배율)
save_manager.gd 저장/불러오기, 30초 자동 저장, 오프라인 보상, 창 위치
scenes/
main.tscn/.gd 도킹(폭 전체·가장자리 스냅)·클릭 통과·메뉴와 화면 조립
aquarium/ 물빛 셰이더, 모래 바닥, 물속 오브젝트
aquarium/ 먼 바다 배경, 격자 바닥, 물빛 셰이더, 물속 오브젝트
ui/ 왼쪽 위 정보 바, 오른쪽 상점 패널
scripts/
korean.gd 받침을 보고 을/를·이/가를 고른다
number_format.gd 1234 -> "1.23K", 경과 시간 표기
ui_style.gd UI 색과 스타일박스
tests/smoke.tscn/.gd 경제 로직 스모크 테스트
@@ -46,9 +65,13 @@ tests/smoke.tscn/.gd 경제 로직 스모크 테스트
## 게임 루프
1. 물 아무 곳이나 클릭해 **거품**을 모은다.
2. 거품으로 **생물**(해초, 물고기…)을 사면 초당 생산량과 **아름다움**이 오른다.
3. **장식**(난파선, 석상…)은 한 번만 살 수 있고, 아름다움이 크며 전체 생산에 곱연산 보너스를 준다.
1. 바다에 있는 **생물**이 시간에 따라 알아서 **거품**을 모은다. 클릭으로 버는 길은 없다.
그래서 새로 시작하면 **항아리 집 한 채와 거품돌·구피 한 마리씩**이 이미 있고, 거품 20을 쥐고 시작한다.
2. 생물은 **집**에 산다. 다만 집은 몇 마리를 들일 수 있는지 세는 단위일 뿐,
헤엄치는 자리는 **층 전체**다. 집에는 두 갈래가 있다 — 돌·해초·산호처럼 **자연 서식지**(싸고 적게 들인다)와
항아리·난파선·석주처럼 **지어 올린 것**(비싸고 정원이 넓다). 더 키우려면 먼저 집을 지어야 한다.
집은 칸을 차지하고 거품을 벌지 않는다. 자리와 **아름다움**을 준다.
3. 생물을 들이면 초당 생산량과 아름다움이 오른다. 비싼 생물일수록 한 마리가 더 번다.
4. 아름다움은 다시 전체 생산량에 `1 + sqrt(아름다움) × 0.02` 배율로 되돌아온다.
**꾸미는 것이 곧 성장이다.**
5. 앞 구역을 충분히 꾸미고 거품을 모으면 오른쪽 다음 구역이 열린다.
@@ -56,27 +79,48 @@ tests/smoke.tscn/.gd 경제 로직 스모크 테스트
## 격자와 배치
바다는 **3/4 부감 격자**다. 구역당 9열 × 6행, 전체 45× 6행 = 270칸.
바닥은 위에서 내려다보지만 그 위에 놓인 것은 **서 있는 모습**으로 그린다.
바다는 **3/4 부감 격자**다. **한 화면에 한 층**만 담기고, 층마다 36× 6행 = 216칸이다.
바닥은 위에서 내려다보지만 그 위에 것은 **서 있는 모습**으로 그린다.
러스티의 농장과 같은 방식이고, 완전한 탑다운과는 다르다.
산 것은 모두 플레이어가 고른 칸에 놓이고, **칸이 곧 한계다.** 거품이 아무리 많아도
자리가 없으면 더 놓을 수 없다. 그래서 성장은 세 갈래로 갈린다.
1. 다음 구역을 열어 자리를 늘린다
2. 싼 것을 치우고 비싼 것으로 갈아 끼운다 (치우면 절반을 돌려받는다)
3. 칸을 차지하지 않는 업그레이드를 산다
격자 칸을 차지하는 것은 **집뿐이다.** 생물은 칸이 아니라 **집에 산다.**
한 채가 3마리를 들이므로, 한 구역에서 키울 수 있는 수는 그 구역에 지은 집 × 3이다.
그래서 한계가 두 겹으로 걸린다.
장식은 여러 칸을 차지한다. 난파선·잠수정·고래 뼈는 3×2, 석주·석상·굴뚝·수정은 2×2,
항아리와 닻은 2×1. 자리를 많이 먹는 만큼 아름다움이 크다.
```
칸 → 집을 몇 채 지을 수 있는가
집 → 생물을 몇 마리 키울 수 있는가
생물 → 거품을 얼마나 버는가
```
**배치 방법**: 상점에서 고르면 상점이 잠시 닫히고 배치 모드가 된다.
놓을 칸을 클릭하면 그때 값이 지불된다. 우클릭이나 Esc로 취소.
이미 놓인 것은 끌어서 옮길 수 있고(공짜), 끄는 동안 아래에 나타나는
휴지통에 떨구면 치우고 절반을 돌려받는다.
거품이 아무리 많아도 자리가 없으면 생물을 더 들일 수 없다. 성장은 세 갈래다.
1. 집을 더 지어 자리를 늘린다 (칸을 먹는다)
2. 싼 생물을 내보내고 비싼 생물로 갈아 끼운다 (내보내면 절반을 돌려받는다)
3. 다음 구역을 열어 칸 자체를 늘린다
집은 여러 칸을 차지한다. 난파선·잠수정·고래 뼈는 3×2, 석주·석상·굴뚝·수정은 2×2,
항아리와 닻은 2×1, 유리병 편지는 1×1이다. **정원은 어느 집이든 3으로 같으므로**,
넓은 집은 아름다움이 크고 좁은 집은 칸을 아낀다. 그 사이에서 고르는 것이 배치의 고민이다.
**집 짓는 법**: 상점 집 탭에서 고르면 상점이 잠시 닫히고 배치 모드가 된다.
지을 칸을 클릭하면 그때 값이 지불된다. 우클릭이나 Esc로 취소.
이미 지은 집은 끌어서 옮길 수 있고(공짜), 끄는 동안 아래에 나타나는
휴지통에 떨구면 헐고 절반을 돌려받는다. 단 살고 있는 생물이 남는 집에 다 들어가야 한다.
**생물 들이는 법**: 상점 생물 탭에서 클릭하면 끝이다. 자리를 고를 필요가 없고,
그 구역의 집에 알아서 배정된다. 내보낼 때만 끌어서 휴지통에 넣는다.
**물고기는 집을 맴돈다.** 헤엄치는 생물(물고기·해파리)은 제 집을 중심으로 타원을 그리며
돌고, 뒤로 돌아갈 때는 집에 가려 들어간 것처럼 보였다가 앞으로 다시 나온다.
해초나 거품돌처럼 바닥에 붙는 생물은 집 둘레 제자리에 선다.
집(`vessel`·`wreck`·`pillar`)과 생물(`plant`·`rock`·`fish`·`glow`)은 그림 형태가 아예
갈라져 있어서, 어느 것이 건물이고 어느 것이 사는 것인지 한눈에 읽힌다.
## 구역
가로가 다섯 구역으로 나뉘어 있고, **왼쪽에서 오른쪽으로 갈수록 깊어진다.**
다섯 층이 있고, **위층에서 아래층으로 내려갈수록 깊어진다.**
왼쪽 아래 층 선택기, 마우스 휠, ↑↓ 키로 오간다.
처음에는 맨 왼쪽 얕은 바다만 열려 있고 나머지는 어둡게 덮여 있다.
잠긴 구역을 클릭하면 해금 조건과 남은 양이 그 자리에 뜬다.
@@ -85,12 +129,13 @@ tests/smoke.tscn/.gd 경제 로직 스모크 테스트
1. **바로 앞 구역의 아름다움** — 앞바다를 대충 두고 다음으로 넘어갈 수 없다.
2. **해금 비용(거품)**
생물과 장식은 각자 자기 구역에 속한다. 그 구역이 열려야 상점에 나타나고,
그 구역 밖으로는 놓을 수 없다(구역 경계를 걸치는 것도 안 된다).
생물과 은 각자 자기 구역에 속한다. 그 구역이 열려야 상점에 나타나고,
집은 그 구역 밖에 지을 수 없다(구역 경계를 걸치는 것도 안 된다).
생물도 자기 구역의 집에만 들어가므로, 얕은 바다에 빈 자리가 남아도
산호초 물고기를 거기 들일 수는 없다.
모래색과 물빛은 구역 중심 색을 기준으로 이어 붙여서 경계가 보이지 않게 그린다.
자세한 수치와 앞으로 붙일 것들은 [docs/DESIGN.md](docs/DESIGN.md) 참고.
## 저장 위치
`%APPDATA%\Godot\app_userdata\Blub Blub\save.json` (한 세대 전 백업은 `save.bak.json`)
+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);
}
+164 -102
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: 바로 앞 구역에 쌓인 아름다움이 이만큼은 되어야 한다.
## "앞 구역을 충분히 꾸며야 다음이 열린다"는 규칙.
@@ -44,157 +45,222 @@ const ZONES := [
},
]
## 생물 — 한 칸을 차지한다. 칸이 곧 한계이므로 무한히 살 수는 없다.
## zone : 어느 구역에 사는지. 구역이 해금되어야 살 수 있고, 그 구역 안에만 놓을 수 있다.
## bps : 개당 초당 거품 생산량
## shape : plant / rock(바닥에 고정) / fish / glow(제 자리 위를 떠다님)
## height: 칸 높이의 몇 배로 서 있는지. 1보다 크면 뒷줄을 가린다.
## 생물 — 일꾼이다. 거품을 버는 것은 오직 이들이고, 격자 칸은 차지하지 않는다.
## 대신 집에 산다. 구역에서 키울 수 있는 수는 그 구역에 지은 집의 정원 합이다.
## zone : 어느 구역에 사는지. 그 구역이 해금되고, 그 구역에 빈 자리가 있어야 살 수 있다.
## bps : 한 마리당 초당 거품 생산량
## shape : plant / rock — 집 둘레 제자리에 붙어 선다
## fish / glow — 집 둘레를 돌며 뒤로 돌아 들어갔다 나온다
## 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": 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,
},
]
## 장식 — 구역당 두 개, 한 번만 살 수 있다. 여러 칸을 차지하는 대신 아름다움이 크다.
## size: 바닥에서 차지하는 칸 수 (열, 행)
const ORNAMENTS := [
## 집 — 생물이 사는 곳. 격자 칸을 차지하는 것은 이쪽뿐이다.
## 자연 서식지(돌·해초·산호·켈프·관벌레)와 지어 올린 것(항아리·난파선·석주…)이 함께 들어간다.
## 물고기가 바위 틈이나 해초 사이를 보금자리로 삼는 것을 그대로 옮겼다.
## 자연 서식지는 싸지만 적게 들이고, 지어 올린 것은 비싼 대신 정원이 넓다.
##
## 한 채당 capacity마리를 들이고, 같은 종류라도 몇 채든 지을 수 있다.
## 지을수록 값이 오르는 것은 생물과 같다(COST_GROWTH).
## 집 자체는 거품을 벌지 않는다. 자리와 아름다움을 준다.
## size : 바닥에서 차지하는 칸 수 (열, 행). 넓은 집일수록 아름다움이 크다.
## capacity: 들일 수 있는 생물 수. 없으면 HOUSE_CAPACITY.
## shape : rock/plant(자연) · vessel/wreck/pillar(지은 것).
## 생물의 형태(fish/glow/crab/seahorse/shrimp)와 섞지 않아야
## 한눈에 "사는 곳"과 "사는 것"이 갈린다.
const HOUSES := [
# --- 얕은 바다 ---
{
"id": "amphora", "name": "침몰한 항아리", "zone": 0, "shape": "rock",
"size": Vector2i(2, 1), "height": 0.9,
"desc": "누군가의 항해가 남긴 흔적.",
"color": Color(0.72, 0.55, 0.38), "cost": 800.0, "beauty": 25.0, "mult": 0.02,
"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": "bottle_letter", "name": "유리병 편지", "zone": 0, "shape": "glow",
"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,
"desc": "누군가의 항해가 남긴 흔적. 작은 것들이 안에 깃든다.",
"color": Color(0.72, 0.55, 0.38), "base_cost": 90.0, "beauty": 12.0,
},
{
"id": "bottle_letter", "name": "유리병 편지", "zone": 0, "shape": "vessel",
"size": Vector2i(1, 1), "height": 0.85,
"desc": "아직 아무도 읽지 못한 문장.",
"color": Color(0.78, 0.92, 0.72), "cost": 4000.0, "beauty": 45.0, "mult": 0.02,
"desc": "한 칸만 쓰고도 세 자리를 낸다. 좁은 바다의 해답.",
"color": Color(0.78, 0.92, 0.72), "base_cost": 1200.0, "beauty": 20.0,
},
# --- 산호초 ---
{
"id": "anchor", "name": "낡은 닻", "zone": 1, "shape": "rock",
"size": Vector2i(2, 1), "height": 1.15,
"desc": "이끼가 낀 무거운 쇠.",
"color": Color(0.48, 0.52, 0.56), "cost": 40000.0, "beauty": 90.0, "mult": 0.03,
"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": "shipwreck", "name": "난파선", "zone": 1, "shape": "rock",
"id": "anchor", "name": "낡은 닻", "zone": 1, "shape": "wreck",
"size": Vector2i(2, 1), "height": 1.15,
"desc": "이끼가 낀 무거운 쇠. 물고기들이 그늘에 모인다.",
"color": Color(0.48, 0.52, 0.56), "base_cost": 9000.0, "beauty": 40.0,
},
{
"id": "shipwreck", "name": "난파선", "zone": 1, "shape": "wreck",
"size": Vector2i(3, 2), "height": 1.65,
"desc": "물고기들의 새 보금자리.",
"color": Color(0.42, 0.32, 0.24), "cost": 500000.0, "beauty": 200.0, "mult": 0.05,
"desc": "자리를 많이 먹지만 그만큼 바다가 화사해진다.",
"color": Color(0.42, 0.32, 0.24), "base_cost": 60000.0, "beauty": 110.0,
},
# --- 켈프 숲 ---
{
"id": "submarine", "name": "침몰한 잠수정", "zone": 2, "shape": "rock",
"size": Vector2i(3, 2), "height": 1.7,
"desc": "돌아오지 못한 탐사선.",
"color": Color(0.72, 0.66, 0.32), "cost": 6000000.0, "beauty": 450.0, "mult": 0.06,
"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": "stone_pillar", "name": "이끼 낀 석주", "zone": 2, "shape": "rock",
"id": "submarine", "name": "침몰한 잠수정", "zone": 2, "shape": "wreck",
"size": Vector2i(3, 2), "height": 1.7,
"desc": "돌아오지 못한 탐사선이 보금자리가 되었다.",
"color": Color(0.72, 0.66, 0.32), "base_cost": 900000.0, "beauty": 240.0,
},
{
"id": "stone_pillar", "name": "이끼 낀 석주", "zone": 2, "shape": "pillar",
"size": Vector2i(2, 2), "height": 1.95,
"desc": "바다가 삼킨 어느 신전의 기둥.",
"color": Color(0.52, 0.60, 0.44), "cost": 80000000.0, "beauty": 900.0, "mult": 0.08,
"color": Color(0.52, 0.60, 0.44), "base_cost": 6000000.0, "beauty": 480.0,
},
# --- 심해 ---
{
"id": "whale_fall", "name": "고래 뼈", "zone": 3, "shape": "rock",
"id": "whale_fall", "name": "고래 뼈", "zone": 3, "shape": "wreck",
"size": Vector2i(3, 2), "height": 1.5,
"desc": "가라앉은 고래 한 마리가 수십 년을 먹여 살린다.",
"color": Color(0.88, 0.88, 0.84), "cost": 1.2e9, "beauty": 1800.0, "mult": 0.10,
"color": Color(0.88, 0.88, 0.84), "base_cost": 150000000.0, "beauty": 950.0,
},
{
"id": "moai", "name": "해저 석상", "zone": 3, "shape": "rock",
"id": "moai", "name": "해저 석상", "zone": 3, "shape": "pillar",
"size": Vector2i(2, 2), "height": 1.85,
"desc": "말없이 구역을 지켜본다.",
"color": Color(0.55, 0.54, 0.50), "cost": 1.6e10, "beauty": 3500.0, "mult": 0.12,
"desc": "말없이 세입자들을 지켜본다.",
"color": Color(0.55, 0.54, 0.50), "base_cost": 1.2e9, "beauty": 1900.0,
},
# --- 열수구 ---
{
"id": "vent_chimney", "name": "열수 굴뚝", "zone": 4, "shape": "rock",
"size": Vector2i(2, 2), "height": 1.95,
"desc": "검은 연기를 끝없이 뿜는다.",
"color": Color(0.30, 0.22, 0.24), "cost": 3e11, "beauty": 7000.0, "mult": 0.15,
"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": "crystal", "name": "심해 수정", "zone": 4, "shape": "glow",
"id": "vent_chimney", "name": "열수 굴뚝", "zone": 4, "shape": "pillar",
"size": Vector2i(2, 2), "height": 1.95,
"desc": "검은 연기를 끝없이 뿜는다. 그 열기에 기대어 산다.",
"color": Color(0.30, 0.22, 0.24), "base_cost": 4e10, "beauty": 3800.0,
},
{
"id": "crystal", "name": "심해 수정", "zone": 4, "shape": "pillar",
"size": Vector2i(2, 2), "height": 1.55,
"desc": "빛이 닿지 않는 곳에서 스스로 빛난다.",
"color": Color(0.55, 0.90, 0.95), "cost": 6e12, "beauty": 15000.0, "mult": 0.20,
"color": Color(0.55, 0.90, 0.95), "base_cost": 5e11, "beauty": 8000.0,
},
]
## 업그레이드 — 칸을 차지하지 않고 전체에 적용된다. 한 번만 살 수 있다.
## kind: click_add(클릭당 가산) / click_mult(클릭 배율) / bps_mult(생산 배율)
## 거품은 오직 생물이 벌기 때문에 업그레이드도 전부 생산 배율(bps_mult)이다.
const UPGRADES := [
{"id": "u_touch1", "name": "부드러운 손짓", "kind": "click_add", "value": 1.0,
"cost": 100.0, "desc": "클릭당 거품 +1"},
{"id": "u_touch2", "name": "물살 가르", "kind": "click_add", "value": 8.0,
"cost": 5000.0, "desc": "클릭당 거품 +8"},
{"id": "u_touch3", "name": "소용돌이 손", "kind": "click_mult", "value": 3.0,
"cost": 250000.0, "desc": "클릭 효율 3배"},
{"id": "u_flow1", "name": "잔잔한 해류", "kind": "bps_mult", "value": 1.5,
{"id": "u_ripple", "name": "잔물결", "kind": "bps_mult", "value": 1.25,
"cost": 100.0, "desc": "전체 생산량 1.25배"},
{"id": "u_plankton", "name": "플랑크톤 뿌리", "kind": "bps_mult", "value": 1.5,
"cost": 2000.0, "desc": "전체 생산량 1.5배"},
{"id": "u_flow2", "name": "따뜻", "kind": "bps_mult", "value": 2.0,
"cost": 400000.0, "desc": "전체 생산량 2"},
{"id": "u_flow3", "name": "달의 인력", "kind": "bps_mult", "value": 3.0,
"cost": 90000000.0, "desc": "전체 생산량 3"},
{"id": "u_current1", "name": "잔잔", "kind": "bps_mult", "value": 1.35,
"cost": 5000.0, "desc": "전체 생산량 1.35"},
{"id": "u_silt", "name": "영양 갯벌", "kind": "bps_mult", "value": 1.8,
"cost": 250000.0, "desc": "전체 생산량 1.8"},
{"id": "u_current2", "name": "따뜻한 조류", "kind": "bps_mult", "value": 1.5,
"cost": 400000.0, "desc": "전체 생산량 1.5배"},
{"id": "u_moon", "name": "달의 인력", "kind": "bps_mult", "value": 2.5,
"cost": 90000000.0, "desc": "전체 생산량 2.5배"},
]
## 생물 가격 성장률. 칸이 한정되어 있어 한 종류를 30개쯤 놓으면 66배가 된다.
## 집 한 채가 들이는 생물 수. 이것이 성장의 진짜 한계다.
## 거품이 아무리 많아도 자리가 없으면 생물을 더 들일 수 없고,
## 자리를 늘리려면 집을 짓거나(칸을 먹는다) 다음 구역을 열어야 한다.
const HOUSE_CAPACITY := 3
## 새로 시작할 때 이미 지어져 있는 집. 집이 없으면 생물을 한 마리도 들일 수 없다.
const STARTER_HOUSES := [
{"id": "amphora", "col": 3, "row": 3},
]
## 새로 시작할 때 이미 살고 있는 생물. id -> 마리 수.
## 거품은 생물만 벌기 때문에 최소 한 마리는 처음부터 있어야 한다.
## 헤엄치는 것(구피)과 바닥을 걷는 것(소라게)을 하나씩 두어,
## 켜자마자 두 가지 움직임이 다 보이게 했다.
const STARTER_FISH := {"guppy": 1, "hermit_crab": 1}
## 시작 거품. 두 번째 구피 값(ceil(15 × 1.15) = 18)보다 조금 넉넉하게 두어,
## 시작하자마자 남은 한 자리를 직접 채워보며 구조를 익히게 한다.
const STARTER_BUBBLES := 20.0
## 생물과 집 모두 살수록(지을수록) 값이 오르는 비율.
const COST_GROWTH := 1.15
## 아름다움 → 생산 배율 곡선. 제곱근이라 초반엔 크게, 후반엔 완만하게 오른다.
@@ -204,15 +270,14 @@ const BEAUTY_MULT_SCALE := 0.02
const REFUND_RATE := 0.5
var _producers_by_id := {}
var _ornaments_by_id := {}
var _houses_by_id := {}
var _upgrades_by_id := {}
func _ready() -> void:
for d in PRODUCERS:
_producers_by_id[d.id] = d
for d in ORNAMENTS:
_ornaments_by_id[d.id] = d
for d in HOUSES:
_houses_by_id[d.id] = d
for d in UPGRADES:
_upgrades_by_id[d.id] = d
@@ -241,8 +306,8 @@ func producer(id: String) -> Dictionary:
return _producers_by_id.get(id, {})
func ornament(id: String) -> Dictionary:
return _ornaments_by_id.get(id, {})
func house(id: String) -> Dictionary:
return _houses_by_id.get(id, {})
func upgrade(id: String) -> Dictionary:
@@ -252,7 +317,7 @@ func upgrade(id: String) -> Dictionary:
func item(kind: String, id: String) -> Dictionary:
match kind:
"producer": return producer(id)
"ornament": return ornament(id)
"house": return house(id)
"upgrade": return upgrade(id)
return {}
@@ -260,11 +325,16 @@ func item(kind: String, id: String) -> Dictionary:
func items_of(kind: String) -> Array:
match kind:
"producer": return PRODUCERS
"ornament": return ORNAMENTS
"house": return HOUSES
"upgrade": return UPGRADES
return []
## 집 한 채가 들이는 생물 수. 집마다 다르게 두고 싶으면 dict에 capacity를 넣으면 된다.
func capacity_of(id: String) -> int:
return int(house(id).get("capacity", HOUSE_CAPACITY))
## 업그레이드는 구역에 속하지 않으므로 항상 0번 구역으로 친다.
func zone_of(kind: String, id: String) -> int:
return int(item(kind, id).get("zone", 0))
@@ -290,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
+342 -139
View File
@@ -2,24 +2,32 @@ extends Node
## 게임의 진행 상태와 파생 수치를 들고 있는 단일 저장소.
## 저장/불러오기는 SaveManager가, 밸런스 수치는 Catalog가 담당한다.
##
## 바다는 탑다운 격자이고, 산 것은 모두 격자 위 특정 칸에 놓인다.
## 즉 "몇 개 샀는가"가 아니라 "어디에 무엇이 있는가"가 진짜 상태다.
## 두 가지가 서로 다른 규칙으로 움직인다.
## 집(house) — 격자 칸을 차지한다. "어디에 무엇을 지을까"가 진짜 상태다.
## 생물(producer) — 칸을 차지하지 않고 집에 산다. 몇 마리인지만 센다.
##
## 거품은 오직 생물이 시간에 따라 번다. 집은 거품을 벌지 않고 자리와 아름다움을 준다.
## 그래서 성장은 "집을 지어 자리를 늘리고 그 자리를 생물로 채운다"의 반복이다.
##
## 어느 생물이 어느 집에 사는지는 저장하지 않는다. 집은 정원만 주므로 어느 집에
## 살든 결과가 같고, 화면에 그릴 때 구역마다 차례로 배정하면 그만이다.
## 구매·이동·업그레이드처럼 화면을 다시 그려야 하는 이산적인 변화.
signal state_changed
## 새 구역이 열렸을 때.
signal zone_unlocked(index: int)
## 배치가 통째로 바뀌어 다시 그려야 할 때(불러오기, 초기화, 구역 해금).
## 배치가 통째로 바뀌어 다시 그려야 할 때(불러오기, 초기화, 생물 증감, 집 철거).
signal reloaded
## 새로 놓였을 때. 등장 연출에 쓴다.
## 집이 새로 지어졌을 때. 등장 연출에 쓴다.
signal placed(index: int)
# --- 저장되는 상태 ---
var bubbles := 0.0
var total_bubbles := 0.0
var clicks := 0
## [{kind, id, col, row}, ...] — 놓인 순서대로. 인덱스가 곧 식별자다.
var placements: Array[Dictionary] = []
## [{id, col, row}, ...] — 지은 순서대로. 인덱스가 곧 식별자다.
var houses: Array[Dictionary] = []
## id -> 마리 수. 생물은 칸을 차지하지 않으므로 위치가 없다.
var fish := {}
var upgrades := {} ## id -> true
var unlocked_zones := 1 ## 왼쪽부터 몇 개 구역이 열려 있는지 (최소 1)
var first_played_unix := 0
@@ -28,11 +36,13 @@ var first_played_unix := 0
var bps := 0.0 ## 초당 거품 생산량 (모든 보너스 반영)
var beauty := 0.0 ## 총 아름다움
var zone_beauty: Array[float] = [] ## 구역별 아름다움
var click_power := 1.0 ## 클릭 한 번당 거품
var beauty_mult := 1.0
var fish_total := 0 ## 살고 있는 생물 수. 마지막 한 마리를 지키는 데 쓴다.
var _counts := {} ## id -> 놓인 개
var _occupancy := PackedInt32Array() ## 칸 -> placements 인덱스, 빈 칸은 -1
var _house_counts := {} ## id -> 지은 채
var _zone_capacity: Array[int] = [] ## 구역별 정원 합
var _zone_fish: Array[int] = [] ## 구역별 살고 있는 수
var _occupancy := PackedInt32Array() ## 칸 -> houses 인덱스, 빈 칸은 -1
func _ready() -> void:
@@ -50,14 +60,7 @@ func _earn(amount: float) -> void:
total_bubbles += amount
## 물을 클릭했을 때. 실제로 얻은 양을 돌려준다(연출용).
func click() -> float:
clicks += 1
_earn(click_power)
return click_power
# --- 격자 ---
# --- 격자 (집만 쓴다) ---
func columns() -> int:
return Catalog.total_columns()
@@ -75,16 +78,16 @@ func _cell(col: int, row: int) -> int:
return row * columns() + col
## 그 칸에 놓인 것의 placements 인덱스. 비었으면 -1.
func placement_at(col: int, row: int) -> int:
## 그 칸에 선 집의 houses 인덱스. 비었으면 -1.
func house_at(col: int, row: int) -> int:
if not in_grid(col, row):
return -1
return _occupancy[_cell(col, row)]
## 아이템이 (col,row)에 놓였을 때 덮는 칸들. 격자를 벗어나면 빈 배열.
func footprint(kind: String, id: String, col: int, row: int) -> Array[Vector2i]:
var s := Catalog.size_of(kind, id)
## 이 (col,row)에 을 때 덮는 칸들. 격자를 벗어나면 빈 배열.
func footprint(id: String, col: int, row: int) -> Array[Vector2i]:
var s := Catalog.size_of("house", id)
var cells: Array[Vector2i] = []
for dx in s.x:
for dy in s.y:
@@ -94,78 +97,121 @@ func footprint(kind: String, id: String, col: int, row: int) -> Array[Vector2i]:
return cells
## 여기에 을 수 있는지. ignore_index는 자기 자신을 옮길 때 자기 칸을 비어 있다고 치기 위한 것.
func can_place_at(kind: String, id: String, col: int, row: int, ignore_index := -1) -> bool:
var item_zone := Catalog.zone_of(kind, id)
## 여기에 을 수 있는지. ignore_index는 자기 을 옮길 때 칸을 비다고 치기 위한 것.
func can_build_at(id: String, col: int, row: int, ignore_index := -1) -> bool:
var item_zone := Catalog.zone_of("house", id)
if not is_zone_unlocked(item_zone):
return false
var cells := footprint(kind, id, col, row)
var cells := footprint(id, col, row)
if cells.is_empty():
return false
for c in cells:
# 아이템은 자기 구역을 벗어날 수 없다. 구역 경계를 걸치는 것도 안 된다.
# 은 자기 구역을 벗어날 수 없다. 구역 경계를 걸치는 것도 안 된다.
if Catalog.zone_of_column(c.x) != item_zone:
return false
var occupant := placement_at(c.x, c.y)
var occupant := house_at(c.x, c.y)
if occupant != -1 and occupant != ignore_index:
return false
return true
## 그 구역에 남은 빈 칸 수. 상점에서 "자리 없음"을 알려주는 데 쓴다.
## 그 구역에 남은 빈 칸 수. 집을 더 지을 수 있는지 알려준다.
func free_cells_in_zone(zone_index: int) -> int:
var free := 0
for col in range(zone_index * Catalog.ZONE_COLUMNS, (zone_index + 1) * Catalog.ZONE_COLUMNS):
for row in rows():
if placement_at(col, row) == -1:
if house_at(col, row) == -1:
free += 1
return free
## 그 아이템을 수 있는 첫 칸. 없으면 null 대신 Vector2i(-1, -1).
func first_free_spot(kind: String, id: String) -> Vector2i:
var z := Catalog.zone_of(kind, id)
## 그 을 수 있는 첫 칸. 없으면 Vector2i(-1, -1).
## 아래 줄부터 훑는다. 게임이 알아서 세우는 집은 앞줄(화면 아래쪽)에 서야 눈에 띄고,
## 뒷줄은 플레이어가 직접 고르도록 남겨두는 편이 낫다.
func first_free_spot(id: String) -> Vector2i:
var z := Catalog.zone_of("house", id)
for col in range(z * Catalog.ZONE_COLUMNS, (z + 1) * Catalog.ZONE_COLUMNS):
for row in rows():
if can_place_at(kind, id, col, row):
for row in range(rows() - 1, -1, -1):
if can_build_at(id, col, row):
return Vector2i(col, row)
return Vector2i(-1, -1)
# --- 정원 (생물이 실제로 부딪히는 한계) ---
func capacity_in_zone(zone_index: int) -> int:
if zone_index < 0 or zone_index >= _zone_capacity.size():
return 0
return _zone_capacity[zone_index]
func fish_in_zone(zone_index: int) -> int:
if zone_index < 0 or zone_index >= _zone_fish.size():
return 0
return _zone_fish[zone_index]
## 그 구역에 남은 자리. 옛 저장본을 옮겨오면 음수가 될 수 있다(정원을 넘겨 살고 있다).
func free_slots_in_zone(zone_index: int) -> int:
return capacity_in_zone(zone_index) - fish_in_zone(zone_index)
func capacity_total() -> int:
var total := 0
for c in _zone_capacity:
total += c
return total
# --- 조회 ---
func count_of(id: String) -> int:
return _counts.get(id, 0)
func house_count(id: String) -> int:
return int(_house_counts.get(id, 0))
func has_ornament(id: String) -> bool:
return count_of(id) > 0
func fish_count(id: String) -> int:
return int(fish.get(id, 0))
func count_of(kind: String, id: String) -> int:
match kind:
"producer": return fish_count(id)
"house": return house_count(id)
"upgrade": return 1 if has_upgrade(id) else 0
return 0
func has_upgrade(id: String) -> bool:
return upgrades.has(id)
func producer_cost(id: String) -> float:
var d := Catalog.producer(id)
## 생물과 집은 늘어날수록 값이 오른다. 업그레이드는 한 번뿐이라 정가다.
func cost_of(kind: String, id: String) -> float:
var d := Catalog.item(kind, id)
if d.is_empty():
return INF
return ceil(float(d.base_cost) * pow(Catalog.COST_GROWTH, count_of(id)))
if kind == "upgrade":
return float(d.get("cost", INF))
return _grown_cost(float(d.get("base_cost", INF)), count_of(kind, id))
func cost_of(kind: String, id: String) -> float:
match kind:
"producer": return producer_cost(id)
"ornament": return Catalog.ornament(id).get("cost", INF)
"upgrade": return Catalog.upgrade(id).get("cost", INF)
return INF
func _grown_cost(base: float, owned_count: int) -> float:
if not is_finite(base):
return INF
return ceil(base * pow(Catalog.COST_GROWTH, owned_count))
## 치울 때 기준이 되는 값 — 지금 값이 아니라 마지막으로 산 그 하나의 값이다.
func _last_paid(kind: String, id: String) -> float:
var d := Catalog.item(kind, id)
if d.is_empty():
return 0.0
return _grown_cost(float(d.get("base_cost", 0.0)), maxi(count_of(kind, id) - 1, 0))
## 한 번만 살 수 있는 것(업그레이드)을 이미 가졌는지.
func owned(kind: String, id: String) -> bool:
match kind:
"ornament": return has_ornament(id)
"upgrade": return has_upgrade(id)
return false
return kind == "upgrade" and has_upgrade(id)
func is_zone_unlocked(index: int) -> bool:
@@ -173,7 +219,7 @@ func is_zone_unlocked(index: int) -> bool:
## 상점에 노출할지 여부.
## 아직 열리지 않은 구역의 물건은 숨기고, 열린 구역 안에서는
## 아직 열리지 않은 구역의 은 숨기고, 열린 구역 안에서는
## 가격의 절반까지 벌어본 적이 있으면 보여준다(점진 공개).
func is_revealed(kind: String, id: String) -> bool:
if not is_zone_unlocked(Catalog.zone_of(kind, id)):
@@ -181,60 +227,127 @@ func is_revealed(kind: String, id: String) -> bool:
# 구역을 막 열었을 때 목록이 비어 보이지 않도록 그 구역의 첫 항목은 항상 보인다.
if id == Catalog.first_id_of_zone(kind, Catalog.zone_of(kind, id)):
return true
if kind == "producer" and count_of(id) > 0:
return true
if owned(kind, id):
if count_of(kind, id) > 0:
return true
return total_bubbles >= cost_of(kind, id) * 0.5
# --- 배치 ---
# --- 집 짓기 ---
## 사서 그 칸에 는다. 성공하면 placements 인덱스, 실패하면 -1.
func place(kind: String, id: String, col: int, row: int) -> int:
if kind == "ornament" and owned(kind, id):
## 사서 그 칸에 는다. 성공하면 houses 인덱스, 실패하면 -1.
func build_house(id: String, col: int, row: int) -> int:
if Catalog.house(id).is_empty():
return -1
if not can_place_at(kind, id, col, row):
if not can_build_at(id, col, row):
return -1
var cost := cost_of(kind, id)
var cost := cost_of("house", id)
if not is_finite(cost) or bubbles < cost:
return -1
bubbles -= cost
placements.append({"kind": kind, "id": id, "col": col, "row": row})
houses.append({"id": id, "col": col, "row": row})
recalc()
placed.emit(placements.size() - 1)
placed.emit(houses.size() - 1)
state_changed.emit()
return placements.size() - 1
return houses.size() - 1
## 이미 놓인 것을 다른 칸으로 옮긴다. 공짜다.
func move(index: int, col: int, row: int) -> bool:
if index < 0 or index >= placements.size():
## 이미 지은 집을 다른 칸으로 옮긴다. 공짜다. 살던 생물도 같이 따라간다.
func move_house(index: int, col: int, row: int) -> bool:
if index < 0 or index >= houses.size():
return false
var p := placements[index]
if not can_place_at(p.kind, p.id, col, row, index):
var h := houses[index]
if not can_build_at(String(h.id), col, row, index):
return false
p.col = col
p.row = row
h.col = col
h.row = row
recalc()
state_changed.emit()
return true
## 치우고 절반을 돌려받는다. 돌려받은 양을 반환한다.
func remove(index: int) -> float:
if index < 0 or index >= placements.size():
return 0.0
var p := placements[index]
# 생물은 지금 가격이 아니라 "마지막으로 산 그 한 마리" 값을 기준으로 돌려준다.
var value := cost_of(p.kind, p.id)
if p.kind == "producer":
var d := Catalog.producer(p.id)
value = ceil(float(d.base_cost) * pow(Catalog.COST_GROWTH, maxi(count_of(p.id) - 1, 0)))
var refund: float = floor(value * Catalog.REFUND_RATE)
## 헐 수 있는지. 살고 있는 생물이 남는 집에 다 들어가야 한다.
func can_remove_house(index: int) -> bool:
if index < 0 or index >= houses.size():
return false
var id := String(houses[index].id)
var z := Catalog.zone_of("house", id)
return fish_in_zone(z) <= capacity_in_zone(z) - Catalog.capacity_of(id)
placements.remove_at(index)
## 헐고 절반을 돌려받는다. 돌려받은 양을 반환한다. 헐 수 없으면 0.
func remove_house(index: int) -> float:
if not can_remove_house(index):
return 0.0
var id := String(houses[index].id)
var refund: float = floor(_last_paid("house", id) * Catalog.REFUND_RATE)
houses.remove_at(index)
bubbles += refund
recalc()
reloaded.emit()
state_changed.emit()
return refund
# --- 생물 들이기 ---
## 그 생물을 들일 수 있는지. 구역이 열려 있고, 그 구역에 빈 자리가 있어야 한다.
func can_take_fish(id: String) -> bool:
var d := Catalog.producer(id)
if d.is_empty():
return false
var z := int(d.get("zone", 0))
return is_zone_unlocked(z) and free_slots_in_zone(z) > 0
## 왜 못 들이는지. 상점과 알림에 그대로 쓴다.
func fish_blocker(id: String) -> String:
var d := Catalog.producer(id)
if d.is_empty():
return "그런 생물은 없어요"
var z := int(d.get("zone", 0))
if not is_zone_unlocked(z):
return "%s 아직 잠겨 있어요" % Korean.i(String(Catalog.zone(z).name))
if free_slots_in_zone(z) <= 0:
return "%s에 빈 자리가 없어요 · 집을 더 지으세요" % Catalog.zone(z).name
if bubbles < cost_of("producer", id):
return "거품이 모자라요"
return ""
## 한 마리 들인다.
func buy_fish(id: String) -> bool:
if not can_take_fish(id):
return false
var cost := cost_of("producer", id)
if not is_finite(cost) or bubbles < cost:
return false
bubbles -= cost
fish[id] = fish_count(id) + 1
recalc()
reloaded.emit()
state_changed.emit()
return true
## 마지막 한 마리는 내보낼 수 없다. 생물이 0이 되면 거품이 영영 늘지 않기 때문이다.
func can_release_fish(id: String) -> bool:
return fish_count(id) > 0 and fish_total > 1
## 한 마리 내보내고 절반을 돌려받는다. 돌려받은 양을 반환한다.
func release_fish(id: String) -> float:
if not can_release_fish(id):
return 0.0
var refund: float = floor(_last_paid("producer", id) * Catalog.REFUND_RATE)
var left := fish_count(id) - 1
if left > 0:
fish[id] = left
else:
fish.erase(id)
bubbles += refund
recalc()
reloaded.emit()
@@ -321,42 +434,54 @@ func unlock_next_zone() -> bool:
# --- 파생 값 재계산 ---
func recalc() -> void:
_counts.clear()
var zones := Catalog.zone_count()
_house_counts.clear()
_occupancy.resize(columns() * rows())
_occupancy.fill(-1)
zone_beauty.clear()
zone_beauty.resize(Catalog.zone_count())
zone_beauty.resize(zones)
_zone_capacity.clear()
_zone_capacity.resize(zones)
_zone_fish.clear()
_zone_fish.resize(zones)
var raw_bps := 0.0
var ornament_mult := 1.0
for i in placements.size():
var p := placements[i]
var d := Catalog.item(p.kind, p.id)
# 집 — 칸을 차지하고, 정원과 아름다움을 낸다. 거품은 벌지 않는다.
for i in houses.size():
var id := String(houses[i].id)
var d := Catalog.house(id)
if d.is_empty():
continue
_counts[p.id] = int(_counts.get(p.id, 0)) + 1
for c in footprint(p.kind, p.id, p.col, p.row):
_house_counts[id] = house_count(id) + 1
for c in footprint(id, int(houses[i].col), int(houses[i].row)):
_occupancy[_cell(c.x, c.y)] = i
var z := int(d.get("zone", 0))
_add_beauty(z, float(d.beauty))
_add_capacity(z, Catalog.capacity_of(id))
_add_zone_beauty(int(d.get("zone", 0)), float(d.beauty))
if p.kind == "producer":
raw_bps += float(d.bps)
else:
ornament_mult += float(d.mult)
# 생물 — 거품을 버는 것은 이쪽뿐이다.
fish_total = 0
var raw_bps := 0.0
for key in fish:
var id := String(key)
var d := Catalog.producer(id)
var n := fish_count(id)
if d.is_empty() or n <= 0:
continue
fish_total += n
raw_bps += float(d.bps) * n
var z := int(d.get("zone", 0))
_add_beauty(z, float(d.beauty) * n)
_add_fish(z, n)
# 업그레이드는 전부 전체 생산 배율이다. 클릭으로 버는 길은 없다.
var upgrade_bps_mult := 1.0
var click_add := 1.0
var click_mult := 1.0
for id in upgrades:
var u := Catalog.upgrade(id)
for key in upgrades:
var u := Catalog.upgrade(String(key))
if u.is_empty():
continue
match u.kind:
"click_add": click_add += float(u.value)
"click_mult": click_mult *= float(u.value)
"bps_mult": upgrade_bps_mult *= float(u.value)
if String(u.kind) == "bps_mult":
upgrade_bps_mult *= float(u.value)
beauty = 0.0
for b in zone_beauty:
@@ -364,26 +489,35 @@ func recalc() -> void:
# 꾸밀수록 생산이 빨라지도록. 제곱근이라 초반 보상이 크고 후반은 완만하다.
beauty_mult = 1.0 + sqrt(beauty) * Catalog.BEAUTY_MULT_SCALE
bps = raw_bps * ornament_mult * upgrade_bps_mult * beauty_mult
click_power = click_add * click_mult * beauty_mult
bps = raw_bps * upgrade_bps_mult * beauty_mult
func _add_zone_beauty(index: int, amount: float) -> void:
func _add_beauty(index: int, amount: float) -> void:
if index >= 0 and index < zone_beauty.size():
zone_beauty[index] += amount
func _add_capacity(index: int, amount: int) -> void:
if index >= 0 and index < _zone_capacity.size():
_zone_capacity[index] += amount
func _add_fish(index: int, amount: int) -> void:
if index >= 0 and index < _zone_fish.size():
_zone_fish[index] += amount
# --- 직렬화 ---
func to_dict() -> Dictionary:
var rows_out: Array = []
for p in placements:
rows_out.append({"kind": p.kind, "id": p.id, "col": p.col, "row": p.row})
for h in houses:
rows_out.append({"id": h.id, "col": h.col, "row": h.row})
return {
"bubbles": bubbles,
"total_bubbles": total_bubbles,
"clicks": clicks,
"placements": rows_out,
"houses": rows_out,
"fish": fish.duplicate(),
"upgrades": upgrades.duplicate(),
"unlocked_zones": unlocked_zones,
"first_played_unix": first_played_unix,
@@ -393,7 +527,6 @@ func to_dict() -> Dictionary:
func from_dict(d: Dictionary) -> void:
bubbles = float(d.get("bubbles", 0.0))
total_bubbles = float(d.get("total_bubbles", bubbles))
clicks = int(d.get("clicks", 0))
unlocked_zones = clampi(int(d.get("unlocked_zones", 1)), 1, Catalog.zone_count())
first_played_unix = int(d.get("first_played_unix", Time.get_unix_time_from_system()))
@@ -401,57 +534,127 @@ func from_dict(d: Dictionary) -> void:
for id in d.get("upgrades", {}):
upgrades[id] = true
placements.clear()
houses.clear()
fish.clear()
recalc()
if d.has("placements"):
for raw in d["placements"]:
_restore(String(raw.get("kind", "producer")), String(raw.get("id", "")),
if d.has("houses") or d.has("fish"):
for raw in d.get("houses", []):
_restore_house(String(raw.get("id", "")),
int(raw.get("col", 0)), int(raw.get("row", 0)))
for id in d.get("fish", {}):
_restore_fish(String(id), int(d["fish"][id]))
else:
_migrate_from_counts(d)
_migrate_old_save(d)
# 집이나 생물이 하나도 없으면 아무 일도 일어나지 않는다. 시작 상태를 채워준다.
if houses.is_empty():
_grant_starter_houses()
if fish_total == 0:
_grant_starter_fish()
_grant_homes_for_overflow()
recalc()
reloaded.emit()
state_changed.emit()
## 저장된 칸이 비어 있으면 그대로, 겹치거나 규칙에 어긋나면 빈 칸을 찾아 옮겨 는다.
## 저장된 칸이 비어 있으면 그대로, 겹치거나 규칙에 어긋나면 빈 칸을 찾아 옮겨 는다.
## 격자 크기나 구역 구성이 바뀌어도 진행도를 잃지 않기 위한 것.
func _restore(kind: String, id: String, col: int, row: int) -> void:
if Catalog.item(kind, id).is_empty():
func _restore_house(id: String, col: int, row: int) -> void:
if Catalog.house(id).is_empty():
return
if not can_place_at(kind, id, col, row):
var spot := first_free_spot(kind, id)
if not can_build_at(id, col, row):
var spot := first_free_spot(id)
if spot.x < 0:
return
col = spot.x
row = spot.y
placements.append({"kind": kind, "id": id, "col": col, "row": row})
houses.append({"id": id, "col": col, "row": row})
recalc()
## 격자가 없던 시절의 저장본(개수만 있음)을 빈 칸에 차례로 놓아 되살린다.
func _migrate_from_counts(d: Dictionary) -> void:
## 정원을 넘겨도 이미 살던 생물을 내쫓지는 않는다. 자리가 빌 때까지 더 못 들일 뿐이다.
func _restore_fish(id: String, count: int) -> void:
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()
## 생물도 칸을 차지하던 시절의 저장본을 옮겨온다.
## 그때의 장식(ornament)이 지금의 집이고, 생물은 위치를 버리고 마릿수만 남긴다.
func _migrate_old_save(d: Dictionary) -> void:
if d.has("placements"):
for raw in d["placements"]:
var kind := String(raw.get("kind", "producer"))
var id := String(raw.get("id", ""))
if kind == "ornament" or kind == "house":
_restore_house(id, int(raw.get("col", 0)), int(raw.get("row", 0)))
else:
_restore_fish(id, 1)
return
# 격자가 없던 더 옛날 저장본. 개수만 있다.
for id in d.get("producers", {}):
for i in int(d["producers"][id]):
var spot := first_free_spot("producer", String(id))
if spot.x < 0:
break
_restore("producer", String(id), spot.x, spot.y)
_restore_fish(String(id), int(d["producers"][id]))
for id in d.get("ornaments", {}):
var spot := first_free_spot("ornament", String(id))
var spot := first_free_spot(String(id))
if spot.x >= 0:
_restore("ornament", String(id), spot.x, spot.y)
_restore_house(String(id), spot.x, spot.y)
func reset() -> void:
## 처음부터 다시. with_starter를 끄면 집도 생물도 없는 빈 바다가 된다(테스트용).
func reset(with_starter := true) -> void:
bubbles = 0.0
total_bubbles = 0.0
clicks = 0
placements.clear()
houses.clear()
fish.clear()
upgrades.clear()
unlocked_zones = 1
first_played_unix = int(Time.get_unix_time_from_system())
recalc()
if with_starter:
_grant_starter_houses()
_grant_starter_fish()
bubbles = Catalog.STARTER_BUBBLES
total_bubbles = Catalog.STARTER_BUBBLES
recalc()
reloaded.emit()
state_changed.emit()
## 시작 집을 값 없이 짓는다. 정해 둔 칸이 차 있으면 빈 칸을 찾는다.
func _grant_starter_houses() -> void:
for s in Catalog.STARTER_HOUSES:
_restore_house(String(s.id), int(s.col), int(s.row))
## 시작 생물을 값 없이 들인다.
func _grant_starter_fish() -> void:
for id in Catalog.STARTER_FISH:
_restore_fish(String(id), int(Catalog.STARTER_FISH[id]))
## 옛 저장본에서 옮겨온 생물이 정원을 넘길 수 있다(그때는 생물이 칸을 직접 차지했다).
## 그러면 집을 공짜로 세워 모두 들어가게 해준다. 살 곳 없는 생물은 화면에도
## 그려지지 않으므로, 보이지 않는 생물을 남기지 않으려는 것이다.
func _grant_homes_for_overflow() -> void:
for z in Catalog.zone_count():
var id := Catalog.first_id_of_zone("house", z)
if id.is_empty():
continue
while fish_in_zone(z) > capacity_in_zone(z):
var spot := first_free_spot(id)
if spot.x < 0:
break # 칸이 다 찼다. 더는 해줄 수 있는 게 없다.
_restore_house(id, spot.x, spot.y)
+11 -11
View File
@@ -16,13 +16,10 @@ const OFFLINE_RATE := 0.5
## 이보다 짧게 비운 건 "오프라인"으로 안 친다(창을 잠깐 껐다 켠 경우).
const OFFLINE_MIN_SECONDS := 60
## 창 위치·표시 옵션. main.gd가 읽고 쓴다.
var window_state := {
"x": -1,
"y": -1,
"always_on_top": true,
"click_through": false,
}
## 창 표시 옵션. main.gd가 _remember_window()로 채우고 _ready()에서 읽는다.
## 기본값은 여기가 아니라 main.gd의 get(키, 기본값)에만 둔다. 두 군데 두면 어긋난다.
## 지금 들어오는 키: always_on_top / click_through / edge_bottom / height / screen
var window_state := {}
## 테스트처럼 실제 저장 파일을 건드리면 안 되는 상황에서 끈다.
## 끄지 않으면 스모크 테스트가 종료될 때 초기화된 상태를 저장해 진행도를 날린다.
@@ -86,14 +83,17 @@ func load_game() -> void:
if data.is_empty():
data = _read(BACKUP_PATH)
if data.is_empty():
# 저장본이 없다 = 이 컴퓨터에서 처음 켰다. 시작 집과 생물을 놓아준다.
# 이게 없으면 빈 바다에서 시작해 아무 일도 일어나지 않는다.
GameState.reset()
return
GameState.from_dict(data.get("state", {}))
var win: Dictionary = data.get("window", {})
for key in window_state:
if win.has(key):
window_state[key] = win[key]
# 저장본에 있는 창 설정을 전부 실어 온다. 예전에는 window_state에 미리
# 들어 있던 키만 복사해서, 높이·도킹·모니터 설정이 조용히 사라졌다.
for key in data.get("window", {}):
window_state[key] = data["window"][key]
_grant_offline(int(data.get("saved_at", 0)))
+382 -29
View File
@@ -8,11 +8,65 @@
## 핵심 설계 판단
**칸이 곧 한계다.** 클리커 장르는 보통 한 종류를 무한히 사서 가격 곡선으로만
**클릭으로 벌지 않는다.** 이건 클리커가 아니라 방치형 위젯이다. 거품은 오직 바다에
놓인 생물이 시간에 따라 번다. 화면 가장자리에 깔아 두고 다른 일을 하는 물건인데
클릭이 주된 수입원이면 위젯을 쳐다보며 연타하는 것이 최적 플레이가 되어, 형태와 어긋난다.
물을 만지면 거품이 퍼지지만 그건 연출일 뿐 얻는 것은 없다.
여기서 두 가지가 따라온다. 첫째, **새로 시작하면 항아리 집 한 채와 거품돌·구피 한 마리씩이
이미 있다**(`Catalog.STARTER_HOUSES` / `STARTER_FISH`). 바닥에 붙는 것과 헤엄치는 것을
하나씩 둔 것은, 켜자마자 두 가지 움직임이 다 보이게 하기 위해서다. 빈 바다는 집도 생물도 없어서
아무리 기다려도 아무 일이 일어나지 않기 때문이다. 시작 거품 20은 세 번째 거품돌 값에
맞춰 두어, 시작하자마자 남은 한 자리를 직접 채워보며 구조를 익히게 했다.
둘째, **마지막 남은 생물은 내보낼 수 없다**(`GameState.can_release_fish`). 생물이 0이 되면
수입도 0이 되고, 환불받은 거품이 다음 한 마리 값에 못 미치면 영영 되돌아올 수 없기 때문이다.
업그레이드도 전부 생산 배율(`bps_mult`)이고, 클릭 관련 항목은 남기지 않았다.
저장본이 아예 없으면(이 컴퓨터에서 처음 켰으면) `SaveManager.load_game`이 새 게임을
시작한다. 클릭으로 벌던 시절의 저장본처럼 집이나 생물이 없는 저장본을 불러올 때도
`GameState.from_dict`가 시작 상태를 채워준다.
**집은 건물, 생물은 일꾼이다.** 칸을 차지하는 것은 집뿐이고, 생물은 칸이 아니라
**집에 산다.** 한 채가 3마리를 들인다(`Catalog.HOUSE_CAPACITY`). 그래서 한계가 두 겹이다.
```
칸 → 집을 몇 채 지을 수 있는가
집 → 생물을 몇 마리 키울 수 있는가
생물 → 거품을 얼마나 버는가
```
앞서는 생물도 장식도 똑같이 한 칸을 먹는 "놓는 것"이었다. 그러면 둘이 같은 자원(칸)을
놓고 직접 경쟁해서, 최적 플레이는 언제나 "장식을 사지 말고 칸을 생물로 채우는 것"이 된다.
꾸미기를 성장에 묶으려고 아름다움 배율을 넣어도 이 경쟁을 완전히 이기지는 못했다.
집과 일꾼으로 나누면 경쟁이 **의존**으로 바뀐다. 집을 짓지 않으면 생물을 늘릴 방법이
아예 없으므로, 꾸미는 것이 성장의 우회로가 아니라 전제 조건이 된다.
**정원은 어느 집이든 3으로 같게 두었다.** 그래야 큰 집과 작은 집이 다른 축에서
갈린다. 3×2 난파선과 1×1 유리병 편지가 똑같이 3마리를 들이므로, 넓은 집은
아름다움으로, 좁은 집은 칸 효율로 값을 한다. 정원을 크기에 비례시키면 큰 집이
모든 면에서 우월해져 선택이 사라진다. 집마다 다르게 하고 싶으면 카탈로그 항목에
`capacity`를 넣으면 된다(`Catalog.capacity_of`).
**집은 같은 종류라도 여러 채 짓는다.** 정원을 늘리는 것이 성장의 주된 축이므로,
장식처럼 종류당 한 번만 살 수 있으면 상한이 곧바로 막힌다. 값은 생물과 똑같이
지을수록 ×1.15로 오른다. 집이 거품을 직접 벌지 않게 둔 것도 이 때문이다 —
정원과 생산을 한 물건이 동시에 주면 곱연산이 두 번 걸려 곡선이 터진다.
**어느 생물이 어느 집에 사는지는 저장하지 않는다.** 집은 정원만 주므로 어느 집에
살든 결과가 같다. 저장하면 집을 헐거나 옮길 때 재배정 로직과 인덱스 관리가 붙는데,
그 복잡도로 사는 것이 아무것도 없다. 그릴 때 구역마다 집을 격자 순서로, 생물을
카탈로그 순서로 세워 채운다(`Aquarium._assign_fish`). 그래서 저장 형식에서 생물은
위치 없이 `{id: 마리 수}`다.
**막다른 길을 두 군데서 막는다.** 수입이 생물에서만 나오므로 생물이 0이 되면 되돌아올
길이 없다. 그래서 마지막 한 마리는 내보낼 수 없고(`can_release_fish`), 살고 있는
생물이 갈 곳이 없어지는 집은 헐 수 없다(`can_remove_house`).
**칸이 곧 집의 한계다.** 클리커 장르는 보통 한 종류를 무한히 사서 가격 곡선으로만
속도를 조절한다. 그러면 배치는 장식이 되고, 꾸미기는 성장과 무관한 곁가지가 된다.
여기서는 격자 칸이 보유 개수를 직접 제한한다. "무엇을 어디에 놓을까"가
매 순간 진짜 고민이 되고, 자리를 늘리는 유일한 길이 다음 구역을 여는 것이라
꾸미기·성장·진행이 한 줄로 이어진다.
여기서는 격자 칸이 지을 수 있는 집 수를 직접 제한하고, 그 집이 다시 생물 수를
제한한다. "무엇을 어디에 지을까"가 매 순간 진짜 고민이 되고, 자리를 늘리는 마지막
길이 다음 구역을 여는 것이라 꾸미기·성장·진행이 한 줄로 이어진다.
**직교 격자 + 3/4 부감 시점을 쓴다.** 아이소메트릭은 가로·세로 두 축이 모두 화면
높이를 먹기 때문에(높이 = (열+행) × 타일높이 / 2) 220px 스트립에서는 격자가
@@ -37,7 +91,7 @@
**꾸미기와 성장을 하나로 묶었다.** 방치형 게임에서 꾸미기는 보통 "번 돈을 쓰는 곳"에
그치고, 그러면 꾸미기가 성장의 경쟁 상대가 되어 아무도 꾸미지 않는다.
그래서 모든 구매물이 **아름다움(beauty)** 수치를 가지고, 아름다움이 다시 전체 생산량에
그래서 생물도 집도 모두 **아름다움(beauty)** 수치를 가지고, 아름다움이 다시 전체 생산량에
곱해지도록 했다. 꾸미는 것과 최적으로 플레이하는 것이 같은 방향을 가리킨다.
```
@@ -51,26 +105,33 @@
| 항목 | 값 | 위치 |
| --- | --- | --- |
| 생물 가격 성장률 | ×1.15 (장르 표준) | `Catalog.COST_GROWTH` |
| 집 정원 | 3마리 (집마다 바꾸려면 `capacity`) | `Catalog.HOUSE_CAPACITY` |
| 시작 상태 | 항아리 1채 + 거품돌·구피 1마리씩 | `Catalog.STARTER_HOUSES` / `STARTER_FISH` |
| 물고기 공전 속도 | 0.5 rad/s (한 바퀴 약 12초) | `Aquarium.ORBIT_SPEED` |
| 시작 거품 | 20 (세 번째 거품돌 값) | `Catalog.STARTER_BUBBLES` |
| 업그레이드 | 생산 배율 6종, 다 사면 ×17.1 | `Catalog.UPGRADES` |
| 가격 성장률 | ×1.15 — 생물도 집도 같다 | `Catalog.COST_GROWTH` |
| 아름다움 배율 계수 | 0.02 | `Catalog.BEAUTY_MULT_SCALE` |
| 오프라인 상한 | 8시간 | `SaveManager.OFFLINE_CAP_SECONDS` |
| 오프라인 효율 | 50% | `SaveManager.OFFLINE_RATE` |
| 자동 저장 | 30초 | `SaveManager.AUTOSAVE_INTERVAL` |
| 상점 공개 조건 | 해금된 구역 + 가격의 50%를 벌어본 적 있음 | `GameState.is_revealed()` |
| 구역 수 | 5 (구역당 생물 2 + 장식 2) | `Catalog.ZONES` |
| 구역 수 | 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` |
| 물체 높이 | 칸 높이의 0.75~1.95배 | 각 아이템의 `height` |
| 치울 때 환불 | 50% | `Catalog.REFUND_RATE` |
| 정보 바 자리 | 위쪽 30px는 격자에서 뺌 | `Aquarium.GRID_TOP` |
| 격자 | 구역당 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` |
| 정보 바 자리 | HUD가 재어 알려준 바 높이 + 6px | `HUDLayer.reserved_top()` |
| 스트립 높이 | 160 / 220 / 300px | `Main.HEIGHTS` |
생물 10종·장식 10종을 구역당 2+2로 나눠 두었다. 한 구역을 70%쯤 채우면
다음 구역 해금 비용이 대략 20~90분치 생산량이 되도록 잡아 두었지만,
칸 제한이 들어오면서 곡선이 크게 달라졌으므로 실제로 플레이해 보며 다시 맞춰야 한다.
현재 밸런스는 첫 몇 시간을 촘촘하게 만드는 데 맞춰져 있고, 실제로 플레이해 보며
`Catalog``base_cost` / `bps` / `beauty` 세 숫자만 조정하면 된다.
생물 10종· 10종을 구역당 2+2로 나눠 두었다. 집 값은 "그 구역에서 가장 싼 생물의
약 6배"라는 규칙으로 잡아, 집 한 채 → 생물 세 마리 → 다시 집 한 채로 리듬이 번갈아
오도록 했다. 집이 정원을 쥐게 되면서 곡선이 통째로 달라졌으므로, 실제 수치는
플레이해 보며 다시 맞춰야 한다. 조정할 것은 `Catalog``base_cost` / `bps` / `beauty`
세 숫자와 `HOUSE_CAPACITY` 하나다.
## 데이터 추가하는 법
@@ -79,15 +140,37 @@
```gdscript
{
"id": "shrimp", "name": "청소새우", "shape": "fish",
"id": "shrimp", "name": "청소새우", "zone": 0, "shape": "fish", "height": 0.8,
"desc": "바닥을 훑고 다닌다.",
"color": Color(0.9, 0.4, 0.4), "base_cost": 400.0, "bps": 3.0, "beauty": 5.0,
},
```
`shape``plant` / `rock`(바닥 고정) / `fish` / `glow`(제자리 위를 떠다님) 중 하나이고,
집은 `HOUSES`에 넣는다. 생물과 같은 항목을 쓰되 `bps` 대신 `size`(바닥에서 차지하는
칸, 기본 1×1)를 쓰고, 정원을 3에서 바꾸고 싶을 때만 `capacity`를 더한다.
```gdscript
{
"id": "reef_cave", "name": "산호 동굴", "zone": 1, "shape": "rock",
"size": Vector2i(2, 2), "height": 1.3,
"desc": "틈마다 물고기가 숨는다.",
"color": Color(0.9, 0.5, 0.5), "base_cost": 30000.0, "beauty": 70.0,
},
```
`shape`은 두 계통으로 갈라져 있다. 섞어 쓰면 화면에서 "건물"과 "사는 것"이 구분되지 않는다.
| 계통 | 값 | 움직임 |
| --- | --- | --- |
| 생물 | `plant` / `rock` | 집 둘레 제자리에 붙어 선다 |
| 생물 | `fish` / `glow` | 집 둘레를 돌며 뒤로 돌아 들어갔다 나온다 |
| 집 | `vessel` / `wreck` / `pillar` | 바닥에 고정 |
`scenes/aquarium/tile_item.gd`가 형태별로 서 있는 모습을 직접 그린다.
`size`는 바닥에서 차지하는 칸(기본 1×1), `height`는 칸 높이의 몇 배로 서는지다.
`height`는 칸 높이의 몇 배로 서는지다.
`zone`은 어느 구역에 속하는지다(0 = 얕은 바다). 그 구역이 열려야 상점에 나타나고,
집은 그 구역 밖에 지을 수 없다(구역 경계를 걸치는 것도 안 된다).
생물은 자기 구역의 집에만 들어간다.
물빛 오버레이가 덮이면 형태가 바닥에 묻히므로, 모든 면에 어두운 테두리를 두른다
(`_fill()`). 테두리를 빼면 무엇인지 읽히지 않는다.
@@ -95,12 +178,47 @@
## 아트
지금은 스프라이트 없이 `_draw()`로 도형을 그린다. 색과 형태만으로 구분되는 자리 표시용이다.
**집과 생물의 형태를 아예 갈라 두었다.** 처음에는 집 10채 중 8채가 생물과 같은
`rock`이어서, 화면에서 항아리와 거품돌이 색만 다른 같은 덩어리로 보였다.
지금은 집이 `vessel`(배가 부르고 입구가 뚫린 그릇) / `wreck`(옆으로 누운 뼈대와 갈비뼈
사이의 빈틈) / `pillar`(이음매가 있는 기둥) 셋만 쓰고, 셋 다 물고기가 드나들 만한
틈이나 입구를 실루엣에 드러낸다. 생물은 이 셋을 쓰지 않는다.
`tests/smoke.gd`의 "형태 계통"이 이 규칙이 깨지지 않는지 지킨다.
**헤엄치는 생물은 집 둘레를 돈다.** `fish`/`glow`는 제자리에 서지 않고 집을 중심으로
납작한 타원 궤도를 돈다(`Aquarium._orbit_center` / `TileItem.set_orbit`).
노드의 원점 자체가 궤도를 따라 움직이므로 **Y 정렬이 앞뒤를 알아서 바꿔준다**
뒤로 돌면 집에 가리고 앞으로 나오면 집 앞에 그려진다. 여기에 뒤쪽에서 크기를 0.72배,
투명도를 0.45로 낮춰 "집 안으로 들어갔다 나온다"로 읽히게 했다.
같은 집의 물고기는 정원 수만큼 위상을 나눠 갖고, 집마다 도는 방향이 반대다.
바닥에 붙는 `plant`/`rock`은 돌지 않는다 — 해초가 집 주위를 헤엄치면 이상하다.
실제 아트가 생기면 `assets/art/`에 넣고 `tile_item.gd`
`Sprite2D` 기반으로 바꾸면 된다(카탈로그에 `texture` 항목 추가).
바닥은 `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`
@@ -115,17 +233,13 @@
## 아직 안 만든 것 (우선순위 순)
1. **꾸미기 배치 자유도** — 지금은 산 물건이 자기 구역 안 랜덤 위치에 놓인다.
드래그로 옮기고 위치를 저장하면 "내 바다"라는 느낌이 훨씬 강해진다.
`DecorSprite`에 위치를 저장하고 `GameState.producers`를 개수 대신
인스턴스 배열로 바꾸는 작업이 필요하다.
2. **두 번째 화폐(진주)** — 아름다움에 비례해 아주 천천히 쌓이고,
특별한 장식만 살 수 있게. 거품 인플레이션과 분리된 장기 목표가 생긴다.
3. **환생/이사** — 더 큰 수조로 옮기면서 영구 배율을 얻는 장르 표준 루프.
4. **소리** — 물소리와 거품 소리. 위젯이라 기본은 음소거가 맞을 듯.
5. **트레이 아이콘** — Godot 기본 기능에 없어서 GDExtension이나 별도 런처가 필요하다.
1. **두 번째 화폐(진주)** — 아름다움에 비례해 아주 천천히 쌓이고,
특별한 집만 지을 수 있게. 거품 인플레이션과 분리된 장기 목표가 생긴다.
2. **환생/이사** — 더 큰 수조로 옮기면서 영구 배율을 얻는 장르 표준 루프.
3. **소리** — 물소리와 거품 소리. 위젯이라 기본은 음소거가 맞을 듯.
4. **트레이 아이콘** — Godot 기본 기능에 없어서 GDExtension이나 별도 런처가 필요하다.
당장은 작업표시줄 창으로 충분.
6. **폭을 화면 일부만 쓰기** — 지금은 항상 폭 전체다. 왼쪽/가운데/오른쪽 정렬과
5. **폭을 화면 일부만 쓰기** — 지금은 항상 폭 전체다. 왼쪽/가운데/오른쪽 정렬과
폭 비율 선택이 있으면 넓은 모니터에서 덜 부담스럽다.
## 한글 문장
@@ -139,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`을 부르면 안 된다.
+48
View File
@@ -0,0 +1,48 @@
# Godot 4 실행 파일을 찾는다. 못 찾으면 아무것도 내놓지 않는다.
# .\find-godot.ps1 그냥 실행판 (창이 뜬다)
# .\find-godot.ps1 -Console 콘솔판 (--headless 용, 출력이 보인다)
# 찾는 순서: $env:GODOT_BIN > 아래 폴더들 > PATH 위의 godot
param([switch]$Console)
$dirs = @(
"$env:LOCALAPPDATA\Programs\Godot",
[Environment]::GetFolderPath('Desktop'),
"$env:USERPROFILE\Desktop",
"$env:OneDrive\Desktop",
"$env:USERPROFILE\Downloads",
"$env:ProgramFiles\Godot"
)
$found = $null
if ($env:GODOT_BIN -and (Test-Path $env:GODOT_BIN)) {
$found = $env:GODOT_BIN
}
if (-not $found) {
foreach ($d in $dirs) {
if (-not $d -or -not (Test-Path $d)) { continue }
# 버전이 올라가도 잡히도록 이름으로 훑고, 높은 버전을 먼저 쓴다
$hit = Get-ChildItem -Path $d -Filter "Godot_v4*_win64.exe" -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -notlike "*_console.exe" } |
Sort-Object Name -Descending | Select-Object -First 1
if ($hit) { $found = $hit.FullName; break }
}
}
if (-not $found) {
$cmd = Get-Command godot -ErrorAction SilentlyContinue
if ($cmd) { $found = $cmd.Source }
}
if (-not $found) { return }
# 찾은 것과 원하는 판이 다르면 짝이 되는 파일로 바꿔 준다
$isConsole = $found -like "*_console.exe"
if ($Console -ne $isConsole) {
if ($Console) { $alt = $found -replace '\.exe$', '_console.exe' }
else { $alt = $found -replace '_console\.exe$', '.exe' }
if (Test-Path $alt) { $found = $alt }
}
return $found
+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"
+17 -4
View File
@@ -1,7 +1,20 @@
# 에디터 없이 게임만 바로 실행합니다. 사용: 우클릭 > PowerShell로 실행, 또는 .\run.ps1
$godot = "$env:LOCALAPPDATA\Programs\Godot\Godot_v4.7.2-stable_win64.exe"
if (-not (Test-Path $godot)) {
Write-Error "Godot 찾을 수 없습니다: $godot"
# 에디터 없이 게임만 바로 실행합니다. 사용: 우클릭 > PowerShell로 실행, 또는 .\run.ps1
$godot = & "$PSScriptRoot\find-godot.ps1"
if (-not $godot) {
Write-Error "Godot 4를 찾을 수 없습니다. 실행 파일 경로를 GODOT_BIN 환경 변수에 넣어 주세요."
exit 1
}
# 클론 직후엔 .godot이 없다. 한 번 가져와야 class_name이 등록되고 스크립트가 뜬다.
if (-not (Test-Path "$PSScriptRoot\.godot")) {
Write-Host "첫 실행입니다. 프로젝트를 가져오는 중..."
$importer = & "$PSScriptRoot\find-godot.ps1" -Console
if (-not $importer) { $importer = $godot }
& $importer --headless --import --path $PSScriptRoot
if ($LASTEXITCODE -ne 0) {
Write-Error "가져오기에 실패했습니다."
exit 1
}
}
Start-Process -FilePath $godot -ArgumentList @("--path", $PSScriptRoot) -WorkingDirectory $PSScriptRoot
+480 -155
View File
@@ -1,49 +1,103 @@
extends Control
class_name Aquarium
## 화면 하단에 깔리는 탑다운 바다.
## 가로를 구역 수만큼 나눠, 왼쪽(얕은 바다)에서 오른쪽(열수구)으로 갈수록 깊어진다.
## 구역마다 격자가 있고, 산 것은 플레이어가 고른 칸에 놓인다.
## 화면 하단에 깔리는 3/4 부감 바다.
## 한 화면에 한 층만 담는다. 위층(얕은 바다)에서 아래층(열수구)으로 내려갈수록 깊어지고,
## 층은 왼쪽 아래 선택기나 휠·위아래 키로 갈아탄다.
##
## 층은 내부적으로 옆으로 늘어서 있고(층 n은 열 n*ZONE_COLUMNS부터), 화면은 그 중
## 한 구간만 비춘다. 갈아탈 때만 위아래로 미끄러뜨려 깊이가 세로로 읽히게 한다.
##
## 격자 칸을 차지하는 것은 집뿐이다. 생물은 칸이 아니라 집에 살기 때문에
## 그릴 때마다 구역별로 집에 차례로 배정해서 그 집 둘레에 세운다.
## 알림을 띄워야 할 때 (해금 실패, 배치 실패, 환불 등). main이 받아 HUD로 넘긴다.
## 알림을 띄워야 할 때 (해금 실패, 건축 실패, 환불 등). main이 받아 HUD로 넘긴다.
signal message(text: String)
## 빈 곳을 눌렀을 때. 거품 클릭 연출에 쓴다.
## 빈 곳을 눌렀을 때. 거품이 퍼지는 연출에 쓴다 — 거품은 생물이 번다.
signal clicked(local_position: Vector2)
## 배치 모드가 켜지거나 꺼졌을 때. 상점이 선택 상태를 표시하는 데 쓴다.
signal placement_changed(active: bool, kind: String, id: String)
## 집 짓기 모드가 켜지거나 꺼졌을 때. 상점이 선택 상태를 표시하는 데 쓴다.
signal build_mode_changed(active: bool, id: String)
## 이미 놓인 것을 집었거나 놓았을 때. HUD가 휴지통을 보였다 감추는 데 쓴다.
signal drag_changed(dragging: bool)
## 물 위에서 우클릭. main이 메뉴를 연다.
signal context_menu_requested
## 보고 있는 층이 바뀌었을 때. 층 선택기가 표시를 맞춘다.
signal layer_changed(index: int)
## 드래그한 것을 여기에 떨구면 치운다. main이 HUD의 휴지통 위치를 넣어준다.
var trash_rect := Rect2()
@onready var 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
## 위쪽 이만큼은 정보 바가 앉는 자리라 격자에서 뺀다.
## 여기를 비워두지 않으면 첫 줄 칸이 바에 가려 보이지도, 눌리지도 않는다.
const GRID_TOP := 30.0
## 바 높이는 글꼴과 담긴 내용에 따라 달라지므로 main이 HUD에서 재어 넣어준다.
var grid_top := 30.0:
set(value):
if is_equal_approx(grid_top, value):
return
grid_top = value
if is_node_ready():
_on_layout_changed()
## 칸의 가로:세로 비. 1보다 크면 살짝 옆으로 퍼져 부감처럼 보인다.
const TILE_ASPECT := 1.15
## 층을 갈아탈 때 미끄러지는 시간.
const LAYER_SLIDE := 0.32
## 감상 모드에서 격자가 차지하는 세로 비율. 나머지는 물기둥이 된다.
## 높이를 전부 격자에 주면 칸만 거대해지고 보이는 열은 오히려 줄어든다.
const SHOWCASE_GRID_RATIO := 0.45
const DRAG_THRESHOLD := 5.0
## _press_index에 쓰는 표식. 이번 누름은 클릭이 아니라 드래그였다는 뜻.
const PRESS_WAS_DRAG := -2
var _rng := RandomNumberGenerator.new()
var _lock_reasons := {} ## 구역 인덱스 -> 사유 Label
var _base_material: ShaderMaterial
var _placing_kind := ""
var _placing_id := ""
## 지금 짓는 중인 집의 id. 비어 있으면 짓는 중이 아니다.
var _building_id := ""
var _hover := Vector2i(-1, -1)
var _press_position := Vector2.ZERO
var _press_index := -1
var _drag_index := -1
## 누르거나 끌고 있는 대상. {} 이면 없음. TileItem의 ref_* 를 그대로 담는다.
var _press_ref := {}
var _drag_ref := {}
var _drag_node: TileItem = null
## 이번 누름이 클릭이 아니라 드래그였다는 표식.
var _press_was_drag := false
var _pressing := false
var _slide: Tween
## 낮빛을 입힐 대상들. 배치 미리보기(Ghost)는 빼둔다 -- 밤에도 또렷해야 한다.
var _daylit: Array[CanvasItem] = []
## 마지막으로 셰이더에 넣은 빛의 양. 조금이라도 달라져야 다시 넣는다.
var _applied_sun := -1.0
## 감상 모드. 바다를 아래에 깔고 그 위를 물기둥으로 채운다.
var showcase := false:
set(value):
if showcase == value:
return
showcase = value
if is_node_ready():
_on_layout_changed()
## 지금 보고 있는 층. 0이 맨 위(얕은 바다), 커질수록 깊다.
## 한 화면에 한 층만 담기므로 화면 위치는 이 값 하나로 정해진다.
var current_zone := 0:
set(value):
var next := clampi(value, 0, Catalog.zone_count() - 1)
if next == current_zone and is_node_ready():
return
var going_down := next > current_zone
current_zone = next
if is_node_ready():
_slide_to_layer(going_down)
layer_changed.emit(current_zone)
func _ready() -> void:
@@ -53,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)
@@ -62,33 +119,106 @@ 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:
return Rect2(Vector2(0.0, GRID_TOP), Vector2(size.x, maxf(size.y - GRID_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()
return Vector2i(int(floor(pos.x / c.x)), int(floor((pos.y - GRID_TOP) / c.y)))
var p := to_world(screen_pos)
var top := grid_rect().position.y
return Vector2i(int(floor(p.x / c.x)), int(floor((p.y - top) / c.y)))
func cell_rect(col: int, row: int, span := Vector2i.ONE) -> Rect2:
var c := cell_size()
return Rect2(
Vector2(col * c.x, GRID_TOP + row * c.y),
Vector2(col * c.x, grid_rect().position.y + row * c.y),
Vector2(c.x * span.x, c.y * span.y))
@@ -99,9 +229,9 @@ func zone_rect(index: int) -> Rect2:
Vector2(Catalog.ZONE_COLUMNS * c.x, size.y))
## 커서가 가리키는 칸을 기준으로 발자국을 가운데 맞춘 왼쪽 위 칸.
func _anchor_for(kind: String, id: String, hover: Vector2i) -> Vector2i:
var s := Catalog.size_of(kind, id)
## 커서가 가리키는 칸을 기준으로 발자국을 가운데 맞춘 왼쪽 위 칸.
func _anchor_for(id: String, hover: Vector2i) -> Vector2i:
var s := Catalog.size_of("house", id)
return hover - Vector2i((s.x - 1) / 2, (s.y - 1) / 2)
@@ -110,19 +240,33 @@ func _anchor_for(kind: String, id: String, hover: Vector2i) -> Vector2i:
## 크기가 바뀌거나 구역이 열리면 전부 다시 만든다.
## 스트립은 자주 바뀌지 않으므로 부분 갱신보다 통째로 다시 만드는 쪽이 단순하다.
func _on_layout_changed() -> void:
ground.rect_size = size
ground.grid_top = GRID_TOP
# 배경과 바닥은 화면이 아니라 바다 전체 너비로 그린다.
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:
@@ -141,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:
@@ -257,12 +400,19 @@ 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
# --- 놓인 것들 ---
# --- 집과 생물 그리기 ---
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
@@ -277,37 +427,161 @@ func _setup_bubbles() -> void:
func _rebuild_items() -> void:
for child in items.get_children():
child.queue_free()
for i in GameState.placements.size():
_make_item(i)
for i in GameState.houses.size():
_make_house(i)
for entry in _assign_fish():
_make_fish(entry)
func _make_item(index: int) -> TileItem:
var p := GameState.placements[index]
var d := Catalog.item(p.kind, p.id)
func _house_rect(index: int) -> Rect2:
var h := GameState.houses[index]
return cell_rect(int(h.col), int(h.row), Catalog.size_of("house", String(h.id)))
func _make_house(index: int) -> TileItem:
var id := String(GameState.houses[index].id)
var d := Catalog.house(id)
if d.is_empty():
return null
var node := TileItem.new()
items.add_child(node)
node.setup(d, p.kind, index, cell_rect(p.col, p.row, Catalog.size_of(p.kind, p.id)),
cell_size(), _rng)
node.setup(d, "house", index, _house_rect(index), cell_size(), _rng)
return node
func _make_fish(entry: Dictionary) -> void:
var d := Catalog.producer(String(entry.id))
if d.is_empty():
return
var home := _house_rect(int(entry.house))
var cell := cell_size()
var slot := int(entry.slot)
var node := TileItem.new()
items.add_child(node)
node.setup(d, "producer", -1, _fish_area(home, cell, slot), cell, _rng)
# 집은 몇 마리를 들일 수 있는지 세는 단위일 뿐, 헤엄치는 자리는 층 전체다.
# 제 집 앞에서 시작해 거기서부터 어디로든 돌아다닌다.
node.set_roam(_roam_bounds(home, cell), _rng)
## 어느 생물이 어느 집에 사는지 그릴 때마다 정한다. 집은 정원만 주므로 배정을
## 저장할 필요가 없다. 구역마다 집을 격자 순서로, 생물을 카탈로그 순서로 세워 채운다.
## 돌려주는 것: [{id, house, slot}, ...]. 정원을 넘겨 사는 것들은 빠진다.
func _assign_fish() -> Array:
var out: Array = []
for z in Catalog.zone_count():
var homes := _homes_in_zone(z)
if homes.is_empty():
continue
var hi := 0
var used := 0
for d in Catalog.PRODUCERS:
if int(d.get("zone", 0)) != z:
continue
for n in GameState.fish_count(String(d.id)):
while hi < homes.size() \
and used >= Catalog.capacity_of(String(GameState.houses[homes[hi]].id)):
hi += 1
used = 0
if hi >= homes.size():
break
out.append({"id": String(d.id), "house": homes[hi], "slot": used})
used += 1
return out
## 그 구역의 집들을 격자 순서(왼쪽 위부터)로. 배정이 매번 같아야 그림이 안 튄다.
func _homes_in_zone(zone_index: int) -> Array[int]:
var homes: Array[int] = []
for i in GameState.houses.size():
if Catalog.zone_of_column(int(GameState.houses[i].col)) == zone_index:
homes.append(i)
homes.sort_custom(func(a: int, b: int) -> bool:
var ha := GameState.houses[a]
var hb := GameState.houses[b]
if int(ha.col) != int(hb.col):
return int(ha.col) < int(hb.col)
return int(ha.row) < int(hb.row))
return homes
## 물고기가 도는 궤도의 반지름. 집을 감싸되 3/4 부감이라 세로로 납작하다.
func _orbit_radius(home: Rect2, cell: Vector2) -> Vector2:
return Vector2(home.size.x * 0.5 + cell.x * 0.55, cell.y * 0.42)
## 궤도의 중심. 집 발치에 두되, 궤도가 통째로 바다 안에 들어오도록 밀어 넣는다.
## 중심을 옮기지 않고 매 프레임 위치만 자르면 물고기가 벽에 붙어 멈춘 것처럼 보인다.
func _orbit_center(home: Rect2, cell: Vector2) -> Vector2:
var r := _orbit_radius(home, cell)
var g := _home_bounds(home, cell)
return Vector2(
_squeeze(home.position.x + home.size.x * 0.5, g.position.x + r.x, g.end.x - r.x),
_squeeze(home.end.y - cell.y * 0.10,
g.position.y + cell.y * 0.9 + r.y, g.end.y - r.y))
## 그 집이 속한 층에서 헤엄칠 수 있는 범위. 층 전체를 쓰되 가장자리는 조금 남긴다.
func _roam_bounds(home: Rect2, cell: Vector2) -> Rect2:
return _home_bounds(home, cell).grow(-cell.x * 0.3)
## 그 집이 속한 층의 테두리. 생물을 여기 안에 가둔다.
## 예전에는 화면(grid_rect)에 맞춰 잘랐는데, 층이 옆으로 늘어선 지금 그렇게 하면
## 뒤쪽 층의 생물이 전부 첫 층 오른쪽 끝으로 끌려와 제 층에서 사라진다.
func _home_bounds(home: Rect2, cell: Vector2) -> Rect2:
var z := Catalog.zone_of_column(int(floor(home.position.x / maxf(cell.x, 1.0))))
var g := grid_rect()
var width := Catalog.ZONE_COLUMNS * cell.x
return Rect2(Vector2(z * width, g.position.y), Vector2(width, g.size.y))
## 범위가 뒤집힐 만큼 좁으면(작은 창) 가운데로 보낸다. clampf는 그때 엉뚱한 값을 준다.
func _squeeze(value: float, lo: float, hi: float) -> float:
if lo > hi:
return (lo + hi) * 0.5
return clampf(value, lo, hi)
## 집 둘레의 붙박이 자리 하나. 해초·거품돌처럼 바닥에 서는 것이 쓴다.
## 왼쪽 → 오른쪽 → 앞 순으로 돌고, 정원이 더 크면 한 겹 더 벌어진다.
func _fish_area(home: Rect2, cell: Vector2, slot: int) -> Rect2:
# 앞자리는 살짝 오른쪽으로 밀어 둔다. 바다 왼쪽 끝에 붙은 집은 왼쪽 자리가
# 안으로 밀려 들어오는데, 그때 앞자리와 겹쳐 한 마리처럼 보이기 때문이다.
const SPOTS := [Vector2(-0.5, -0.1), Vector2(0.5, -0.1), Vector2(0.12, 0.62)]
var spot: Vector2 = SPOTS[slot % SPOTS.size()]
var ring := float(slot / SPOTS.size())
var cx := home.position.x + home.size.x * 0.5
var x := cx + spot.x * (home.size.x + cell.x * 0.9) + ring * cell.x * 0.28
var base := home.end.y + spot.y * cell.y + ring * cell.y * 0.22
# 제 층 밖으로 새어 나가지 않게 잡아둔다.
var g := _home_bounds(home, cell)
x = clampf(x, g.position.x + cell.x * 0.5, g.end.x - cell.x * 0.5)
base = clampf(base, g.position.y + cell.y * 0.6, g.end.y)
return Rect2(Vector2(x - cell.x * 0.5, base - cell.y), cell)
func _on_placed(index: int) -> void:
var node := _make_item(index)
# 집이 하나 늘면 생물 배정도 달라지므로 통째로 다시 그린다.
_rebuild_items()
var node := _node_for_house(index)
if node:
# 새로 은 살짝 부풀며 등장한다.
# 새로 은 살짝 부풀며 등장한다.
node.scale = Vector2.ZERO
var t := create_tween()
t.tween_property(node, "scale", Vector2.ONE, 0.28) \
.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
## 커서 아래에 있는 놓인 것. 그려진 모양으로 먼저 찾는다.
## 커서 아래에 있는 것. 그려진 모양으로 먼저 찾는다.
## 키 큰 것은 제 칸보다 위쪽에 그려지므로, 보이는 자리를 눌러야 집히는 게 자연스럽다.
## 앞줄(화면 아래쪽)에 있는 것이 이긴다.
func _pick_at(pos: Vector2) -> int:
var best := -1
func _pick_at(pos: Vector2) -> Dictionary:
var best: TileItem = null
var best_y := -INF
for child in items.get_children():
var it := child as TileItem
@@ -315,92 +589,106 @@ func _pick_at(pos: Vector2) -> int:
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.placement_index
if best >= 0:
return best
# 그림에 안 걸렸으면 발자국 칸으로 한 번 더 본다.
var cell := cell_at(pos)
return GameState.placement_at(cell.x, cell.y)
best = it
if best == null:
# 그림에 안 걸렸으면 발자국 칸으로 한 번 더 본다. 칸을 가진 것은 집뿐이다.
var cell := cell_at(pos)
var index := GameState.house_at(cell.x, cell.y)
if index < 0:
return {}
return {"kind": "house", "index": index, "id": String(GameState.houses[index].id)}
return {"kind": best.ref_kind, "index": best.ref_index, "id": best.ref_id}
func _item_node(index: int) -> TileItem:
func _node_for_house(index: int) -> TileItem:
for child in items.get_children():
var it := child as TileItem
if it and it.placement_index == index:
if it and it.ref_kind == "house" and it.ref_index == index:
return it
return null
# --- 배치 모드 ---
func _node_for(ref: Dictionary) -> TileItem:
if ref.is_empty():
return null
if String(ref.kind) == "house":
return _node_for_house(int(ref.index))
for child in items.get_children():
var it := child as TileItem
if it and it.ref_kind == "producer" and it.ref_id == String(ref.id):
return it
return null
func begin_placement(kind: String, id: String) -> void:
if kind == "upgrade":
# --- 집 짓기 모드 ---
func begin_build(id: String) -> void:
if Catalog.house(id).is_empty():
return
_cancel_drag()
_placing_kind = kind
_placing_id = id
placement_changed.emit(true, kind, id)
_building_id = id
# 그 구역이 화면 밖이면 지을 자리를 찍을 수가 없다. 먼저 데려다 놓는다.
current_zone = Catalog.zone_of("house", id)
build_mode_changed.emit(true, id)
_update_ghost()
func cancel_placement() -> void:
if _placing_id.is_empty():
func cancel_build() -> void:
if _building_id.is_empty():
return
_placing_kind = ""
_placing_id = ""
_building_id = ""
ghost.hide()
placement_changed.emit(false, "", "")
build_mode_changed.emit(false, "")
func is_placing() -> bool:
return not _placing_id.is_empty()
func is_building() -> bool:
return not _building_id.is_empty()
func _update_ghost() -> void:
if not is_placing() or _hover.x < 0:
if not is_building() or _hover.x < 0:
ghost.hide()
return
var anchor := _anchor_for(_placing_kind, _placing_id, _hover)
var span := Catalog.size_of(_placing_kind, _placing_id)
var ok := GameState.can_place_at(_placing_kind, _placing_id, anchor.x, anchor.y) \
and GameState.bubbles >= GameState.cost_of(_placing_kind, _placing_id)
var anchor := _anchor_for(_building_id, _hover)
var span := Catalog.size_of("house", _building_id)
var ok := GameState.can_build_at(_building_id, anchor.x, anchor.y) \
and GameState.bubbles >= GameState.cost_of("house", _building_id)
ghost.show_at(cell_rect(anchor.x, anchor.y, span), ok)
func _try_place() -> void:
var kind := _placing_kind
var id := _placing_id
var anchor := _anchor_for(kind, id, _hover)
func _try_build() -> void:
var id := _building_id
var anchor := _anchor_for(id, _hover)
if GameState.bubbles < GameState.cost_of(kind, id):
if GameState.bubbles < GameState.cost_of("house", id):
message.emit("거품이 모자라요")
cancel_placement()
cancel_build()
return
if not GameState.can_place_at(kind, id, anchor.x, anchor.y):
message.emit(_why_not(kind, id, anchor))
if not GameState.can_build_at(id, anchor.x, anchor.y):
message.emit(_why_not(id, anchor))
return
GameState.place(kind, id, anchor.x, anchor.y)
# 계속 을 수 있으면 모드를 유지한다. 같은 여러 깔 때 편하다.
if kind == "ornament" or GameState.bubbles < GameState.cost_of(kind, id):
cancel_placement()
GameState.build_house(id, anchor.x, anchor.y)
# 계속 을 수 있으면 모드를 유지한다. 같은 집을 여러 깔 때 편하다.
if GameState.bubbles < GameState.cost_of("house", id):
cancel_build()
else:
_update_ghost()
func _why_not(kind: String, id: String, anchor: Vector2i) -> String:
var item_zone := Catalog.zone_of(kind, id)
func _why_not(id: String, anchor: Vector2i) -> String:
var item_zone := Catalog.zone_of("house", id)
if not GameState.is_zone_unlocked(item_zone):
return "%s 아직 잠겨 있어요" % Korean.i(String(Catalog.zone(item_zone).name))
var cells := GameState.footprint(kind, id, anchor.x, anchor.y)
var cells := GameState.footprint(id, anchor.x, anchor.y)
if cells.is_empty():
return "바다 밖으로 나가요"
for c in cells:
if Catalog.zone_of_column(c.x) != item_zone:
return "%s 안에만 을 수 있어요" % Catalog.zone(item_zone).name
return "이미 무언가 놓여 있어요"
return "%s 안에만 을 수 있어요" % Catalog.zone(item_zone).name
return "이미 무언가 있어요"
# --- 입력 ---
@@ -408,11 +696,12 @@ func _why_not(kind: String, id: String, anchor: Vector2i) -> String:
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
_hover = cell_at(event.position)
if _drag_index >= 0:
if not _drag_ref.is_empty():
_update_drag(event.position)
elif is_placing():
elif is_building():
_update_ghost()
elif _press_index >= 0 and event.position.distance_to(_press_position) > DRAG_THRESHOLD:
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
@@ -420,97 +709,133 @@ func _gui_input(event: InputEvent) -> void:
return
if event.button_index == MOUSE_BUTTON_RIGHT and event.pressed:
if is_placing():
cancel_placement()
elif _drag_index >= 0:
if is_building():
cancel_build()
elif not _drag_ref.is_empty():
_cancel_drag()
else:
context_menu_requested.emit()
return
# 휠은 층을 오르내린다. 위로 굴리면 얕은 쪽, 아래로 굴리면 깊은 쪽.
if event.pressed and event.button_index in [
MOUSE_BUTTON_WHEEL_UP, MOUSE_BUTTON_WHEEL_DOWN]:
current_zone += -1 if event.button_index == MOUSE_BUTTON_WHEEL_UP else 1
return
if event.button_index != MOUSE_BUTTON_LEFT:
return
if event.pressed:
_hover = cell_at(event.position)
if is_placing():
_try_place()
if is_building():
_try_build()
return
_press_position = event.position
_press_index = _pick_at(event.position)
_press_ref = _pick_at(event.position)
_press_was_drag = false
_pressing = true
else:
if _drag_index >= 0:
if not _drag_ref.is_empty():
_drop(event.position)
elif _press_index != PRESS_WAS_DRAG:
# 끌지 않고 뗐으면 그냥 물을 클릭한 것으로 친다.
elif not _press_was_drag:
# 끌지 않고 뗐으면 물을 만진 것으로 친다.
clicked.emit(event.position)
_press_index = -1
_press_ref = {}
_press_was_drag = false
_pressing = false
func _begin_drag() -> void:
if _press_index < 0:
if _press_ref.is_empty():
return
_drag_index = _press_index
_press_index = PRESS_WAS_DRAG
_drag_ref = _press_ref
_drag_node = _node_for(_drag_ref)
# 끄는 동안에는 궤도를 멈춘다. 안 그러면 손을 따라오지 않고 제 길을 간다.
if _drag_node:
_drag_node.roam_enabled = false
_press_was_drag = true
drag_changed.emit(true)
func _update_drag(pos: Vector2) -> void:
var node := _item_node(_drag_index)
if node:
node.position = pos
if _drag_node and is_instance_valid(_drag_node):
_drag_node.position = to_world(pos)
if trash_rect.has_point(pos):
# 생물은 칸을 차지하지 않으므로 놓일 자리를 미리 보여줄 것이 없다.
if trash_rect.has_point(pos) or String(_drag_ref.kind) != "house":
ghost.hide()
return
var p := GameState.placements[_drag_index]
var anchor := _anchor_for(p.kind, p.id, _hover)
var span := Catalog.size_of(p.kind, p.id)
var ok := GameState.can_place_at(p.kind, p.id, anchor.x, anchor.y, _drag_index)
ghost.show_at(cell_rect(anchor.x, anchor.y, span), ok)
var id := String(_drag_ref.id)
var anchor := _anchor_for(id, _hover)
var ok := GameState.can_build_at(id, anchor.x, anchor.y, int(_drag_ref.index))
ghost.show_at(cell_rect(anchor.x, anchor.y, Catalog.size_of("house", id)), ok)
func _drop(pos: Vector2) -> void:
var index := _drag_index
_drag_index = -1
var ref := _drag_ref
_drag_ref = {}
_drag_node = null
ghost.hide()
drag_changed.emit(false)
if index < 0 or index >= GameState.placements.size():
if trash_rect.has_point(pos):
if String(ref.kind) == "house":
_trash_house(int(ref.index))
else:
_release_fish(String(ref.id))
return
# 집은 끌어다 옮길 수 있다. 생물은 자기 집을 따라다니므로 제자리로 돌아간다.
if String(ref.kind) == "house":
var anchor := _anchor_for(String(ref.id), _hover)
if not GameState.move_house(int(ref.index), anchor.x, anchor.y):
message.emit("여기에는 놓을 수 없어요")
_rebuild_items()
func _trash_house(index: int) -> void:
if index < 0 or index >= GameState.houses.size():
_rebuild_items()
return
if trash_rect.has_point(pos):
var p := GameState.placements[index]
var item_name := String(Catalog.item(p.kind, p.id).get("name", ""))
var refund := GameState.remove(index)
message.emit("%s 치우고 거품 %s를 돌려받았어요" % [
Korean.eul(item_name), NumberFormat.short(refund)])
if not GameState.can_remove_house(index):
message.emit("살고 있는 생물이 갈 곳이 없어요")
_rebuild_items()
return
var house_name := String(Catalog.house(String(GameState.houses[index].id)).get("name", ""))
var refund := GameState.remove_house(index)
message.emit("%s 헐고 거품 %s를 돌려받았어요" % [
Korean.eul(house_name), NumberFormat.short(refund)])
var placement := GameState.placements[index]
var anchor := _anchor_for(placement.kind, placement.id, _hover)
if not GameState.move(index, anchor.x, anchor.y):
message.emit("여기에는 놓을 수 없어요")
# 성공이든 실패든 제자리를 다시 잡아준다.
_rebuild_items()
func _release_fish(id: String) -> void:
if not GameState.can_release_fish(id):
message.emit("마지막 생물은 내보낼 수 없어요")
_rebuild_items()
return
var fish_name := String(Catalog.producer(id).get("name", ""))
var refund := GameState.release_fish(id)
message.emit("%s 내보내고 거품 %s를 돌려받았어요" % [
Korean.eul(fish_name), NumberFormat.short(refund)])
func _cancel_drag() -> void:
if _drag_index < 0:
if _drag_ref.is_empty():
return
_drag_index = -1
_drag_ref = {}
_drag_node = null
ghost.hide()
drag_changed.emit(false)
_rebuild_items()
## 클릭 지점에서 거품이 지는 연출.
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)
+574 -24
View File
@@ -5,11 +5,20 @@ class_name TileItem
## 바닥은 위에서 내려다보지만 물건은 "서 있는" 모습으로 그린다.
## 노드의 원점은 발이 바닥에 닿는 지점(발자국 아래 가운데)이고,
## 그림은 거기서 위(-Y)로 자란다. 그래서 키가 큰 것은 뒷줄을 가린다.
##
## 헤엄치는 것은 여기에 hover가 하나 더 붙는다. 원점은 여전히 "모래 위 어디"를
## 가리키고 그림자도 거기 남지만, 몸은 hover만큼 위에 그린다. 이 둘을 나누지
## 않으면 -- 즉 물고기를 설치물과 똑같이 바닥에 붙여 놓으면 -- 모래 위를 기어
## 다니는 것처럼 보인다. 뜬 높이만큼 몸과 그림자가 벌어져야 물속으로 읽힌다.
## 아직 아트 에셋이 없어서 형태별로 직접 그린다.
## assets/art/ 에 스프라이트가 생기면 Sprite2D + offset 기반으로 바꾸면 된다.
## placements 배열에서의 인덱스. 클릭·드래그로 무엇을 집었는지 알아내는 데 쓴다.
var placement_index := -1
## 이 그림이 가리키는 것. 클릭·드래그로 무엇을 집었는지 알아내는 데 쓴다.
## 집이면 ref_kind "house" + ref_index (houses 배열 인덱스)
## 생물이면 ref_kind "producer" + ref_id (종류). 생물은 개체를 구분하지 않는다.
var ref_kind := ""
var ref_id := ""
var ref_index := -1
var data: Dictionary = {}
var shape := "rock"
@@ -19,7 +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
## 헤엄치는 것만 돌아다닌다. 끌고 있는 동안에는 멈춘다.
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
@@ -32,7 +81,9 @@ var _shape_seed := 0.0
func setup(item_data: Dictionary, kind: String, index: int, area: Rect2, cell: Vector2,
rng: RandomNumberGenerator) -> void:
data = item_data
placement_index = index
ref_kind = kind
ref_index = index
ref_id = String(item_data.get("id", ""))
shape = String(item_data.get("shape", "rock"))
color = item_data.get("color", Color.WHITE)
_phase = rng.randf() * TAU
@@ -46,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)
@@ -62,6 +114,248 @@ func set_area(area: Rect2, cell: Vector2, height_tiles: float) -> void:
queue_redraw()
## 층 전체를 자유롭게 돌아다니게 한다.
## 집은 정원을 세는 단위일 뿐이고, 헤엄치는 자리는 층 전체다.
##
## 움직임은 두 상태를 오간다 — 한동안 나아가다가(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_roaming() -> bool:
return _roaming and roam_enabled and _roam.size.x > 1.0
## 헤엄칠 수 있는 세로 범위. 종류마다 물속에서 노는 높이가 다르다.
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)
## 그려지는 대략의 범위. 클릭으로 집을 때 쓴다.
func visual_rect() -> Rect2:
var top := -body_height - hover - _swim_range.y
@@ -72,30 +366,49 @@ func visual_rect() -> Rect2:
func _process(delta: float) -> void:
_t += delta
if is_roaming():
_roam_step(delta)
if _swim_range != Vector2.ZERO:
var next_x := sin(_t * 0.55 + _phase) * _swim_range.x
# 방향을 튼 순간 몸도 돌아본다.
if 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()
_: _draw_rock()
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
@@ -132,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([
@@ -166,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()
## 스스로 빛나며 떠 있는 것. 후광과 아래로 늘어진 촉수.
@@ -175,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:
@@ -188,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)
# 반구형 갓.
@@ -198,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))
## 바위·구조물. 아래가 넓고 위로 갈수록 좁아지는 덩어리.
@@ -226,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=꼭대기)에서 옆으로 벌어지는 정도.
@@ -251,3 +570,234 @@ func _fill(points: PackedVector2Array, fill: Color, outline := true) -> void:
var loop := points.duplicate()
loop.append(points[0])
draw_polyline(loop, fill.darkened(0.55), maxf(span.x * 0.04, 1.0), true)
# --- 집 전용 형태 ---
# 생물과 같은 도형을 쓰면 "건물"과 "사는 것"이 구분되지 않는다.
# 그래서 집은 아래 셋만 쓰고, 셋 다 물고기가 드나들 만한 틈이나 입구를 드러낸다.
## 항아리·유리병. 배가 부르고 목이 좁으며, 꼭대기에 어두운 입구가 뚫려 있다.
func _draw_vessel() -> void:
# 발자국 너비를 다 쓰면 항아리가 납작한 돌처럼 보인다. 좁혀야 그릇으로 읽힌다.
var w := span.x * 0.29
var h := body_height
var steps := 14
var body := PackedVector2Array()
for i in steps + 1:
var f := float(i) / steps
body.append(Vector2(-w * _vessel_profile(f), -h * f))
for i in range(steps, -1, -1):
var f := float(i) / steps
body.append(Vector2(w * _vessel_profile(f), -h * f))
_fill(body, color.darkened(0.18))
# 빛을 받는 왼쪽 배. 몸통보다 좁게 얹어야 둥글어 보인다.
var lit := PackedVector2Array()
for p in body:
lit.append(Vector2(p.x * 0.52 - w * 0.20, p.y))
_fill(lit, color, false)
# 입구. 물고기가 드나드는 곳이라 확실히 어둡게 파 둔다.
var mouth := w * _vessel_profile(1.0)
_xform(Vector2(0, -h), Vector2(1.0, 0.38))
draw_circle(Vector2.ZERO, mouth * 1.05, color.darkened(0.72))
_xform()
## 밑동은 좁고, 중간이 부르고, 목에서 다시 좁아진다.
func _vessel_profile(f: float) -> float:
return 0.55 + sin(clampf(f, 0.0, 1.0) * PI) * 0.50 - f * 0.16
## 난파선·잠수정·고래 뼈. 옆으로 길게 누운 뼈대와 그 사이의 빈틈.
## 기울여 그려야 "가라앉은 것"으로 읽힌다.
func _draw_wreck() -> void:
var w := span.x * 0.44
var h := body_height
var lean := h * 0.13 # 오른쪽으로 기운 정도
var deck := -h * 0.52
# 선체. 아래는 둥글고 위는 갑판으로 열려 있다.
# 갑판(왼→오른)에서 시작해 오른쪽 뱃전을 타고 내려간 뒤,
# 둥근 배 밑을 훑어 왼쪽으로 돌아온다. 한 방향으로만 돌아야
# 폴리곤이 제 몸을 가로지르지 않는다 -- 그러면 삼각분할이 실패한다.
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)
# 갈비뼈. 사이의 빈틈이 물고기가 숨는 자리로 읽힌다.
var rib := color.darkened(0.48)
var thick := maxf(span.x * 0.035, 1.2)
for i in 3:
var x := (float(i) - 1.0) * w * 0.52
var top := deck * (0.78 + float(i) * 0.14)
draw_line(Vector2(x, -h * 0.06), Vector2(x + lean * 0.8, top), rib, thick)
# 부러진 돛대 하나. 실루엣에 세로선이 하나 있어야 배로 읽힌다.
draw_line(Vector2(-w * 0.30, deck * 0.85),
Vector2(-w * 0.30 + lean * 1.6, -h * 1.0), color.darkened(0.32), thick * 1.3)
## 석주·석상·굴뚝·수정. 위로 곧게 선 기둥. 이음매를 그어 돌덩이로 읽히게 한다.
func _draw_pillar() -> void:
var w := span.x * 0.22
var h := body_height
var body := PackedVector2Array([
Vector2(-w * 1.30, 0.0),
Vector2(-w * 0.92, -h * 0.90),
Vector2(-w * 1.02, -h),
Vector2(w * 1.02, -h),
Vector2(w * 0.92, -h * 0.90),
Vector2(w * 1.30, 0.0),
])
_fill(body, color.darkened(0.26))
# 빛을 받는 왼쪽 면.
var lit := PackedVector2Array()
for p in body:
lit.append(Vector2(p.x * 0.46 - w * 0.34, p.y))
_fill(lit, color, false)
# 돌을 쌓아 올린 이음매.
var seam := color.darkened(0.52)
for i in 3:
var y := -h * (0.26 + float(i) * 0.23)
var half := w * lerpf(1.24, 1.0, float(i) / 3.0)
draw_line(Vector2(-half, y), Vector2(half, y), seam, maxf(span.x * 0.028, 1.0))
# 꼭대기 면. 부감이라 윗면이 살짝 보인다.
_xform(Vector2(0, -h), Vector2(1.0, 0.34))
draw_circle(Vector2.ZERO, w * 1.02, color.lightened(0.18))
_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()
+163 -26
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))
@@ -57,15 +70,27 @@ func _ready() -> void:
shop.hide()
shop.closed.connect(_close_shop)
shop.item_selected.connect(_on_shop_item_selected)
# 격자는 정보 바 아래에서 시작해야 한다. 바 높이는 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)
aquarium.message.connect(hud.show_toast)
aquarium.clicked.connect(_on_water_clicked)
aquarium.clicked.connect(_on_water_touched)
aquarium.context_menu_requested.connect(_open_menu)
aquarium.placement_changed.connect(_on_placement_changed)
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)
@@ -86,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()
@@ -100,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
@@ -108,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
# 통과 영역은 창의 물리 픽셀 좌표로 준다.
@@ -127,47 +167,93 @@ 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_placing():
aquarium.cancel_placement()
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()
func _on_water_clicked(pos: Vector2) -> void:
var gained := GameState.click()
## 물을 만지면 거품이 퍼진다. 거품은 생물이 버는 것이라 여기서 얻는 것은 없다.
func _on_water_touched(pos: Vector2) -> void:
aquarium.pop_at(pos)
hud.float_number(pos, gained)
func _toggle_shop() -> void:
if shop.visible:
_close_shop()
else:
aquarium.cancel_placement()
aquarium.cancel_build()
shop.show()
func _close_shop() -> void:
shop.hide()
aquarium.cancel_placement()
aquarium.cancel_build()
## 상점에서 무언가를 골랐을 때.
## 집만 바다에 자리를 잡아야 하므로 배치 모드로 들어가고,
## 생물과 업그레이드는 자리가 필요 없어서 그 자리에서 바로 사진다.
func _on_shop_item_selected(kind: String, id: String) -> void:
if kind == "upgrade":
if GameState.buy_upgrade(id):
hud.show_toast("%s 켰어요" % Korean.eul(String(Catalog.upgrade(id).name)))
match kind:
"upgrade":
if GameState.buy_upgrade(id):
hud.show_toast("%s 켰어요" % Korean.eul(String(Catalog.upgrade(id).name)))
"producer":
_take_fish(id)
"house":
aquarium.begin_build(id)
func _take_fish(id: String) -> void:
var fish_name := String(Catalog.producer(id).get("name", ""))
if GameState.buy_fish(id):
var z := Catalog.zone_of("producer", id)
hud.show_toast("%s 들였어요 · %s 남은 자리 %d" % [
Korean.eul(fish_name), Catalog.zone(z).name, GameState.free_slots_in_zone(z)])
return
aquarium.begin_placement(kind, id)
var why := GameState.fish_blocker(id)
hud.show_toast(why if not why.is_empty() else "지금은 들일 수 없어요", 3.0)
## 배치 중에는 상점을 잠시 치운다. 상점이 바다 오른쪽을 가리고 있어서
## 열어 둔 채로는 뒤쪽 구역에 을 수가 없다.
func _on_placement_changed(active: bool, kind: String, id: String) -> void:
shop.set_selected(active, kind, id)
## 집을 짓는 동안에는 상점을 잠시 치운다. 상점이 바다 오른쪽을 가리고 있어서
## 열어 둔 채로는 뒤쪽 구역에 을 수가 없다.
func _on_build_mode_changed(active: bool, id: String) -> void:
shop.set_selected(active, id)
shop.visible = not active
if active:
hud.show_toast("을 칸을 클릭하세요 · 우클릭이나 Esc로 취소", 3.0)
hud.show_toast("집을 지을 칸을 클릭하세요 · 우클릭이나 Esc로 취소", 3.0)
func _on_drag_changed(dragging: bool) -> void:
hud.set_trash_visible(dragging)
@@ -180,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)
@@ -199,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:
@@ -251,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()
@@ -270,7 +408,6 @@ func _confirm_reset() -> void:
add_child(dialog)
dialog.confirmed.connect(func():
SaveManager.wipe()
aquarium.rebuild()
hud.show_toast("수조를 비웠어요")
)
dialog.close_requested.connect(dialog.queue_free)
+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"]
+37 -18
View File
@@ -5,6 +5,12 @@ class_name HUDLayer
signal shop_pressed
signal menu_pressed
## 정보 바가 위에서 자리를 얼마나 먹는지. 바 높이는 글꼴과 내용에 따라 달라지므로
## 재어서 알려주고, main이 받아 수조 격자를 그만큼 아래로 내린다.
signal reserved_top_changed(px: float)
## 바 아래로 이만큼은 띄워야 첫 줄 칸이 바에 닿지 않는다.
const BAR_CLEARANCE := 22.0
@onready var control_bar: PanelContainer = $ControlBar
@onready var bubbles_label: Label = %BubblesLabel
@@ -14,6 +20,7 @@ signal menu_pressed
@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
@@ -21,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)
@@ -38,6 +50,11 @@ func _ready() -> void:
shop_button.pressed.connect(func(): shop_pressed.emit())
menu_button.pressed.connect(func(): menu_pressed.emit())
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)
GameState.reloaded.connect(_refresh_zone_label)
@@ -47,7 +64,10 @@ func _ready() -> void:
func _process(_delta: float) -> void:
bubbles_label.text = NumberFormat.short(GameState.bubbles)
bps_label.text = "초당 %s" % NumberFormat.short(GameState.bps)
# 정원이 곧 성장의 한계라 초당 생산량 옆에 늘 같이 둔다.
bps_label.text = "초당 %s · 생물 %d/%d" % [
NumberFormat.short(GameState.bps), GameState.fish_total, GameState.capacity_total()
]
beauty_label.text = "아름다움 %s · x%.2f" % [
NumberFormat.short(GameState.beauty), GameState.beauty_mult
]
@@ -72,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():
@@ -82,21 +111,6 @@ func show_toast(text: String, duration := 2.0) -> void:
_toast_tween.tween_property(toast, "modulate:a", 0.0, 0.5)
## 클릭했을 때 얻은 양이 위로 떠오르는 숫자.
func float_number(at: Vector2, amount: float) -> void:
var l := Label.new()
add_child(l)
l.text = "+%s" % NumberFormat.short(amount)
UIStyle.label(l, 15, UIStyle.ACCENT)
l.position = at - Vector2(14, 10)
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
var t := create_tween().set_parallel(true)
t.tween_property(l, "position:y", l.position.y - 42.0, 0.9) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
t.tween_property(l, "modulate:a", 0.0, 0.9)
t.chain().tween_callback(l.queue_free)
## 무언가를 집어 든 동안만 휴지통을 보여준다. 평소에는 자리를 차지하지 않는다.
func set_trash_visible(on: bool) -> void:
trash.visible = on
@@ -105,3 +119,8 @@ func set_trash_visible(on: bool) -> void:
## 휴지통의 사각형. HUD와 바다가 같은 좌표계(Main의 전체 영역)를 쓰므로 그대로 넘길 수 있다.
func trash_area() -> Rect2:
return Rect2(trash.position, trash.size)
## 정보 바가 먹는 세로 자리. 격자는 이 아래에서 시작해야 한다.
func reserved_top() -> float:
return control_bar.position.y + control_bar.size.y + BAR_CLEARANCE
+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
+40 -26
View File
@@ -1,18 +1,18 @@
extends PanelContainer
class_name ShopPanel
## 상점. 생물 / 장식 / 업그레이드 세 탭.
## 상점. 생물 / / 업그레이드 세 탭.
##
## 생물과 장식은 여기서 바로 사지지 않는다. 고르면 배치 모드로 들어가고,
## 실제 구매는 바다에서 칸을 클릭할 때 일어난다. 업그레이드만 즉시 구매다.
## 집만 바다에 자리를 잡아야 하므로, 고르면 배치 모드로 들어가고
## 실제 값은 칸을 클릭할 때 치러진다. 생물과 업그레이드는 여기서 바로 사진다.
## 목록은 Catalog에서 바로 만들어지므로, 아이템을 추가할 때 이 파일은 건드릴 필요가 없다.
signal closed
## 고른 아이템. kind가 "upgrade"면 즉시 구매, 아니면 배치 모드로 들어간다.
## 고른 것. "house"면 배치 모드로 들어가고, 나머지는 즉시 구매다.
signal item_selected(kind: String, id: String)
const TABS := [
{"kind": "producer", "label": "생물"},
{"kind": "ornament", "label": "장식"},
{"kind": "house", "label": ""},
{"kind": "upgrade", "label": "업그레이드"},
]
@@ -54,8 +54,8 @@ func _process(_delta: float) -> void:
_update_rows()
## 배치 모드가 켜지고 꺼질 때 main이 알려준다. 고른 행을 강조해 둔다.
func set_selected(active: bool, _kind: String, id: String) -> void:
## 집 짓기 모드가 켜지고 꺼질 때 main이 알려준다. 고른 행을 강조해 둔다.
func set_selected(active: bool, id: String) -> void:
_selected_id = id if active else ""
_update_rows()
@@ -77,8 +77,8 @@ func _visible_ids() -> Array[String]:
for d in _current_items():
if not GameState.is_revealed(kind, d.id):
continue
# 한 번만 살 수 있는 것은 이미 가지고 있으면 목록에서 빠진다.
if kind != "producer" and GameState.owned(kind, d.id):
# 업그레이드만 한 번뿐이다. 생물과 집은 얼마든지 더 들일 수 있다.
if GameState.owned(kind, String(d.id)):
continue
ids.append(String(d.id))
return ids
@@ -192,16 +192,16 @@ func _name_text(kind: String, d: Dictionary) -> String:
func _effect_text(kind: String, d: Dictionary) -> String:
var zone_name := String(Catalog.zone(int(d.get("zone", 0))).name)
match kind:
"producer":
return "%s · 초당 +%s · 아름다움 +%s" % [
Catalog.zone(int(d.get("zone", 0))).name,
NumberFormat.short(d.bps), NumberFormat.short(d.beauty)
zone_name, NumberFormat.short(d.bps), NumberFormat.short(d.beauty)
]
"ornament":
return "%s · 아름다움 +%s · 생산 +%d%%" % [
Catalog.zone(int(d.get("zone", 0))).name,
NumberFormat.short(d.beauty), int(round(float(d.mult) * 100.0))
"house":
return "%s · 생물 %d마리 · 아름다움 +%s" % [
zone_name, Catalog.capacity_of(String(d.id)),
NumberFormat.short(d.beauty)
]
"upgrade":
return String(d.desc)
@@ -214,28 +214,42 @@ func _update_rows() -> void:
var kind: String = r.kind
var cost := GameState.cost_of(kind, id)
var affordable := GameState.bubbles >= cost
# 생물은 값을 치를 수 있어도 살 집이 없으면 못 들인다.
var has_room: bool = kind != "producer" or GameState.can_take_fish(String(id))
r.cost.text = NumberFormat.short(cost)
r.cost.modulate = Color.WHITE if affordable else Color(1, 1, 1, 0.45)
r.button.disabled = not affordable
# 고른 것은 밝게 남겨 두어 지금 무엇을 는 중인지 보이게 한다.
r.button.disabled = not affordable or not has_room
# 고른 것은 밝게 남겨 두어 지금 무엇을 는 중인지 보이게 한다.
r.button.modulate = Color(1.0, 1.25, 1.35) if id == _selected_id else Color.WHITE
if kind == "producer":
var owned := GameState.count_of(id)
r.owned.text = "놓임 %d" % owned if owned > 0 else ""
else:
r.owned.text = ""
var owned := GameState.count_of(kind, String(id))
match kind:
"producer":
if not has_room and affordable:
r.owned.text = "자리 없음"
else:
r.owned.text = "%d마리" % owned if owned > 0 else ""
"house":
r.owned.text = "%d" % owned if owned > 0 else ""
_:
r.owned.text = ""
_update_space_label()
## 구역별로 빈 칸이 얼마나 남았는지. 칸이 곧 한계이므로 늘 보이게 둔다.
## 구역별로 생물 자리가 얼마나 남았는지. 이것이 성장의 진짜 한계다.
## 집 탭에서는 집을 세울 빈 칸을 대신 보여준다.
func _update_space_label() -> void:
if TABS[_tab].kind == "upgrade":
space_label.text = "칸을 차지하지 않아요"
space_label.text = "자리를 차지하지 않아요"
return
var parts := PackedStringArray()
var is_house_tab: bool = TABS[_tab].kind == "house"
for i in GameState.unlocked_zones:
parts.append("%s %d" % [Catalog.zone(i).name, GameState.free_cells_in_zone(i)])
space_label.text = "남은 칸 · " + " ".join(parts)
var left := GameState.free_cells_in_zone(i) if is_house_tab \
else maxi(GameState.free_slots_in_zone(i), 0)
parts.append("%s %d" % [Catalog.zone(i).name, left])
var label := "빈 칸 · " if is_house_tab else "남은 자리 · "
space_label.text = label + " ".join(parts)
+17 -2
View File
@@ -1,4 +1,19 @@
# 경제 로직 스모크 테스트를 돌립니다. 사용: .\test.ps1
$godot = "$env:LOCALAPPDATA\Programs\Godot\Godot_v4.7.2-stable_win64_console.exe"
# 경제 로직 스모크 테스트를 돌립니다. 사용: .\test.ps1
$godot = & "$PSScriptRoot\find-godot.ps1" -Console
if (-not $godot) {
Write-Error "Godot 4를 찾을 수 없습니다. 실행 파일 경로를 GODOT_BIN 환경 변수에 넣어 주세요."
exit 1
}
# 클론 직후엔 .godot이 없다. 한 번 가져와야 class_name이 등록되고 스크립트가 뜬다.
if (-not (Test-Path "$PSScriptRoot\.godot")) {
Write-Host "첫 실행입니다. 프로젝트를 가져오는 중..."
& $godot --headless --import --path $PSScriptRoot
if ($LASTEXITCODE -ne 0) {
Write-Error "가져오기에 실패했습니다."
exit 1
}
}
& $godot --headless --path $PSScriptRoot res://tests/smoke.tscn
exit $LASTEXITCODE
+558 -153
View File
@@ -9,24 +9,31 @@ func _ready() -> void:
# 이 테스트는 실제 저장 파일을 건드리면 안 된다.
# 끄지 않으면 종료할 때 초기화된 상태가 저장되어 진행도가 날아간다.
SaveManager.persistence_enabled = false
GameState.reset()
GameState.reset(false)
_test_click()
_test_producer_cost_curve()
_test_place()
_test_footprint_and_multi_tile()
_test_starting_state()
_test_auto_income()
_test_cost_curves()
_test_build_house()
_test_house_footprint()
_test_zone_boundaries()
_test_move()
_test_remove_refund()
_test_capacity()
_test_move_house()
_test_remove_house()
_test_release_fish()
_test_space_is_the_limit()
_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("모든 검사 통과")
@@ -43,174 +50,336 @@ func check(label: String, condition: bool, detail := "") -> void:
print(" FAIL ", label, " ", detail)
## 모든 구역을 열고 거품을 넉넉히 준 상태에서 시작한다.
## 모든 구역을 열고 거품을 넉넉히 준 빈 바다에서 시작한다.
func _rich_and_open() -> void:
GameState.reset()
GameState.reset(false)
GameState.unlocked_zones = Catalog.zone_count()
GameState.bubbles = 1e15
func _test_click() -> void:
print("클릭")
## 그 구역에 집을 한 채 세워 자리를 만든다. 세운 집의 인덱스를 돌려준다.
## 정원이 표준(HOUSE_CAPACITY)인 집을 고른다 -- 거품돌처럼 정원이 다른 집을
## 기준으로 삼으면 자리 계산 검사가 통째로 흔들린다.
func _house_in(zone_index: int, id := "") -> int:
if id.is_empty():
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)
func _test_starting_state() -> void:
print("시작 상태")
GameState.reset(false)
check("빈 바다로도 시작할 수 있다(테스트용)",
GameState.houses.is_empty() and GameState.fish_total == 0)
GameState.reset()
var gained := GameState.click()
check("클릭 한 번에 기본 1 거품", is_equal_approx(gained, 1.0), str(gained))
check("잔액과 누적이 함께 오른다",
is_equal_approx(GameState.bubbles, 1.0) and is_equal_approx(GameState.total_bubbles, 1.0))
check("시작하자마자 집이 서 있다", GameState.houses.size() > 0,
str(GameState.houses.size()))
check("시작하자마자 생물이 살고 있다", GameState.fish_total > 0,
str(GameState.fish_total))
check("시작하자마자 거품이 늘어난다", GameState.bps > 0.0, str(GameState.bps))
check("시작 거품을 쥐고 시작한다",
is_equal_approx(GameState.bubbles, Catalog.STARTER_BUBBLES), str(GameState.bubbles))
check("정원이 생물보다 넉넉하다", GameState.free_slots_in_zone(0) > 0,
"%d / %d" % [GameState.fish_total, GameState.capacity_total()])
check("시작 거품으로 한 마리를 더 들일 수 있다",
GameState.bubbles >= GameState.cost_of("producer", "guppy")
and GameState.can_take_fish("guppy"))
var s: Dictionary = Catalog.STARTER_HOUSES[0]
check("집이 정해 둔 칸에 선다",
GameState.house_at(int(s.col), int(s.row)) >= 0)
func _test_producer_cost_curve() -> void:
func _test_auto_income() -> void:
print("자동 수익")
_rich_and_open()
check("아무것도 없으면 초당 생산이 0", is_equal_approx(GameState.bps, 0.0),
str(GameState.bps))
_house_in(0)
check("집만으로는 거품이 나지 않는다", is_equal_approx(GameState.bps, 0.0),
str(GameState.bps))
GameState.buy_fish("hermit_crab")
check("생물이 들어와야 거품이 난다", GameState.bps > 0.0, str(GameState.bps))
var before_bps := GameState.bps
GameState.buy_fish("hermit_crab")
check("생물이 늘면 초당 생산도 는다", GameState.bps > before_bps,
"%f -> %f" % [before_bps, GameState.bps])
# 1초가 흐른 것으로 친다. 실제 게임에서도 _process가 이 계산을 한다.
var before := GameState.bubbles
var before_total := GameState.total_bubbles
var expected := GameState.bps
GameState._process(1.0)
check("1초에 초당 생산량만큼 늘어난다",
is_equal_approx(GameState.bubbles, before + expected),
"%f vs %f" % [GameState.bubbles, before + expected])
check("누적도 같이 오른다",
is_equal_approx(GameState.total_bubbles, before_total + expected))
check("클릭으로 버는 길은 없다", not GameState.has_method("click"))
func _test_cost_curves() -> void:
print("가격 곡선")
GameState.reset()
GameState.bubbles = 1000.0
var first := GameState.producer_cost("bubble_stone")
check("첫 거품돌은 15", is_equal_approx(first, 15.0), str(first))
GameState.place("producer", "bubble_stone", 0, 0)
var second := GameState.producer_cost("bubble_stone")
check("놓을수록 비싸진다 (x1.15)", second > first and second <= ceil(15.0 * 1.15) + 0.001,
"%f -> %f" % [first, second])
_rich_and_open()
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")))
_house_in(0)
var house_second := GameState.cost_of("house", "amphora")
check("집도 지을수록 비싸진다 (x1.15)",
is_equal_approx(house_second, ceil(90.0 * 1.15)), str(house_second))
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("서로 값을 간섭하지 않는다",
is_equal_approx(GameState.cost_of("house", "amphora"), house_second))
func _test_place() -> void:
print("배치")
GameState.reset()
GameState.bubbles = 1000.0
func _test_build_house() -> void:
print("집 짓기")
_rich_and_open()
var before := GameState.bubbles
var index := GameState.place("producer", "seaweed", 2, 3)
check("빈 칸에 놓인", index == 0, str(index))
check("가격만큼 빠진다", is_equal_approx(GameState.bubbles, before - 110.0), str(GameState.bubbles))
check("그 칸이 자기 것이 된다", GameState.placement_at(2, 3) == 0)
check("은 여전히 빈", GameState.placement_at(3, 3) == -1)
check("아름다움이 붙는다", is_equal_approx(GameState.beauty, 3.0), str(GameState.beauty))
var index := GameState.build_house("amphora", 2, 3)
check("빈 칸에 ", index == 0, str(index))
check("가격만큼 빠진다", is_equal_approx(GameState.bubbles, before - 90.0),
str(GameState.bubbles))
check("이 자기 것이 된", GameState.house_at(2, 3) == 0)
check("항아리는 2x1이라 옆 칸도 먹는다", GameState.house_at(3, 3) == 0)
check("그 옆은 여전히 빈다", GameState.house_at(4, 3) == -1)
check("아름다움이 붙는다", is_equal_approx(GameState.beauty, 12.0), str(GameState.beauty))
check("정원이 생긴다", GameState.capacity_in_zone(0) == Catalog.HOUSE_CAPACITY,
str(GameState.capacity_in_zone(0)))
check("같은 칸에 또 을 수 없다", GameState.place("producer", "seaweed", 2, 3) == -1)
check("격자 밖에는 놓을 수 없", GameState.place("producer", "seaweed", 2, 99) == -1)
check("같은 칸에 또 을 수 없다", GameState.build_house("amphora", 2, 3) == -1)
check("겹치는 칸에도 못 짓는", GameState.build_house("amphora", 3, 3) == -1)
check("격자 밖에는 못 짓는다", GameState.build_house("amphora", 2, 99) == -1)
GameState.bubbles = 5.0
check("잔액이 모자라면 못 는다", GameState.place("producer", "bubble_stone", 5, 0) == -1)
check("잔액이 모자라면 못 는다", GameState.build_house("amphora", 5, 0) == -1)
check("실패했으면 잔액 그대로", is_equal_approx(GameState.bubbles, 5.0))
func _test_footprint_and_multi_tile() -> void:
print("여러 칸짜리")
func _test_house_footprint() -> void:
print("여러 칸짜리")
_rich_and_open()
var span := Catalog.size_of("ornament", "shipwreck")
var span := Catalog.size_of("house", "shipwreck")
check("난파선은 3x2", span == Vector2i(3, 2), str(span))
# 산호초(1번 구역)의 두 번째 열에 난파선(3x2)을 놓으면 세 열 두 행을 덮는다.
var left := Catalog.ZONE_COLUMNS + 1
var index := GameState.place("ornament", "shipwreck", left, 0)
check("놓인", index >= 0, str(index))
var index := GameState.build_house("shipwreck", left, 0)
check("", index >= 0, str(index))
var covered := 0
for col in range(left, left + 3):
for row in 2:
if GameState.placement_at(col, row) == index:
if GameState.house_at(col, row) == index:
covered += 1
check("여섯 칸을 모두 차지한다", covered == 6, str(covered))
check("발자국 안쪽에는 겹쳐 놓을 수 없",
GameState.place("producer", "clownfish", left + 1, 1) == -1)
check("발자국 바로 옆에는 놓인",
GameState.place("producer", "clownfish", left + 3, 1) >= 0)
check("발자국 안쪽에는 겹쳐 못 짓는",
GameState.build_house("anchor", left + 1, 1) == -1)
check("발자국 바로 옆에는 ",
GameState.build_house("anchor", left + 3, 1) >= 0)
check("큰 집도 정원은 같다",
Catalog.capacity_of("shipwreck") == Catalog.capacity_of("anchor"))
func _test_zone_boundaries() -> void:
print("구역 경계")
_rich_and_open()
# 얕은 바다는 0번 구역. 해초는 그 구역 것이라 다음 구역 첫 열에는 못 간다.
var last := Catalog.ZONE_COLUMNS - 1
check("자기 구역 안에는 놓인", GameState.can_place_at("producer", "seaweed", last, 0))
check("남의 구역에는 못 는다",
not GameState.can_place_at("producer", "seaweed", last + 1, 0))
check("자기 구역 안에는 ", GameState.can_build_at("bottle_letter", last, 0))
check("남의 구역에는 못 는다",
not GameState.can_build_at("bottle_letter", last + 1, 0))
# 항아리(2x1)를 마지막 열에 놓으면 다음 구역까지 걸친다.
check("구역을 걸치면 못 는다",
not GameState.can_place_at("ornament", "amphora", last, 0))
check("한 구역 안에 들어가면 놓인다",
GameState.can_place_at("ornament", "amphora", last - 1, 0))
check("구역을 걸치면 못 는다", not GameState.can_build_at("amphora", last, 0))
check("한 구역 안에 들어가면 선다", GameState.can_build_at("amphora", last - 1, 0))
func _test_move() -> void:
print("옮기기")
func _test_capacity() -> void:
print("정원")
_rich_and_open()
var index := GameState.place("producer", "seaweed", 1, 1)
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("집을 지으면 정원이 생긴다",
GameState.free_slots_in_zone(0) == Catalog.HOUSE_CAPACITY,
str(GameState.free_slots_in_zone(0)))
for i in Catalog.HOUSE_CAPACITY:
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("guppy"))
check("종류를 바꿔도 마찬가지", not GameState.buy_fish("hermit_crab"))
_house_in(0)
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)))
# 구역마다 따로 센다. 얕은 바다의 빈 자리로 산호초 물고기를 들일 수는 없다.
check("다른 구역 정원은 따로다", GameState.capacity_in_zone(1) == 0)
check("자리가 없는 구역의 생물은 못 들인다", not GameState.can_take_fish("clownfish"))
_house_in(1)
check("그 구역에 집을 지어야 들인다", GameState.buy_fish("clownfish"))
func _test_move_house() -> void:
print("집 옮기기")
_rich_and_open()
var index := GameState.build_house("amphora", 1, 1)
var spent := GameState.bubbles
check("빈 칸으로 옮겨진다", GameState.move(index, 4, 2))
check("빈 칸으로 옮겨진다", GameState.move_house(index, 4, 2))
check("옮기는 건 공짜", is_equal_approx(GameState.bubbles, spent), str(GameState.bubbles))
check("옛 칸이 비었다", GameState.placement_at(1, 1) == -1)
check("새 칸이 자기 것", GameState.placement_at(4, 2) == index)
check("옛 칸이 비었다", GameState.house_at(1, 1) == -1)
check("새 칸이 자기 것", GameState.house_at(4, 2) == index)
GameState.place("producer", "seaweed", 0, 0)
check("남이 있는 칸으로는 못 옮긴다", not GameState.move(index, 0, 0))
check("실패했으면 제자리", GameState.placement_at(4, 2) == index)
check("제자리에 그대로 놓는 건 된다", GameState.move(index, 4, 2))
GameState.build_house("amphora", 0, 0)
check("남이 칸으로는 못 옮긴다", not GameState.move_house(index, 0, 0))
check("실패했으면 제자리", GameState.house_at(4, 2) == index)
check("제자리에 그대로 놓는 건 된다", GameState.move_house(index, 4, 2))
GameState.buy_fish("guppy")
check("살던 생물은 그대로 따라간다", GameState.move_house(index, 6, 4)
and GameState.fish_total == 1)
func _test_remove_refund() -> void:
print("치우기와 환불")
func _test_remove_house() -> void:
print("집 헐기")
_rich_and_open()
GameState.place("producer", "seaweed", 0, 0)
var index := GameState.place("producer", "seaweed", 1, 0)
GameState.build_house("amphora", 0, 0)
var second := GameState.build_house("amphora", 2, 0)
var before := GameState.bubbles
# 두 번째 해초 가격은 올림(110 * 1.15). 그 절반이 돌아온다.
var expected: float = floor(ceil(110.0 * 1.15) * Catalog.REFUND_RATE)
# 두 번째 항아리 값은 올림(90 * 1.15). 그 절반이 돌아온다.
var expected: float = floor(ceil(90.0 * 1.15) * Catalog.REFUND_RATE)
var refund := GameState.remove(index)
var refund := GameState.remove_house(second)
check("절반을 돌려받는다", is_equal_approx(refund, expected),
"%f vs %f" % [refund, expected])
check("잔액에 더해진다", is_equal_approx(GameState.bubbles, before + expected))
check("칸이 비었다", GameState.placement_at(1, 0) == -1)
check("수가 줄었다", GameState.count_of("seaweed") == 1)
check("칸이 비었다", GameState.house_at(2, 0) == -1)
check("수가 줄었다", GameState.house_count("amphora") == 1,
str(GameState.house_count("amphora")))
check("정원도 줄었다", GameState.capacity_in_zone(0) == Catalog.HOUSE_CAPACITY,
str(GameState.capacity_in_zone(0)))
var oi := GameState.place("ornament", "amphora", 2, 0)
check("장식은 하나만", GameState.place("ornament", "amphora", 4, 0) == -1)
GameState.remove(oi)
check("치우면 다시 살 수 있", GameState.place("ornament", "amphora", 4, 0) >= 0)
# 살고 있는 생물이 갈 곳이 없으면 헐 수 없다.
for i in Catalog.HOUSE_CAPACITY:
GameState.buy_fish("guppy")
check("세입자가 꽉 찬 마지막 집은 못 헌", not GameState.can_remove_house(0))
check("헐려고 해도 아무것도 돌아오지 않는다",
is_equal_approx(GameState.remove_house(0), 0.0))
check("집이 그대로 남는다", GameState.houses.size() == 1, str(GameState.houses.size()))
GameState.build_house("amphora", 4, 0)
check("자리가 남는 집이 생기면 헐 수 있다", GameState.can_remove_house(0))
check("실제로 헐린다", GameState.remove_house(0) > 0.0)
check("생물은 그대로 산다", GameState.fish_total == Catalog.HOUSE_CAPACITY,
str(GameState.fish_total))
func _test_release_fish() -> void:
print("생물 내보내기")
_rich_and_open()
_house_in(0)
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("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("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("hermit_crab"))
check("내보내려 해도 아무것도 돌아오지 않다",
is_equal_approx(GameState.release_fish("hermit_crab"), 0.0))
check("그대로 남는다", GameState.fish_total == 1, str(GameState.fish_total))
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("hermit_crab"))
func _test_space_is_the_limit() -> void:
print("공간이 곧 한계")
_rich_and_open()
var total := Catalog.GRID_ROWS * Catalog.ZONE_COLUMNS
check("구역 하나는 %d" % total, GameState.free_cells_in_zone(0) == total,
# 한 구역을 끝까지 채우려면 값이 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)))
var placed := 0
# 유리병 편지는 1x1이라 한 구역을 가장 촘촘하게 채운다.
var built := 0
while true:
var spot := GameState.first_free_spot("producer", "seaweed")
if spot.x < 0:
var spot := GameState.first_free_spot("bottle_letter")
if spot.x < 0 or GameState.build_house("bottle_letter", spot.x, spot.y) < 0:
break
if GameState.place("producer", "seaweed", spot.x, spot.y) < 0:
break
placed += 1
built += 1
check("칸이 차면 더 못 는다", placed == total, str(placed))
check("칸이 차면 더 못 는다", built == cells, str(built))
check("남은 칸 0", GameState.free_cells_in_zone(0) == 0)
check("거품이 남아도 못 는다", GameState.place("producer", "seaweed", 0, 0) == -1)
check("거품이 남아도 못 는다", GameState.build_house("bottle_letter", 0, 0) == -1)
check("한 구역의 정원 상한은 칸 수 x %d" % Catalog.HOUSE_CAPACITY,
GameState.capacity_in_zone(0) == cells * Catalog.HOUSE_CAPACITY,
str(GameState.capacity_in_zone(0)))
check("다른 구역은 아직 비어 있다",
GameState.free_cells_in_zone(1) == total, str(GameState.free_cells_in_zone(1)))
GameState.free_cells_in_zone(1) == cells, str(GameState.free_cells_in_zone(1)))
func _test_zone_gating() -> void:
print("구역 잠금")
GameState.reset()
GameState.reset(false)
GameState.bubbles = 1e15
check("시작은 1구역만 열림", GameState.unlocked_zones == 1)
check("0구역 물건은 보인다", GameState.is_revealed("producer", "bubble_stone"))
check("1구역 물건은 보인다", not GameState.is_revealed("producer", "clownfish"))
check("돈이 넘쳐도 잠긴 구역에는 못 놓는다",
GameState.place("producer", "clownfish", Catalog.ZONE_COLUMNS, 0) == -1)
# 각 구역의 첫 항목은 값을 벌기 전에도 보여야 한다. 안 그러면 상점이 비어 시작한다.
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("돈이 넘쳐도 잠긴 구역에는 못 짓는다",
GameState.build_house("anchor", Catalog.ZONE_COLUMNS, 0) == -1)
check("실패했으면 잔액 그대로", is_equal_approx(GameState.bubbles, 1e15))
GameState.unlocked_zones = 2
check("열린 뒤에는 보인다", GameState.is_revealed("producer", "clownfish"))
check("열린 뒤에는 놓인",
GameState.place("producer", "clownfish", Catalog.ZONE_COLUMNS, 0) >= 0)
check("열린 뒤에는 보인다", GameState.is_revealed("house", "coral"))
check("열린 뒤에는 지어진",
GameState.build_house("anchor", Catalog.ZONE_COLUMNS, 0) >= 0)
func _test_zone_unlock() -> void:
print("구역 해금")
GameState.reset()
GameState.reset(false)
var need_beauty := GameState.next_zone_required_beauty()
var cost := GameState.next_zone_cost()
check("2구역 해금 조건이 정의되어 있다", need_beauty > 0.0 and cost > 0.0,
@@ -221,10 +390,13 @@ func _test_zone_unlock() -> void:
check("사유가 아름다움 부족이라고 나온다",
GameState.next_zone_blocker().contains("꾸며야"), GameState.next_zone_blocker())
# 앞 구역(얕은 바다)을 해초로 채 아름다움을 올린다. 한 포기당 3.
# 집을 짓고 그 정원을 해초로 채우면 아름다움이 오른다.
while GameState.beauty_in_zone(0) < need_beauty:
var spot := GameState.first_free_spot("producer", "seaweed")
if spot.x < 0 or GameState.place("producer", "seaweed", spot.x, spot.y) < 0:
if GameState.free_slots_in_zone(0) <= 0:
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("hermit_crab"):
break
check("칸 안에서 조건을 채울 수 있다", GameState.beauty_in_zone(0) >= need_beauty,
"%f / %f" % [GameState.beauty_in_zone(0), need_beauty])
@@ -239,70 +411,84 @@ func _test_zone_unlock() -> void:
func _test_zone_beauty_is_per_zone() -> void:
print("구역별 아름다움")
_rich_and_open()
GameState.place("producer", "seaweed", 0, 0) # 0구역, 아름다움 3
GameState.place("producer", "clownfish", Catalog.ZONE_COLUMNS, 0) # 1구역, 아름다움 8
_house_in(0) # 항아리, 아름다움 12
GameState.buy_fish("hermit_crab") # 0구역, 아름다움 4
_house_in(1) # 낡은 닻, 아름다움 40
GameState.buy_fish("clownfish") # 1구역, 아름다움 8
check("0구역에 3", is_equal_approx(GameState.beauty_in_zone(0), 3.0),
check("0구역에 16", is_equal_approx(GameState.beauty_in_zone(0), 16.0),
str(GameState.beauty_in_zone(0)))
check("1구역에 8", is_equal_approx(GameState.beauty_in_zone(1), 8.0),
check("1구역에 48", is_equal_approx(GameState.beauty_in_zone(1), 48.0),
str(GameState.beauty_in_zone(1)))
check("총합은 11", is_equal_approx(GameState.beauty, 11.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:
print("저장 직렬화")
_rich_and_open()
GameState.place("producer", "seaweed", 1, 1)
GameState.place("producer", "seaweed", 2, 1)
GameState.place("ornament", "shipwreck", Catalog.ZONE_COLUMNS + 1, 2)
GameState.upgrades["u_touch1"] = true
GameState.clicks = 42
# 배치가 끝난 뒤에 잔액을 맞춘다. 배치는 값을 치르기 때문에 먼저 정하면 어긋난다.
GameState.build_house("amphora", 1, 1)
GameState.build_house("shipwreck", Catalog.ZONE_COLUMNS + 1, 2)
GameState.buy_fish("hermit_crab")
GameState.buy_fish("hermit_crab")
GameState.buy_fish("clownfish")
GameState.upgrades["u_ripple"] = true
# 배치가 끝난 뒤에 잔액을 맞춘다. 짓고 들이는 데 값이 들기 때문이다.
GameState.bubbles = 12345.0
GameState.recalc()
var bps_before := GameState.bps
# 실제 저장 경로와 똑같이 JSON 문자열을 거쳐서 되돌린다.
var text := JSON.stringify(GameState.to_dict())
GameState.reset()
GameState.reset(false)
GameState.from_dict(JSON.parse_string(text))
check("보유량 복원", is_equal_approx(GameState.bubbles, 12345.0), str(GameState.bubbles))
check("배치 개수 복원", GameState.placements.size() == 3, str(GameState.placements.size()))
check("수 복원", GameState.houses.size() == 2, str(GameState.houses.size()))
var wreck := Catalog.ZONE_COLUMNS + 1
check("배치된 칸까지 그대로",
GameState.placement_at(2, 1) >= 0 and GameState.placement_at(wreck, 2) >= 0)
check("집이 선 칸까지 그대로",
GameState.house_at(1, 1) >= 0 and GameState.house_at(wreck, 2) >= 0)
check("난파선 발자국도 복원",
GameState.placement_at(wreck + 2, 3) == GameState.placement_at(wreck, 2))
check("업그레이드 복원", GameState.has_upgrade("u_touch1"))
check("클릭 수 복원", GameState.clicks == 42)
GameState.house_at(wreck + 2, 3) == GameState.house_at(wreck, 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)))
check("업그레이드 복원", GameState.has_upgrade("u_ripple"))
check("열린 구역 수 복원", GameState.unlocked_zones == Catalog.zone_count(),
str(GameState.unlocked_zones))
check("초당 생산량이 그대로", is_equal_approx(GameState.bps, bps_before),
@@ -311,26 +497,55 @@ func _test_serialization_round_trip() -> void:
func _test_legacy_save_migration() -> void:
print("옛 저장본 이전")
GameState.reset()
# 격자가 없던 시절의 저장본. 개수만 있고 위치가 없다.
# 생물도 칸을 차지하던 시절. 장식이 지금의 집이 되고, 생물은 마릿수만 남는다.
# 돌·해초처럼 그때는 생물이었다가 지금은 집이 된 것은 그만큼 집으로 지어 준다.
GameState.reset(false)
GameState.from_dict({
"bubbles": 500.0,
"unlocked_zones": 2,
"producers": {"seaweed": 4, "clownfish": 2},
"placements": [
{"kind": "producer", "id": "seaweed", "col": 0, "row": 0},
{"kind": "producer", "id": "seaweed", "col": 1, "row": 0},
{"kind": "ornament", "id": "amphora", "col": 3, "row": 0},
{"kind": "producer", "id": "clownfish", "col": Catalog.ZONE_COLUMNS, "row": 0},
{"kind": "ornament", "id": "anchor", "col": Catalog.ZONE_COLUMNS + 2, "row": 0},
],
})
# 항아리 + 닻 + 해초 두 포기 = 네 채.
check("장식이 집이 된다", GameState.houses.size() == 4, str(GameState.houses.size()))
check("집이 선 칸은 그대로", GameState.house_at(3, 0) >= 0)
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, "guppy": 4},
"ornaments": {"amphora": true},
})
check("개수만큼 칸에 놓인다", GameState.placements.size() == 7,
str(GameState.placements.size()))
check("해초 4포기", GameState.count_of("seaweed") == 4, str(GameState.count_of("seaweed")))
check("장식도 옮겨온다", GameState.has_ornament("amphora"))
var all_in_zone := true
for p in GameState.placements:
if Catalog.zone_of_column(p.col) != Catalog.zone_of(p.kind, p.id):
all_in_zone = false
check("각자 제 구역에 놓인다", all_in_zone)
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)))
# 집도 생물도 없는 저장본은 시작 상태를 채워준다.
GameState.from_dict({"bubbles": 1.0})
check("구역 개념이 없던 저장본은 1구역으로", GameState.unlocked_zones == 1)
check("빈 저장본에는 집을 세워준다", GameState.houses.size() > 0)
check("빈 저장본에는 생물도 넣어준다", GameState.fish_total > 0)
func _test_number_format() -> void:
@@ -355,3 +570,193 @@ func _test_korean_particles() -> void:
if not Korean.eul(String(z.name)).begins_with(String(z.name)):
ok = false
check("구역 이름 전부 처리된다", ok)
func _test_shape_families() -> void:
print("형태 계통")
# 집과 생물이 같은 도형을 쓰면 화면에서 "건물"과 "사는 것"이 구분되지 않는다.
# 그래서 두 계통을 아예 겹치지 않게 갈라 두고, 그것을 여기서 지킨다.
# 사는 것은 헤엄치거나 걷는 해양동물, 사는 곳은 자연 서식지와 지어 올린 것.
const CREATURE_SHAPES := ["fish", "glow", "crab", "seahorse", "shrimp"]
const HOUSE_SHAPES := ["rock", "plant", "vessel", "wreck", "pillar"]
var bad_creature := ""
for d in Catalog.PRODUCERS:
if not String(d.shape) in CREATURE_SHAPES:
bad_creature = "%s: %s" % [d.name, d.shape]
check("생물은 생물 형태만 쓴다", bad_creature.is_empty(), bad_creature)
var bad_house := ""
for d in Catalog.HOUSES:
if not String(d.shape) in HOUSE_SHAPES:
bad_house = "%s: %s" % [d.name, d.shape]
check("집은 집 형태만 쓴다", bad_house.is_empty(), bad_house)
var overlap := false
for s in HOUSE_SHAPES:
if s in CREATURE_SHAPES:
overlap = true
check("두 계통이 겹치지 않는다", not overlap)
# 헤엄치는 것이 하나도 없는 구역이 있으면 "집을 도는" 움직임을 볼 수 없다.
var swimmerless := ""
for z in Catalog.zone_count():
var has_swimmer := false
for d in Catalog.PRODUCERS:
if int(d.get("zone", 0)) == z and String(d.shape) in ["fish", "glow"]:
has_swimmer = true
if not has_swimmer:
swimmerless = String(Catalog.zone(z).name)
check("구역마다 헤엄치는 생물이 있다", swimmerless.is_empty(), swimmerless)
var starter_swims := false
for id in Catalog.STARTER_FISH:
if String(Catalog.producer(String(id)).shape) in ["fish", "glow"]:
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("업그레이드")
var all_bps := true
for u in Catalog.UPGRADES:
if String(u.kind) != "bps_mult":
all_bps = false
check("업그레이드는 전부 생산 배율", all_bps)
_rich_and_open()
_house_in(0)
GameState.buy_fish("hermit_crab")
var before := GameState.bps
var id := String(Catalog.UPGRADES[0].id)
check("살 수 있다", GameState.buy_upgrade(id))
check("초당 생산량이 오른다", GameState.bps > before,
"%f -> %f" % [before, GameState.bps])
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