From 519e4980de4b2460d26e16cc3132236ff713c60e Mon Sep 17 00:00:00 2001 From: ttipo Date: Wed, 2 Sep 2026 12:41:36 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A8=BC=20=EB=B0=94=EB=8B=A4=20=EB=B0=B0?= =?UTF-8?q?=EA=B2=BD=C2=B7=ED=95=98=EB=A3=A8=EC=9D=98=20=EB=B9=9B=C2=B7?= =?UTF-8?q?=EC=B8=B5=20=EA=B5=AC=EC=A1=B0=C2=B7=EA=B0=90=EC=83=81=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 네 가지 작업이 같은 함수들을 겹쳐 고쳐서 한 커밋으로 묶는다. 쪼개려면 hunk 단위로 갈라야 하는데 서로 얽혀 있어 오히려 위험하다. 먼 바다 배경 (backdrop.gd) - 3/4 부감에서 화면 위쪽은 더 먼 곳이다. 거기까지 모래를 깔면 바다가 아니라 사막처럼 보여서, 위쪽 띠를 물로 비웠다. - 먼 물 그라디언트 → 능선 → 먼 실루엣 순으로 깊이를 만든다. - Seabed가 뒷줄일수록 색을 물빛 쪽으로 당겨 공기원근을 준다. - 빛줄기를 알파를 더하는 방향으로 바꿨다. 걷어내는 방향이면 바닥만 밝아지고 물기둥은 텅 빈 채로 남는다. 하루의 빛 (day_cycle.gd) - 실제 시각에 맞춰 새벽/아침/낮/저녁/밤. 이름은 구간으로 끊고 색과 밝기는 계속 보간한다. - 밤에는 물결과 빛줄기가 잦아들고, 스스로 빛나는 것은 어둠을 되밀어 오히려 도드라진다. - godot --path . -- --hour=21 로 특정 시각을 눈으로 확인할 수 있다. 한 화면에 한 층 - 가로 연속 스크롤을 버리고 층 전환으로 바꿨다. 위층(얕은 바다)에서 아래층(열수구)으로 내려간다. 층 선택기·휠·↑↓ 키. - 층은 내부적으로 여전히 옆으로 늘어서 있고 화면이 한 구간만 비춘다. 갈아탈 때만 세로로 미끄러뜨려 깊이가 위아래로 읽히게 했다. - 칸 크기를 가로에서 정하도록 뒤집고 ZONE_COLUMNS를 36으로 올렸다. 층당 칸이 96 -> 216이라 정원과 해금 곡선은 다시 봐야 한다. - 한 화면에 한 층만 보이므로 층 색은 이웃과 섞지 않는다. 감상 모드 (F11) - 창을 화면 전체로. 세로로 남는 공간은 격자가 아니라 물기둥에 준다. 격자를 키우면 타일만 커지고 보이는 열은 오히려 줄어든다. - 창을 화면과 픽셀까지 똑같이 맞추면 Windows가 독점 전체화면으로 승격시켜 size 지정이 먹지 않는다. usable_rect까지만 쓴다. 그 밖에 - 저장 window_state: scroll_x -> layer - 알림이 정보 바와 겹치던 것을 바 아래로 옮겼다 - 난파선 선체 폴리곤이 제 몸을 가로질러 삼각분할이 실패하던 것 수정 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 10 +- assets/shaders/water.gdshader | 23 +++- autoload/catalog.gd | 31 ++--- autoload/day_cycle.gd | 117 ++++++++++++++++ autoload/day_cycle.gd.uid | 1 + docs/DESIGN.md | 141 +++++++++++++++++++- project.godot | 1 + scenes/aquarium/aquarium.gd | 228 ++++++++++++++++++++++++++------ scenes/aquarium/aquarium.tscn | 52 ++------ scenes/aquarium/backdrop.gd | 133 +++++++++++++++++++ scenes/aquarium/backdrop.gd.uid | 1 + scenes/aquarium/seabed.gd | 24 ++-- scenes/aquarium/tile_item.gd | 36 +++-- scenes/main.gd | 76 +++++++++-- scenes/ui/hud.gd | 26 +++- scenes/ui/hud.tscn | 34 +++-- scenes/ui/layer_bar.gd | 69 ++++++++++ scenes/ui/layer_bar.gd.uid | 1 + tests/smoke.gd | 108 ++++++++++++--- 19 files changed, 942 insertions(+), 170 deletions(-) create mode 100644 autoload/day_cycle.gd create mode 100644 autoload/day_cycle.gd.uid create mode 100644 scenes/aquarium/backdrop.gd create mode 100644 scenes/aquarium/backdrop.gd.uid create mode 100644 scenes/ui/layer_bar.gd create mode 100644 scenes/ui/layer_bar.gd.uid diff --git a/README.md b/README.md index 70f06fa..fec3fd2 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ | 동작 | 방법 | | --- | --- | | 물 만지기 | 빈 곳 좌클릭 (거품이 퍼진다 · 거품은 생물이 번다) | +| 층 오가기 | 왼쪽 아래 층 선택기 · 마우스 휠 · ↑↓ 키 | +| 감상 모드 | **F11** 또는 메뉴 · Esc나 F11로 돌아온다 | | 집 짓기 | 상점 **집** 탭에서 고른 뒤 칸을 클릭 (우클릭·Esc로 취소) | | 생물 들이기 | 상점 **생물** 탭에서 클릭 — 그 자리에서 바로 들어온다 (빈 자리 필요) | | 집 옮기기 | 집을 끌어다 다른 칸에 놓기 (공짜 · 살던 생물도 따라간다) | @@ -41,11 +43,12 @@ project.godot 창 설정(투명·테두리 없음·항상 위·1:1 픽 run.ps1 / test.ps1 게임 실행 / 스모크 테스트 (find-godot.ps1이 Godot을 찾아준다) autoload/ catalog.gd 밸런스 데이터 전부. 아이템 추가는 여기서만. + day_cycle.gd 실제 시각에 맞춘 하루의 빛 (새벽/아침/낮/저녁/밤) game_state.gd 보유 자원·배치 상태와 파생 수치(bps, 아름다움, 아름다움 배율) save_manager.gd 저장/불러오기, 30초 자동 저장, 오프라인 보상, 창 위치 scenes/ main.tscn/.gd 도킹(폭 전체·가장자리 스냅)·클릭 통과·메뉴와 화면 조립 - aquarium/ 물빛 셰이더, 모래 바닥, 물속 오브젝트 + aquarium/ 먼 바다 배경, 격자 바닥, 물빛 셰이더, 물속 오브젝트 ui/ 왼쪽 위 정보 바, 오른쪽 상점 패널 scripts/ korean.gd 받침을 보고 을/를·이/가를 고른다 @@ -68,7 +71,7 @@ tests/smoke.tscn/.gd 경제 로직 스모크 테스트 ## 격자와 배치 -바다는 **3/4 부감 격자**다. 구역당 9열 × 6행, 전체 45열 × 6행 = 270칸. +바다는 **3/4 부감 격자**다. **한 화면에 한 층**만 담기고, 층마다 36열 × 6행 = 216칸이다. 바닥은 위에서 내려다보지만 그 위에 선 것은 **서 있는 모습**으로 그린다. 러스티의 농장과 같은 방식이고, 완전한 탑다운과는 다르다. @@ -108,7 +111,8 @@ tests/smoke.tscn/.gd 경제 로직 스모크 테스트 ## 구역 -가로가 다섯 구역으로 나뉘어 있고, **왼쪽에서 오른쪽으로 갈수록 깊어진다.** +다섯 층이 있고, **위층에서 아래층으로 내려갈수록 깊어진다.** +왼쪽 아래 층 선택기, 마우스 휠, ↑↓ 키로 오간다. 처음에는 맨 왼쪽 얕은 바다만 열려 있고 나머지는 어둡게 덮여 있다. 잠긴 구역을 클릭하면 해금 조건과 남은 양이 그 자리에 뜬다. diff --git a/assets/shaders/water.gdshader b/assets/shaders/water.gdshader index ce5aa3d..6d70b6b 100644 --- a/assets/shaders/water.gdshader +++ b/assets/shaders/water.gdshader @@ -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); } diff --git a/autoload/catalog.gd b/autoload/catalog.gd index 714cda1..dd99baa 100644 --- a/autoload/catalog.gd +++ b/autoload/catalog.gd @@ -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: 바로 앞 구역에 쌓인 아름다움이 이만큼은 되어야 한다. ## "앞 구역을 충분히 꾸며야 다음이 열린다"는 규칙. @@ -328,25 +329,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]) diff --git a/autoload/day_cycle.gd b/autoload/day_cycle.gd new file mode 100644 index 0000000..f34e18e --- /dev/null +++ b/autoload/day_cycle.gd @@ -0,0 +1,117 @@ +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 + +## 0 이상이면 그 시각으로 고정한다. 테스트와 눈으로 확인할 때 쓴다. +var forced_hour := -1.0: + set(value): + forced_hour = value + refresh() + +var tint := Color.WHITE +var light := 1.0 +var phase := "" + +var _timer := 0.0 + + +func _ready() -> void: + # 개발 중 특정 시각을 눈으로 확인할 때: godot --path . -- --hour=21 + for arg in OS.get_cmdline_user_args(): + if arg.begins_with("--hour="): + forced_hour = float(arg.trim_prefix("--hour=")) + refresh() + + +func _process(delta: float) -> void: + _timer += delta + if _timer < REFRESH_INTERVAL: + return + _timer = 0.0 + refresh() + + +## 지금 몇 시인지. 소수점까지 있는 시각(13.5 = 오후 1시 30분). +func hour() -> float: + if forced_hour >= 0.0: + return fposmod(forced_hour, 24.0) + var t := Time.get_time_dict_from_system() + return float(t.hour) + float(t.minute) / 60.0 + float(t.second) / 3600.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] diff --git a/autoload/day_cycle.gd.uid b/autoload/day_cycle.gd.uid new file mode 100644 index 0000000..2db90ee --- /dev/null +++ b/autoload/day_cycle.gd.uid @@ -0,0 +1 @@ +uid://bw8bwvut3qhox diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 4fa1a3e..0a51243 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -119,7 +119,8 @@ | 구역 수 | 5 (구역당 생물 2 + 집 2) | `Catalog.ZONES` | | 구역 해금 비용 | 30.0K / 3.00M / 400M / 40.0B | `Catalog.ZONES[i].unlock_cost` | | 구역 해금 아름다움 | 앞 구역에 90 / 500 / 2.20K / 8.00K | `Catalog.ZONES[i].require_beauty` | -| 격자 | 구역당 9열 × 6행 (총 45×6 = 270칸) | `Catalog.ZONE_COLUMNS` / `GRID_ROWS` | +| 격자 | 구역당 16열 × 6행 (총 80×6 = 480칸) | `Catalog.ZONE_COLUMNS` / `GRID_ROWS` | +| 칸 가로:세로 | 1.15 | `Aquarium.TILE_ASPECT` | | 물체 높이 | 칸 높이의 0.75~1.95배 | 각 항목의 `height` | | 집 값 | 그 구역 싼 생물의 약 6배 | 각 집의 `base_cost` | | 헐거나 내보낼 때 환불 | 50% | `Catalog.REFUND_RATE` | @@ -198,6 +199,26 @@ 바닥은 `seabed.gd`가 칸마다 그리고, 그 위에 `water.gdshader`가 물빛과 일렁이는 빛 그물을 덮는다. 깊이감은 이 오버레이의 `murk`가 만든다. +## 먼 바다 배경 + +3/4 부감에서 화면 위쪽은 "더 먼 곳"이다. 거기까지 모래를 깔면 바다가 아니라 +사막처럼 보였다. 그래서 격자 위쪽 띠를 `backdrop.gd`가 물로 채운다. + +- **먼 물** — 위로 갈수록 어두워지는 세로 그라디언트. 열마다 사각형을 그리고 + 정점 색을 먹여 가로(구역)·세로(거리) 양쪽으로 동시에 번지게 한다. +- **능선** — 격자 바로 위에 물결치는 먼 바닥. 이 선이 수평선 노릇을 한다. +- **먼 실루엣** — 능선 위의 낮고 넓은 덩어리들. 무엇인지 알아볼 필요는 없고 + "저 너머에도 뭔가 있다"는 신호만 주면 된다. 가늘고 높게 그리면 비석처럼 + 서 버려서 먼 풍경으로 안 읽힌다. +- **공기원근** — `seabed.gd`가 뒷줄(위쪽) 칸일수록 색을 물빛 쪽으로 22%까지 + 당겨서 멀어 보이게 한다. +- **빛줄기** — `water.gdshader`가 비스듬히 내려오는 빛을 얹는다. 세기는 + `murk`의 반대(`_ray_strength()`)라 얕은 바다에서만 뚜렷하고 심해에서는 사라진다. + +띠의 높이는 HUD가 `reserved_top()`으로 알려주는 값이다. 정보 바가 그 안에 앉고, +`BAR_CLEARANCE`만큼 남는 자리에 배경이 드러난다. 바를 키우면 배경이 가려지고 +격자가 좁아지므로, 이 셋은 같이 조정해야 한다. + ## 창 동작 (Windows) - 투명·테두리 없음: `project.godot`의 `per_pixel_transparency/allowed` + `size/transparent` @@ -232,3 +253,121 @@ `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`만큼 색을 끌어올리고 후광을 넓혀, +어두울수록 오히려 도드라지게 했다. 심해 해파리가 밤에 살아나는 것이 이 때문이다. + +**눈으로 확인하기**: `godot --path . -- --hour=21` 처럼 시각을 고정할 수 있다. +테스트도 `DayCycle.forced_hour`로 같은 문을 쓴다. + +## 감상 모드 (전체화면) + +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"]`). diff --git a/project.godot b/project.godot index 01b239c..400b4a8 100644 --- a/project.godot +++ b/project.godot @@ -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" diff --git a/scenes/aquarium/aquarium.gd b/scenes/aquarium/aquarium.gd index 6878a35..217a619 100644 --- a/scenes/aquarium/aquarium.gd +++ b/scenes/aquarium/aquarium.gd @@ -1,7 +1,11 @@ extends Control class_name Aquarium -## 화면 하단에 깔리는 탑다운 바다. -## 가로를 구역 수만큼 나눠, 왼쪽(얕은 바다)에서 오른쪽(열수구)으로 갈수록 깊어진다. +## 화면 하단에 깔리는 3/4 부감 바다. +## 한 화면에 한 층만 담는다. 위층(얕은 바다)에서 아래층(열수구)으로 내려갈수록 깊어지고, +## 층은 왼쪽 아래 선택기나 휠·위아래 키로 갈아탄다. +## +## 층은 내부적으로 옆으로 늘어서 있고(층 n은 열 n*ZONE_COLUMNS부터), 화면은 그 중 +## 한 구간만 비춘다. 갈아탈 때만 위아래로 미끄러뜨려 깊이가 세로로 읽히게 한다. ## ## 격자 칸을 차지하는 것은 집뿐이다. 생물은 칸이 아니라 집에 살기 때문에 ## 그릴 때마다 구역별로 집에 차례로 배정해서 그 집 둘레에 세운다. @@ -16,18 +20,21 @@ signal build_mode_changed(active: bool, id: String) signal drag_changed(dragging: bool) ## 물 위에서 우클릭. main이 메뉴를 연다. signal context_menu_requested +## 보고 있는 층이 바뀌었을 때. 층 선택기가 표시를 맞춘다. +signal layer_changed(index: int) ## 드래그한 것을 여기에 떨구면 치운다. main이 HUD의 휴지통 위치를 넣어준다. var trash_rect := Rect2() -@onready var ground: Seabed = $Ground -@onready var items: Node2D = $Items -@onready var water: Control = $Water -@onready var bubbles: CPUParticles2D = $Bubbles -@onready var ghost: GridGhost = $Ghost -@onready var dividers: Control = $Dividers -@onready var zone_labels: Control = $ZoneLabels -@onready var locks: Control = $Locks +@onready var world: Node2D = $World +@onready var backdrop: Backdrop = $World/Backdrop +@onready var ground: Seabed = $World/Ground +@onready var items: Node2D = $World/Items +@onready var water: Node2D = $World/Water +@onready var bubbles: CPUParticles2D = $World/Bubbles +@onready var ghost: GridGhost = $World/Ghost +@onready var zone_labels: Node2D = $World/ZoneLabels +@onready var locks: Node2D = $World/Locks ## 위쪽 이만큼은 정보 바가 앉는 자리라 격자에서 뺀다. ## 여기를 비워두지 않으면 첫 줄 칸이 바에 가려 보이지도, 눌리지도 않는다. @@ -40,6 +47,13 @@ var grid_top := 30.0: if is_node_ready(): _on_layout_changed() +## 칸의 가로:세로 비. 1보다 크면 살짝 옆으로 퍼져 부감처럼 보인다. +const TILE_ASPECT := 1.15 +## 층을 갈아탈 때 미끄러지는 시간. +const LAYER_SLIDE := 0.32 +## 감상 모드에서 격자가 차지하는 세로 비율. 나머지는 물기둥이 된다. +## 높이를 전부 격자에 주면 칸만 거대해지고 보이는 열은 오히려 줄어든다. +const SHOWCASE_GRID_RATIO := 0.45 const DRAG_THRESHOLD := 5.0 ## 물고기가 집을 한 바퀴 도는 속도(라디안/초). 위젯이라 눈에 거슬리지 않게 느리다. const ORBIT_SPEED := 0.5 @@ -58,6 +72,34 @@ var _drag_ref := {} var _drag_node: TileItem = null ## 이번 누름이 클릭이 아니라 드래그였다는 표식. var _press_was_drag := false +var _pressing := false +var _slide: Tween +## 낮빛을 입힐 대상들. 배치 미리보기(Ghost)는 빼둔다 -- 밤에도 또렷해야 한다. +var _daylit: Array[CanvasItem] = [] +## 마지막으로 셰이더에 넣은 빛의 양. 조금이라도 달라져야 다시 넣는다. +var _applied_sun := -1.0 + +## 감상 모드. 바다를 아래에 깔고 그 위를 물기둥으로 채운다. +var showcase := false: + set(value): + if showcase == value: + return + showcase = value + if is_node_ready(): + _on_layout_changed() + +## 지금 보고 있는 층. 0이 맨 위(얕은 바다), 커질수록 깊다. +## 한 화면에 한 층만 담기므로 화면 위치는 이 값 하나로 정해진다. +var current_zone := 0: + set(value): + var next := clampi(value, 0, Catalog.zone_count() - 1) + if next == current_zone and is_node_ready(): + return + var going_down := next > current_zone + current_zone = next + if is_node_ready(): + _slide_to_layer(going_down) + layer_changed.emit(current_zone) func _ready() -> void: @@ -67,6 +109,9 @@ func _ready() -> void: ghost.hide() _setup_bubbles() + _daylit = [backdrop, ground, items, water, bubbles, zone_labels, locks] + _apply_daylight() + resized.connect(_on_layout_changed) GameState.zone_unlocked.connect(_on_zone_unlocked) GameState.reloaded.connect(_rebuild_items) @@ -76,30 +121,100 @@ func _ready() -> void: func _process(_delta: float) -> void: + _apply_daylight() + _follow_view() + # 잠긴 구역의 안내 문구는 거품이 쌓이면서 계속 바뀐다. for index in _lock_reasons: if index == GameState.next_zone_index(): _lock_reasons[index].text = GameState.next_zone_blocker() +## 부유물 뿌리개를 지금 보이는 곳으로 옮긴다. 스크롤해도 밀도가 유지된다. +func _follow_view() -> void: + bubbles.position = Vector2(layer_offset() + size.x * 0.5, size.y * 0.5) + bubbles.emission_rect_extents = Vector2(size.x, size.y) * 0.55 + + +## 하루의 빛을 바다에 입힌다. +## 곱하기라 밤에는 전체가 어두워지고 저녁에는 주황빛이 돈다. +func _apply_daylight() -> void: + for node in _daylit: + if is_instance_valid(node): + node.modulate = DayCycle.tint + + # 물결과 빛줄기는 수면에서 오는 빛이므로 밤에는 잦아들어야 한다. + if absf(DayCycle.light - _applied_sun) < 0.01: + return + _applied_sun = DayCycle.light + for child in water.get_children(): + var rect := child as ColorRect + if rect and rect.material is ShaderMaterial: + (rect.material as ShaderMaterial).set_shader_parameter("sun", _applied_sun) + + # --- 격자 좌표 --- -## 격자가 실제로 깔리는 영역. 위쪽 정보 바 자리는 뺀다. -func grid_rect() -> Rect2: - # 스트립이 낮을 때 바가 격자를 다 먹어버리지 않도록 위쪽 절반은 넘기지 않는다. - var top := clampf(grid_top, 0.0, size.y * 0.45) - return Rect2(Vector2(0.0, top), Vector2(size.x, maxf(size.y - top, 1.0))) - - +## 칸 하나의 픽셀 크기. +## 한 층이 화면 가로를 꽉 채우므로 가로는 화면 너비를 열 수로 나눈 값이고, +## 세로는 거기에 비를 맞춘다. 그렇게 잡은 격자가 남은 높이보다 크면 눌러 넣는다. func cell_size() -> Vector2: - var g := grid_rect() - return Vector2(g.size.x / GameState.columns(), g.size.y / GameState.rows()) + var w := size.x / maxf(float(Catalog.ZONE_COLUMNS), 1.0) + var h := w / TILE_ASPECT + var room := _band_height() + if h * GameState.rows() > room: + h = room / GameState.rows() + return Vector2(w, h) -func cell_at(pos: Vector2) -> Vector2i: +## 격자가 쓸 수 있는 세로 폭. +func _band_height() -> float: + if showcase: + return maxf(size.y * SHOWCASE_GRID_RATIO, 1.0) + # 스트립이 낮을 때 바가 격자를 다 먹어버리지 않도록 위쪽 절반은 넘기지 않는다. + return maxf(size.y - clampf(grid_top, 0.0, size.y * 0.45), 1.0) + + +## 격자가 실제로 깔리는 영역. 언제나 화면 아래쪽에 붙는다. +## 남는 위쪽은 Backdrop이 물기둥으로 채운다 -- 세로가 넉넉할 때 격자를 키우는 것보다 +## 물을 채우는 편이 바다로 읽힌다. +func grid_rect() -> Rect2: + var h := cell_size().y * GameState.rows() + return Rect2(Vector2(0.0, size.y - h), Vector2(size.x, h)) + + +## 바다 전체의 가로 길이. 층 수만큼 옆으로 이어져 있지만 한 번에 하나만 보인다. +func world_width() -> float: + return cell_size().x * GameState.columns() + + +## 지금 층이 화면 왼쪽 끝에 오도록 밀어 둔 양. +func layer_offset() -> float: + return current_zone * Catalog.ZONE_COLUMNS * cell_size().x + + +## 화면 좌표를 바다 좌표로. 지금 층만큼 옆으로 밀려 있다. +func to_world(screen_pos: Vector2) -> Vector2: + return screen_pos + Vector2(layer_offset(), 0.0) + + +## 층을 갈아탈 때 위아래로 미끄러뜨린다. +## 실제 배치는 옆으로 늘어서 있지만, 화면에서는 위층/아래층으로 오가는 것처럼 보여야 한다. +func _slide_to_layer(going_down: bool) -> void: + if _slide and _slide.is_valid(): + _slide.kill() + world.position = Vector2(-layer_offset(), size.y * (1.0 if going_down else -1.0)) + _slide = create_tween() + _slide.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT) + _slide.tween_property(world, "position:y", 0.0, LAYER_SLIDE) + + +## 화면에서 누른 자리가 어느 칸인지. +func cell_at(screen_pos: Vector2) -> Vector2i: var c := cell_size() + var p := to_world(screen_pos) var top := grid_rect().position.y - return Vector2i(int(floor(pos.x / c.x)), int(floor((pos.y - top) / c.y))) + return Vector2i(int(floor(p.x / c.x)), int(floor((p.y - top) / c.y))) func cell_rect(col: int, row: int, span := Vector2i.ONE) -> Rect2: @@ -127,19 +242,33 @@ func _anchor_for(id: String, hover: Vector2i) -> Vector2i: ## 크기가 바뀌거나 구역이 열리면 전부 다시 만든다. ## 스트립은 자주 바뀌지 않으므로 부분 갱신보다 통째로 다시 만드는 쪽이 단순하다. func _on_layout_changed() -> void: - ground.rect_size = size + # 배경과 바닥은 화면이 아니라 바다 전체 너비로 그린다. + var world_size := Vector2(world_width(), size.y) + + backdrop.rect_size = world_size + backdrop.grid_top = grid_rect().position.y + backdrop.queue_redraw() + + ground.rect_size = world_size ground.grid_top = grid_rect().position.y + # 감상 중에는 격자선을 눌러 둔다. 지금은 놓는 시간이 아니라 보는 시간이다. + ground.grid_alpha = 0.05 if showcase else 0.13 ground.queue_redraw() _build_water() - _build_dividers() _build_zone_labels() _build_locks() _rebuild_items() - bubbles.position = size * 0.5 - bubbles.emission_rect_extents = size * 0.5 - bubbles.amount = clampi(int(size.x / 40.0), 10, 60) + bubbles.position = Vector2(world_width(), size.y) * 0.5 + bubbles.emission_rect_extents = Vector2(world_width(), size.y) * 0.5 + # 부유물은 바다 전체가 아니라 지금 보이는 곳에만 뿌린다. + # 넓은 바다에 고르게 뿌리면 화면당 밀도가 낮아져 텅 빈 벽처럼 보인다. + bubbles.amount = clampi(int(size.x * size.y / 7000.0), 24, 200) + _follow_view() + + # 격자 크기가 바뀌었으므로 지금 층을 다시 화면에 맞춘다. + world.position = Vector2(-layer_offset(), 0.0) func _build_water() -> void: @@ -158,22 +287,21 @@ func _build_water() -> void: mat.set_shader_parameter("tint_right", Catalog.ramp_color("tint", float(i + 1) / n)) mat.set_shader_parameter("murk_left", Catalog.ramp_float("murk", float(i) / n)) mat.set_shader_parameter("murk_right", Catalog.ramp_float("murk", float(i + 1) / n)) + # 빛줄기는 흐림의 반대다. 얕은 구역에만 내려오고 심해에서는 사라진다. + mat.set_shader_parameter("ray_left", _ray_strength(float(i) / n)) + mat.set_shader_parameter("ray_right", _ray_strength(float(i + 1) / n)) mat.set_shader_parameter("x_offset", r.position.x) mat.set_shader_parameter("rect_size", r.size) rect.material = mat water.add_child(rect) + # 새로 만든 물이라 빛의 양이 아직 안 들어갔다. 다음 틀에서 넣도록 표시해 둔다. + _applied_sun = -1.0 -func _build_dividers() -> void: - for child in dividers.get_children(): - child.queue_free() - for i in range(1, Catalog.zone_count()): - var line := ColorRect.new() - line.color = Color(1, 1, 1, 0.10) - line.position = Vector2(zone_rect(i).position.x - 1.0, 0.0) - line.size = Vector2(2.0, size.y) - line.mouse_filter = Control.MOUSE_FILTER_IGNORE - dividers.add_child(line) + +## 그 자리에 빛줄기가 얼마나 내려오는지. 흐릴수록 빛이 못 닿는다. +func _ray_strength(f: float) -> float: + return clampf(1.0 - Catalog.ramp_float("murk", f) * 1.5, 0.0, 1.0) func _build_zone_labels() -> void: @@ -274,6 +402,8 @@ func _on_lock_pressed(index: int) -> void: func _on_zone_unlocked(index: int) -> void: message.emit("%s 열렸어요" % Korean.i(String(Catalog.zone(index).name))) _on_layout_changed() + # 새로 열린 곳은 보통 화면 밖이다. 바로 보여준다. + current_zone = index @@ -282,6 +412,9 @@ func _on_zone_unlocked(index: int) -> void: func _setup_bubbles() -> void: bubbles.emitting = true + # 한 번 나온 부유물은 그 자리에 머문다. 이게 없으면 화면을 밀 때 + # 부유물이 통째로 따라와 물이 같이 움직이는 것처럼 보인다. + bubbles.local_coords = false bubbles.lifetime = 3.5 bubbles.emission_shape = CPUParticles2D.EMISSION_SHAPE_RECTANGLE bubbles.gravity = Vector2.ZERO @@ -450,7 +583,7 @@ func _pick_at(pos: Vector2) -> Dictionary: continue var r := it.visual_rect() r.position += it.position - if r.has_point(pos) and it.position.y > best_y: + if r.has_point(to_world(pos)) and it.position.y > best_y: best_y = it.position.y best = it if best == null: @@ -490,6 +623,8 @@ func begin_build(id: String) -> void: return _cancel_drag() _building_id = id + # 그 구역이 화면 밖이면 지을 자리를 찍을 수가 없다. 먼저 데려다 놓는다. + current_zone = Catalog.zone_of("house", id) build_mode_changed.emit(true, id) _update_ghost() @@ -559,7 +694,7 @@ func _gui_input(event: InputEvent) -> void: _update_drag(event.position) elif is_building(): _update_ghost() - elif not _press_ref.is_empty() and not _press_was_drag \ + elif _pressing and not _press_ref.is_empty() and not _press_was_drag \ and event.position.distance_to(_press_position) > DRAG_THRESHOLD: _begin_drag() return @@ -576,6 +711,12 @@ func _gui_input(event: InputEvent) -> void: context_menu_requested.emit() return + # 휠은 층을 오르내린다. 위로 굴리면 얕은 쪽, 아래로 굴리면 깊은 쪽. + if event.pressed and event.button_index in [ + MOUSE_BUTTON_WHEEL_UP, MOUSE_BUTTON_WHEEL_DOWN]: + current_zone += -1 if event.button_index == MOUSE_BUTTON_WHEEL_UP else 1 + return + if event.button_index != MOUSE_BUTTON_LEFT: return @@ -587,6 +728,7 @@ func _gui_input(event: InputEvent) -> void: _press_position = event.position _press_ref = _pick_at(event.position) _press_was_drag = false + _pressing = true else: if not _drag_ref.is_empty(): _drop(event.position) @@ -595,6 +737,7 @@ func _gui_input(event: InputEvent) -> void: clicked.emit(event.position) _press_ref = {} _press_was_drag = false + _pressing = false func _begin_drag() -> void: @@ -611,7 +754,7 @@ func _begin_drag() -> void: func _update_drag(pos: Vector2) -> void: if _drag_node and is_instance_valid(_drag_node): - _drag_node.position = pos + _drag_node.position = to_world(pos) # 생물은 칸을 차지하지 않으므로 놓일 자리를 미리 보여줄 것이 없다. if trash_rect.has_point(pos) or String(_drag_ref.kind) != "house": @@ -682,10 +825,11 @@ func _cancel_drag() -> void: ## 만진 자리에서 거품이 퍼지는 연출. -func pop_at(pos: Vector2) -> void: +## 스크롤해도 물에 남아 있도록 화면이 아니라 바다에 붙인다. +func pop_at(screen_pos: Vector2) -> void: var burst := CPUParticles2D.new() - add_child(burst) - burst.position = pos + world.add_child(burst) + burst.position = to_world(screen_pos) burst.emitting = true burst.one_shot = true burst.explosiveness = 1.0 diff --git a/scenes/aquarium/aquarium.tscn b/scenes/aquarium/aquarium.tscn index 2be14f2..6770770 100644 --- a/scenes/aquarium/aquarium.tscn +++ b/scenes/aquarium/aquarium.tscn @@ -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"] diff --git a/scenes/aquarium/backdrop.gd b/scenes/aquarium/backdrop.gd new file mode 100644 index 0000000..87c41e3 --- /dev/null +++ b/scenes/aquarium/backdrop.gd @@ -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) diff --git a/scenes/aquarium/backdrop.gd.uid b/scenes/aquarium/backdrop.gd.uid new file mode 100644 index 0000000..4f1fb0c --- /dev/null +++ b/scenes/aquarium/backdrop.gd.uid @@ -0,0 +1 @@ +uid://be6jcjj4707om diff --git a/scenes/aquarium/seabed.gd b/scenes/aquarium/seabed.gd index 0cf230f..1c210c9 100644 --- a/scenes/aquarium/seabed.gd +++ b/scenes/aquarium/seabed.gd @@ -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) diff --git a/scenes/aquarium/tile_item.gd b/scenes/aquarium/tile_item.gd index ede9aa3..be18c5e 100644 --- a/scenes/aquarium/tile_item.gd +++ b/scenes/aquarium/tile_item.gd @@ -224,9 +224,15 @@ func _draw_glow() -> void: var pulse := 0.88 + sin(_t * 1.5 + _phase) * 0.12 var bell := Vector2(0, -body_height * 0.45) + # 스스로 빛나는 것은 밤에 오히려 도드라진다. 바다 전체에 걸린 어둠을 + # 색과 후광으로 되밀어서, 어두울수록 또렷해 보이게 한다. + var dark := 1.0 - DayCycle.light + var lume := color.lightened(dark * 0.45) + var halo := 0.08 + dark * 0.10 + for i in range(5, 0, -1): - draw_circle(bell, r * (float(i) / 5.0) * 2.1 * pulse, - Color(color.r, color.g, color.b, 0.08)) + draw_circle(bell, r * (float(i) / 5.0) * (2.1 + dark * 0.8) * pulse, + Color(lume.r, lume.g, lume.b, halo)) # 아래로 늘어진 촉수를 먼저 그려 갓 뒤로 보내둔다. for i in 4: @@ -237,7 +243,7 @@ func _draw_glow() -> void: strand.append(bell + Vector2( x + sin(_t * 1.6 + _phase + i + f * 3.0) * r * 0.30 * f, r * 0.5 + body_height * 0.42 * f)) - draw_polyline(strand, Color(color.r, color.g, color.b, 0.50), + draw_polyline(strand, Color(lume.r, lume.g, lume.b, 0.50), maxf(r * 0.16, 1.2), true) # 반구형 갓. @@ -247,8 +253,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)) ## 바위·구조물. 아래가 넓고 위로 갈수록 좁아지는 덩어리. @@ -350,13 +356,19 @@ func _draw_wreck() -> void: var deck := -h * 0.52 # 선체. 아래는 둥글고 위는 갑판으로 열려 있다. - var hull := PackedVector2Array() - var steps := 12 - for i in steps + 1: - var a := PI + PI * float(i) / steps - hull.append(Vector2(cos(a) * w, sin(a) * h * 0.46)) - hull.append(Vector2(w + lean, deck)) - hull.append(Vector2(-w + lean * 0.4, deck * 0.72)) + # 갑판(왼→오른)에서 시작해 오른쪽 뱃전을 타고 내려간 뒤, + # 둥근 배 밑을 훑어 왼쪽으로 돌아온다. 한 방향으로만 돌아야 + # 폴리곤이 제 몸을 가로지르지 않는다 -- 그러면 삼각분할이 실패한다. + var belly := -h * 0.12 + var hull := PackedVector2Array([ + Vector2(-w + lean * 0.4, deck * 0.72), + Vector2(w + lean, deck), + Vector2(w, belly), + ]) + var steps := 10 + for i in range(1, steps + 1): + var a := PI * float(i) / steps + hull.append(Vector2(cos(a) * w, belly + sin(a) * h * 0.12)) _fill(hull, color) # 갈비뼈. 사이의 빈틈이 물고기가 숨는 자리로 읽힌다. diff --git a/scenes/main.gd b/scenes/main.gd index 433751b..49ab0a0 100644 --- a/scenes/main.gd +++ b/scenes/main.gd @@ -8,6 +8,7 @@ enum MenuId { SAVE_NOW = 2, RESET = 3, QUIT = 4, + FULLSCREEN = 5, } ## 높이 프리셋. 메뉴에서 고른다. @@ -34,12 +35,17 @@ 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)) _always_on_top = bool(state.get("always_on_top", true)) _click_through = bool(state.get("click_through", false)) _edge_bottom = bool(state.get("edge_bottom", true)) @@ -60,6 +66,7 @@ func _ready() -> void: # 격자는 정보 바 아래에서 시작해야 한다. 바 높이는 HUD가 재어서 알려준다. hud.reserved_top_changed.connect(func(px: float): aquarium.grid_top = px) aquarium.grid_top = hud.reserved_top() + aquarium.current_zone = saved_layer hud.shop_pressed.connect(_toggle_shop) hud.menu_pressed.connect(_open_menu) @@ -70,6 +77,11 @@ func _ready() -> void: aquarium.build_mode_changed.connect(_on_build_mode_changed) aquarium.drag_changed.connect(_on_drag_changed) + # 층 선택기와 바다를 서로 물린다. 어느 쪽으로 층을 바꿔도 표시가 맞는다. + hud.layer_bar.layer_picked.connect(func(i: int): aquarium.current_zone = i) + aquarium.layer_changed.connect(hud.layer_bar.set_current) + hud.layer_bar.set_current(aquarium.current_zone) + _build_menu() get_window().close_requested.connect(_quit) @@ -90,12 +102,23 @@ func _apply_dock() -> void: h = mini(h, maxi(usable.size.y - 60, 120)) var win := get_window() + # 창을 화면과 픽셀까지 똑같이 맞추면 Windows가 독점 전체화면으로 승격시켜 버리고, + # 그 뒤로는 size를 넣어도 먹지 않아 감상 모드에서 빠져나올 수 없다. + # 그래서 감상 모드도 작업표시줄을 뺀 영역까지만 쓴다. + if win.mode != Window.MODE_WINDOWED: + win.mode = Window.MODE_WINDOWED win.content_scale_factor = _ui_scale - win.size = Vector2i(usable.size.x, h) - win.position = Vector2i( - usable.position.x, - usable.end.y - h if _edge_bottom else usable.position.y - ) + if _fullscreen: + win.size = usable.size + win.position = usable.position + else: + win.size = Vector2i(usable.size.x, h) + win.position = Vector2i( + usable.position.x, + usable.end.y - h if _edge_bottom else usable.position.y + ) + win.always_on_top = _always_on_top or _fullscreen + aquarium.showcase = _fullscreen _apply_passthrough() _remember_window() @@ -104,6 +127,7 @@ 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["edge_bottom"] = _edge_bottom SaveManager.window_state["height"] = _strip_height SaveManager.window_state["screen"] = _screen @@ -112,7 +136,8 @@ func _remember_window() -> void: ## 클릭 통과를 켜면 안쪽 모서리 하나만 입력을 받는 영역으로 남긴다. ## 빈 배열을 넘기면 통과가 꺼지고 창 전체가 다시 입력을 받는다. func _apply_passthrough() -> void: - if not _click_through: + # 감상 중에는 통과시키지 않는다. 화면을 다 덮은 채 클릭이 새 나가면 곤란하다. + if _fullscreen or not _click_through: DisplayServer.window_set_mouse_passthrough(PackedVector2Array()) return # 통과 영역은 창의 물리 픽셀 좌표로 준다. @@ -131,9 +156,40 @@ func _apply_passthrough() -> void: # --- 게임 입력 --- +## 감상 모드로 들어가고 나온다. 창을 화면 전체로 키우고, +## 바다는 아래에 깔고 그 위를 물기둥으로 채운다. +func set_fullscreen(on: bool) -> void: + if _fullscreen == on: + return + _fullscreen = on + aquarium.cancel_build() + if on: + shop.hide() + _apply_dock() + hud.show_toast("감상 모드 · Esc나 F11로 돌아갑니다" if on else "위젯으로 돌아왔어요", 3.0) + + func _unhandled_input(event: InputEvent) -> void: - if event.is_action_pressed("ui_cancel") and aquarium.is_building(): - aquarium.cancel_build() + if event.is_action_pressed("ui_cancel"): + if aquarium.is_building(): + aquarium.cancel_build() + elif _fullscreen: + set_fullscreen(false) + else: + return + get_viewport().set_input_as_handled() + return + + if event is InputEventKey and event.pressed and not event.echo: + match event.keycode: + KEY_F11: + set_fullscreen(not _fullscreen) + KEY_UP, KEY_PAGEUP: + aquarium.current_zone -= 1 + KEY_DOWN, KEY_PAGEDOWN: + aquarium.current_zone += 1 + _: + return get_viewport().set_input_as_handled() @@ -199,6 +255,7 @@ 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() for h in HEIGHTS: menu.add_radio_check_item(h.label, h.id) @@ -221,6 +278,7 @@ func _build_menu() -> void: 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)) _check(EDGE_BOTTOM, _edge_bottom) @@ -270,6 +328,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() diff --git a/scenes/ui/hud.gd b/scenes/ui/hud.gd index 42c2822..0add46c 100644 --- a/scenes/ui/hud.gd +++ b/scenes/ui/hud.gd @@ -10,7 +10,7 @@ signal menu_pressed signal reserved_top_changed(px: float) ## 바 아래로 이만큼은 띄워야 첫 줄 칸이 바에 닿지 않는다. -const BAR_CLEARANCE := 6.0 +const BAR_CLEARANCE := 22.0 @onready var control_bar: PanelContainer = $ControlBar @onready var bubbles_label: Label = %BubblesLabel @@ -20,6 +20,7 @@ const BAR_CLEARANCE := 6.0 @onready var shop_button: Button = %ShopButton @onready var menu_button: Button = %MenuButton @onready var toast: Label = %Toast +@onready var layer_bar: LayerBar = %LayerBar @onready var trash: PanelContainer = %Trash @onready var trash_label: Label = %TrashLabel @@ -27,8 +28,13 @@ var _toast_tween: Tween func _ready() -> void: - control_bar.add_theme_stylebox_override("panel", UIStyle.panel(UIStyle.PANEL_BG, 12)) - UIStyle.label(bubbles_label, 24, UIStyle.INK) + # 바를 납작하게 눌러 둔다. 이 높이만큼 격자가 밀리고, + # 남는 자리에 먼 바다 배경이 드러난다. + var bar_style := UIStyle.panel(UIStyle.PANEL_BG, 11) + bar_style.content_margin_top = 6 + bar_style.content_margin_bottom = 6 + control_bar.add_theme_stylebox_override("panel", bar_style) + UIStyle.label(bubbles_label, 20, UIStyle.INK) UIStyle.label(bps_label, 13, UIStyle.INK_DIM) UIStyle.label(beauty_label, 13, UIStyle.GOLD) UIStyle.label(zone_label, 13, UIStyle.ACCENT) @@ -44,7 +50,10 @@ func _ready() -> void: shop_button.pressed.connect(func(): shop_pressed.emit()) menu_button.pressed.connect(func(): menu_pressed.emit()) - control_bar.resized.connect(func(): reserved_top_changed.emit(reserved_top())) + control_bar.resized.connect(func(): + _place_toast() + reserved_top_changed.emit(reserved_top())) + _place_toast() GameState.zone_unlocked.connect(func(_i: int): _refresh_zone_label()) GameState.state_changed.connect(_refresh_zone_label) @@ -83,6 +92,15 @@ func _on_offline_earned(amount: float, seconds: int) -> void: ], 4.5) +## 알림은 정보 바 바로 아래 왼쪽에 붙인다. +## 가운데에 두면 바가 길어졌을 때 글자가 겹쳐 둘 다 못 읽는다. +func _place_toast() -> void: + # 층 선택기가 왼쪽 아래를 쓰므로 알림은 그 폭만큼 비켜 앉힌다. + toast.position = Vector2( + control_bar.position.x + layer_bar.size.x + 14.0, + control_bar.position.y + control_bar.size.y + 6.0) + + func show_toast(text: String, duration := 2.0) -> void: toast.text = text if _toast_tween and _toast_tween.is_valid(): diff --git a/scenes/ui/hud.tscn b/scenes/ui/hud.tscn index 3bb2fb6..986b036 100644 --- a/scenes/ui/hud.tscn +++ b/scenes/ui/hud.tscn @@ -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 diff --git a/scenes/ui/layer_bar.gd b/scenes/ui/layer_bar.gd new file mode 100644 index 0000000..4f6d0e7 --- /dev/null +++ b/scenes/ui/layer_bar.gd @@ -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) diff --git a/scenes/ui/layer_bar.gd.uid b/scenes/ui/layer_bar.gd.uid new file mode 100644 index 0000000..2ac5fab --- /dev/null +++ b/scenes/ui/layer_bar.gd.uid @@ -0,0 +1 @@ +uid://bdayflw4cn1co diff --git a/tests/smoke.gd b/tests/smoke.gd index 67135e2..637266e 100644 --- a/tests/smoke.gd +++ b/tests/smoke.gd @@ -25,13 +25,14 @@ func _ready() -> void: _test_zone_gating() _test_zone_unlock() _test_zone_beauty_is_per_zone() - _test_ramp_continuity() + _test_zone_colors() _test_serialization_round_trip() _test_legacy_save_migration() _test_number_format() _test_korean_particles() _test_shape_families() _test_upgrades_are_all_production() + _test_day_cycle() if _failures == 0: print("모든 검사 통과") @@ -324,6 +325,9 @@ func _test_release_fish() -> void: func _test_space_is_the_limit() -> void: print("공간이 곧 한계") _rich_and_open() + # 한 구역을 끝까지 채우려면 값이 1.15배씩 오르는 집을 200채 넘게 지어야 한다. + # 거품이 먼저 떨어지면 "칸이 차서" 멈춘 것인지 알 수 없다. + GameState.bubbles = 1e30 var cells := Catalog.GRID_ROWS * Catalog.ZONE_COLUMNS check("구역 하나는 %d칸" % cells, GameState.free_cells_in_zone(0) == cells, str(GameState.free_cells_in_zone(0))) @@ -412,30 +416,38 @@ func _test_zone_beauty_is_per_zone() -> void: 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: @@ -607,3 +619,57 @@ func _test_upgrades_are_all_production() -> void: check("같은 것을 두 번은 못 산다", not GameState.buy_upgrade(id)) check("업그레이드는 칸도 자리도 안 먹는다", GameState.free_cells_in_zone(0) > 0 and GameState.capacity_in_zone(0) > 0) + + +func _test_day_cycle() -> void: + print("하루의 빛") + var before := DayCycle.forced_hour + + DayCycle.forced_hour = 5.0 + check("새벽 5시는 새벽", DayCycle.phase == "새벽", DayCycle.phase) + DayCycle.forced_hour = 8.0 + check("아침 8시는 아침", DayCycle.phase == "아침", DayCycle.phase) + DayCycle.forced_hour = 13.0 + check("오후 1시는 낮", DayCycle.phase == "낮", DayCycle.phase) + DayCycle.forced_hour = 18.0 + check("저녁 6시는 저녁", DayCycle.phase == "저녁", DayCycle.phase) + DayCycle.forced_hour = 22.0 + check("밤 10시는 밤", DayCycle.phase == "밤", DayCycle.phase) + # 자정을 넘긴 시각은 전날 밤이 이어져야 한다. 여기서 구간이 비면 이름이 빈다. + DayCycle.forced_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.forced_hour = 0.0 + var midnight := DayCycle.light + DayCycle.forced_hour = 24.0 + check("자정과 24시가 이어진다", is_equal_approx(DayCycle.light, midnight), + "%f vs %f" % [DayCycle.light, midnight]) + + # 빛이 튀면 해가 순간이동하는 것처럼 보인다. 하루를 훑으며 급변이 없는지 본다. + var max_jump := 0.0 + DayCycle.forced_hour = 0.0 + var prev := DayCycle.light + for i in range(1, 481): + DayCycle.forced_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.forced_hour = before