Compare commits
10 Commits
446d8d177c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 868ce08f90 | |||
| c9b9145615 | |||
| 7c52c34874 | |||
| 9eaaabd3b3 | |||
| 375b340c47 | |||
| 1ca381922f | |||
| 711cd453ff | |||
| 519e4980de | |||
| 89069b06f0 | |||
| b490e463b0 |
@@ -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`)
|
||||
|
||||
@@ -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
@@ -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])
|
||||
|
||||
@@ -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]
|
||||
@@ -0,0 +1 @@
|
||||
uid://bw8bwvut3qhox
|
||||
+342
-139
@@ -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
@@ -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
@@ -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`을 부르면 안 된다.
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://be6jcjj4707om
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -26,3 +26,5 @@ visible = false
|
||||
layout_mode = 1
|
||||
|
||||
[node name="ContextMenu" type="PopupMenu" parent="."]
|
||||
|
||||
[node name="TimeMenu" type="PopupMenu" parent="ContextMenu"]
|
||||
|
||||
+37
-18
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bdayflw4cn1co
|
||||
+40
-26
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user