commit 446d8d177c1c0d7f32261e8faf47aedcb26b192c Author: ttipo Date: Tue Sep 1 17:59:50 2026 +0900 바탕화면 바닷속 방치형 위젯 첫 커밋 화면 아래 가장자리에 가로로 깔리는 Godot 4.7 위젯. 거품을 모아 바다를 꾸미고, 꾸밀수록 거품이 더 빨리 모인다. - 도킹 창: 투명·테두리 없음, 화면 폭 전체, 위/아래 가장자리 스냅, 높이 프리셋, 클릭 통과(모서리로 복귀), DPI 배율 보정 - 3/4 부감 격자: 구역당 9x6, 전체 45x6 = 270칸. 바닥은 위에서, 물체는 서 있는 모습으로 그리고 Y 정렬로 앞뒤를 가린다 - 구역 5개(얕은 바다 -> 열수구): 앞 구역을 꾸며야 다음이 열린다 - 칸이 곧 한계인 경제: 배치/이동/치우기(절반 환불), 여러 칸짜리 장식 - 저장: 30초 자동 저장, 백업 한 세대, 8시간 상한 오프라인 보상, 격자 이전 형식 저장본 자동 이전 - 스모크 테스트 60여 개 (test.ps1) Co-Authored-By: Claude Opus 5 (1M context) diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..385e3f2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Godot 프로젝트 파일은 모두 텍스트다. 줄바꿈을 LF로 통일해 +# Windows/macOS/Linux를 오가도 .tscn과 .gd에 불필요한 변경이 생기지 않게 한다. +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..860b782 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Godot 4 +.godot/ +/android/ +export_presets.cfg +*.translation + +# 빌드 산출물 +/build/ +/export/ + +# Windows +Thumbs.db +desktop.ini diff --git a/README.md b/README.md new file mode 100644 index 0000000..c51e517 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# Blub Blub + +화면 아래 가장자리에 가로로 깔리는 바닷속 방치형 스트립. +다른 일을 하는 동안 화면 밑에서 조용히 돌아간다. 거품을 모아 바다를 꾸미고, 꾸밀수록 거품이 더 빨리 모인다. + +창은 화면 폭 전체를 차지하고 높이만 160~300px이다. 작업표시줄은 덮지 않는다. + +## 실행 + +- 에디터: `%LOCALAPPDATA%\Programs\Godot\Godot_v4.7.2-stable_win64.exe` 를 실행해 이 폴더를 열거나, 이미 임포트되어 있으면 프로젝트 목록에서 **Blub Blub** 선택 +- 게임만: `.\run.ps1` +- 테스트: `.\test.ps1` + +## 조작 + +| 동작 | 방법 | +| --- | --- | +| 거품 모으기 | 빈 곳 좌클릭 | +| 놓기 | 상점에서 고른 뒤 칸을 클릭 (우클릭·Esc로 취소) | +| 옮기기 | 놓인 것을 끌어다 다른 칸에 놓기 (공짜) | +| 치우기 | 끌어서 아래 휴지통에 떨구기 (절반 환불) | +| 상점 열기 | 왼쪽 위 **상점** (패널은 오른쪽에 열린다) | +| 메뉴 | **···** 버튼 또는 물 위 우클릭 | + +메뉴: 항상 위에 표시 · 클릭 통과 · 높이(낮게/보통/높게) · 위아래 도킹 · 화면 선택(모니터가 여러 대일 때) · 지금 저장 · 처음부터 다시 · 종료 +**클릭 통과**를 켜면 클릭이 바탕화면으로 지나가고, 안쪽 모서리(84×56px)만 계속 눌린다. +이 모서리를 눌러 메뉴로 돌아와 통과를 끌 수 있다. (아래 도킹이면 오른쪽 위, 위 도킹이면 오른쪽 아래) + +## 구조 + +``` +project.godot 창 설정(투명·테두리 없음·항상 위·1:1 픽셀)과 오토로드 등록 +autoload/ + catalog.gd 밸런스 데이터 전부. 아이템 추가는 여기서만. + game_state.gd 보유 자원·구매 상태와 파생 수치(bps, 아름다움, 등급) + save_manager.gd 저장/불러오기, 30초 자동 저장, 오프라인 보상, 창 위치 +scenes/ + main.tscn/.gd 도킹(폭 전체·가장자리 스냅)·클릭 통과·메뉴와 화면 조립 + aquarium/ 물빛 셰이더, 모래 바닥, 물속 오브젝트 + ui/ 왼쪽 위 정보 바, 오른쪽 상점 패널 +scripts/ + number_format.gd 1234 -> "1.23K", 경과 시간 표기 + ui_style.gd UI 색과 스타일박스 +tests/smoke.tscn/.gd 경제 로직 스모크 테스트 +``` + +## 게임 루프 + +1. 물 아무 곳이나 클릭해 **거품**을 모은다. +2. 거품으로 **생물**(해초, 물고기…)을 사면 초당 생산량과 **아름다움**이 오른다. +3. **장식**(난파선, 석상…)은 한 번만 살 수 있고, 아름다움이 크며 전체 생산에 곱연산 보너스를 준다. +4. 아름다움은 다시 전체 생산량에 `1 + sqrt(아름다움) × 0.02` 배율로 되돌아온다. + → **꾸미는 것이 곧 성장이다.** +5. 앞 구역을 충분히 꾸미고 거품을 모으면 오른쪽 다음 구역이 열린다. + 얕은 바다 → 산호초 → 켈프 숲 → 심해 → 열수구 + +## 격자와 배치 + +바다는 **3/4 부감 격자**다. 구역당 9열 × 6행, 전체 45열 × 6행 = 270칸. +바닥은 위에서 내려다보지만 그 위에 놓인 것은 **서 있는 모습**으로 그린다. +러스티의 농장과 같은 방식이고, 완전한 탑다운과는 다르다. +산 것은 모두 플레이어가 고른 칸에 놓이고, **칸이 곧 한계다.** 거품이 아무리 많아도 +자리가 없으면 더 놓을 수 없다. 그래서 성장은 세 갈래로 갈린다. + +1. 다음 구역을 열어 자리를 늘린다 +2. 싼 것을 치우고 비싼 것으로 갈아 끼운다 (치우면 절반을 돌려받는다) +3. 칸을 차지하지 않는 업그레이드를 산다 + +장식은 여러 칸을 차지한다. 난파선·잠수정·고래 뼈는 3×2, 석주·석상·굴뚝·수정은 2×2, +항아리와 닻은 2×1. 자리를 많이 먹는 만큼 아름다움이 크다. + +**배치 방법**: 상점에서 고르면 상점이 잠시 닫히고 배치 모드가 된다. +놓을 칸을 클릭하면 그때 값이 지불된다. 우클릭이나 Esc로 취소. +이미 놓인 것은 끌어서 옮길 수 있고(공짜), 끄는 동안 아래에 나타나는 +휴지통에 떨구면 치우고 절반을 돌려받는다. + +## 구역 + +가로가 다섯 구역으로 나뉘어 있고, **왼쪽에서 오른쪽으로 갈수록 깊어진다.** +처음에는 맨 왼쪽 얕은 바다만 열려 있고 나머지는 어둡게 덮여 있다. +잠긴 구역을 클릭하면 해금 조건과 남은 양이 그 자리에 뜬다. + +다음 구역을 열려면 두 가지가 필요하다. + +1. **바로 앞 구역의 아름다움** — 앞바다를 대충 두고 다음으로 넘어갈 수 없다. +2. **해금 비용(거품)** + +생물과 장식은 각자 자기 구역에 속한다. 그 구역이 열려야 상점에 나타나고, +그 구역 밖으로는 놓을 수 없다(구역 경계를 걸치는 것도 안 된다). +모래색과 물빛은 구역 중심 색을 기준으로 이어 붙여서 경계가 보이지 않게 그린다. + +자세한 수치와 앞으로 붙일 것들은 [docs/DESIGN.md](docs/DESIGN.md) 참고. + +## 저장 위치 + +`%APPDATA%\Godot\app_userdata\Blub Blub\save.json` (한 세대 전 백업은 `save.bak.json`) + +## 부팅 시 자동 실행 + +내보내기(Export)로 exe를 만든 뒤, `Win+R` → `shell:startup` 폴더에 그 exe의 바로가기를 넣으면 된다. diff --git a/assets/art/.gitkeep b/assets/art/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/audio/.gitkeep b/assets/audio/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/shaders/water.gdshader b/assets/shaders/water.gdshader new file mode 100644 index 0000000..ce5aa3d --- /dev/null +++ b/assets/shaders/water.gdshader @@ -0,0 +1,32 @@ +shader_type canvas_item; +// 탑다운 바다의 물빛 오버레이. 바닥 위에 덮여서 "물을 통해 내려다본다"는 느낌을 만든다. +// 구역 하나를 담당하고, 좌우 끝 색을 이웃과 공유해 이음매 없이 이어진다. + +uniform vec4 tint_left : source_color = vec4(0.27, 0.70, 0.80, 1.0); +uniform vec4 tint_right : source_color = vec4(0.16, 0.51, 0.57, 1.0); +// 깊을수록 물이 두꺼워 바닥이 흐려진다. +uniform float murk_left : hint_range(0.0, 1.0) = 0.20; +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; + +void fragment() { + vec2 px = vec2(x_offset, 0.0) + UV * rect_size; + vec3 tint = mix(tint_left.rgb, tint_right.rgb, UV.x); + float murk = mix(murk_left, murk_right, UV.x); + + // 수면에서 갈라진 빛이 바닥에 드리우는 그물 무늬. + 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 alpha = clamp(murk - caustic * caustic_strength * 0.5, 0.0, 1.0); + vec3 col = tint + caustic * caustic_strength; + + COLOR = vec4(col, alpha); +} diff --git a/assets/shaders/water.gdshader.uid b/assets/shaders/water.gdshader.uid new file mode 100644 index 0000000..1387f3e --- /dev/null +++ b/assets/shaders/water.gdshader.uid @@ -0,0 +1 @@ +uid://dm6slb5kdhngy diff --git a/assets/shaders/water_material.tres b/assets/shaders/water_material.tres new file mode 100644 index 0000000..5c323dc --- /dev/null +++ b/assets/shaders/water_material.tres @@ -0,0 +1,7 @@ +[gd_resource type="ShaderMaterial" load_steps=2 format=3] + +[ext_resource type="Shader" path="res://assets/shaders/water.gdshader" id="1_water"] + +[resource] +shader = ExtResource("1_water") +shader_parameter/caustic_strength = 0.13 diff --git a/assets/ui_font.tres b/assets/ui_font.tres new file mode 100644 index 0000000..a4e8663 --- /dev/null +++ b/assets/ui_font.tres @@ -0,0 +1,6 @@ +[gd_resource type="SystemFont" format=3] + +[resource] +font_names = PackedStringArray("Malgun Gothic", "맑은 고딕", "Noto Sans CJK KR", "Segoe UI") +allow_system_fallback = true +subpixel_positioning = 1 diff --git a/autoload/catalog.gd b/autoload/catalog.gd new file mode 100644 index 0000000..c0ba70c --- /dev/null +++ b/autoload/catalog.gd @@ -0,0 +1,314 @@ +extends Node +## 게임 밸런스 데이터의 단일 출처. +## 새 구역/생물/장식/업그레이드는 아래 배열에만 추가하면 상점과 바다에 자동으로 반영된다. + +# --- 격자 --- +## 바다는 3/4 부감 격자다. 바닥은 위에서 내려다보고, 그 위에 놓인 것은 서 있는 모습으로 그린다. +## 세로 행 수는 고정이고, 칸의 픽셀 크기는 스트립 크기에 맞춰 늘어난다. +## 이렇게 해야 창 높이를 바꿔도 저장해 둔 배치가 그대로 유효하다. +const GRID_ROWS := 6 +## 구역 하나가 차지하는 열 수. 구역당 GRID_ROWS * ZONE_COLUMNS 칸이 배치 공간이 된다. +const ZONE_COLUMNS := 9 + +## 구역. 왼쪽(얕은 바다)에서 오른쪽(열수구)으로 갈수록 깊어진다. +## unlock_cost : 해금에 드는 거품 +## require_beauty: 바로 앞 구역에 쌓인 아름다움이 이만큼은 되어야 한다. +## "앞 구역을 충분히 꾸며야 다음이 열린다"는 규칙. +## sand : 바닥색. 위에서 내려다보는 그림이라 이게 바탕이 된다. +## tint/murk : 그 위에 덮이는 물빛과 진하기. 깊을수록 진해져 바닥이 흐려진다. +const ZONES := [ + { + "id": "shallow", "name": "얕은 바다", + "unlock_cost": 0.0, "require_beauty": 0.0, + "sand": Color(0.93, 0.83, 0.58), "tint": Color(0.18, 0.68, 0.76), "murk": 0.17, + }, + { + "id": "reef", "name": "산호초", + "unlock_cost": 30000.0, "require_beauty": 90.0, + "sand": Color(0.89, 0.74, 0.50), "tint": Color(0.10, 0.48, 0.68), "murk": 0.24, + }, + { + "id": "kelp", "name": "켈프 숲", + "unlock_cost": 3000000.0, "require_beauty": 500.0, + "sand": Color(0.60, 0.56, 0.36), "tint": Color(0.05, 0.30, 0.42), "murk": 0.40, + }, + { + "id": "abyss", "name": "심해", + "unlock_cost": 400000000.0, "require_beauty": 2200.0, + "sand": Color(0.31, 0.33, 0.37), "tint": Color(0.02, 0.10, 0.24), "murk": 0.60, + }, + { + "id": "vent", "name": "열수구", + "unlock_cost": 4e10, "require_beauty": 8000.0, + "sand": Color(0.30, 0.17, 0.16), "tint": Color(0.06, 0.02, 0.09), "murk": 0.76, + }, +] + +## 생물 — 한 칸을 차지한다. 칸이 곧 한계이므로 무한히 살 수는 없다. +## zone : 어느 구역에 사는지. 그 구역이 해금되어야 살 수 있고, 그 구역 안에만 놓을 수 있다. +## bps : 개당 초당 거품 생산량 +## shape : plant / rock(바닥에 고정) / fish / glow(제 자리 위를 떠다님) +## height: 칸 높이의 몇 배로 서 있는지. 1보다 크면 뒷줄을 가린다. +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": "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": "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, + }, + { + "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": "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": 2000000.0, "bps": 1400.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, + }, + { + "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, + }, + # --- 열수구 --- + { + "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": "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, + }, +] + +## 장식 — 구역당 두 개, 한 번만 살 수 있다. 여러 칸을 차지하는 대신 아름다움이 크다. +## size: 바닥에서 차지하는 칸 수 (열, 행) +const ORNAMENTS := [ + # --- 얕은 바다 --- + { + "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": "bottle_letter", "name": "유리병 편지", "zone": 0, "shape": "glow", + "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, + }, + # --- 산호초 --- + { + "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": "shipwreck", "name": "난파선", "zone": 1, "shape": "rock", + "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, + }, + # --- 켈프 숲 --- + { + "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": "stone_pillar", "name": "이끼 낀 석주", "zone": 2, "shape": "rock", + "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, + }, + # --- 심해 --- + { + "id": "whale_fall", "name": "고래 뼈", "zone": 3, "shape": "rock", + "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, + }, + { + "id": "moai", "name": "해저 석상", "zone": 3, "shape": "rock", + "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, + }, + # --- 열수구 --- + { + "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": "crystal", "name": "심해 수정", "zone": 4, "shape": "glow", + "size": Vector2i(2, 2), "height": 1.55, + "desc": "빛이 닿지 않는 곳에서 스스로 빛난다.", + "color": Color(0.55, 0.90, 0.95), "cost": 6e12, "beauty": 15000.0, "mult": 0.20, + }, +] + +## 업그레이드 — 칸을 차지하지 않고 전체에 적용된다. 한 번만 살 수 있다. +## kind: click_add(클릭당 가산) / click_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, + "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배"}, +] + +## 생물 가격 성장률. 칸이 한정되어 있어 한 종류를 30개쯤 놓으면 66배가 된다. +const COST_GROWTH := 1.15 + +## 아름다움 → 생산 배율 곡선. 제곱근이라 초반엔 크게, 후반엔 완만하게 오른다. +const BEAUTY_MULT_SCALE := 0.02 + +## 치울 때 돌려받는 비율. +const REFUND_RATE := 0.5 + +var _producers_by_id := {} +var _ornaments_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 UPGRADES: + _upgrades_by_id[d.id] = d + + +# --- 구역 --- + +func zone_count() -> int: + return ZONES.size() + + +func zone(index: int) -> Dictionary: + return ZONES[clampi(index, 0, ZONES.size() - 1)] + + +func total_columns() -> int: + return ZONES.size() * ZONE_COLUMNS + + +func zone_of_column(col: int) -> int: + return clampi(col / ZONE_COLUMNS, 0, ZONES.size() - 1) + + +# --- 아이템 --- + +func producer(id: String) -> Dictionary: + return _producers_by_id.get(id, {}) + + +func ornament(id: String) -> Dictionary: + return _ornaments_by_id.get(id, {}) + + +func upgrade(id: String) -> Dictionary: + return _upgrades_by_id.get(id, {}) + + +func item(kind: String, id: String) -> Dictionary: + match kind: + "producer": return producer(id) + "ornament": return ornament(id) + "upgrade": return upgrade(id) + return {} + + +func items_of(kind: String) -> Array: + match kind: + "producer": return PRODUCERS + "ornament": return ORNAMENTS + "upgrade": return UPGRADES + return [] + + +## 업그레이드는 구역에 속하지 않으므로 항상 0번 구역으로 친다. +func zone_of(kind: String, id: String) -> int: + return int(item(kind, id).get("zone", 0)) + + +## 바닥에서 차지하는 칸 수. 지정하지 않은 것은 한 칸. +func size_of(kind: String, id: String) -> Vector2i: + return item(kind, id).get("size", Vector2i.ONE) + + +## 칸 높이의 몇 배로 서 있는지. +func height_of(kind: String, id: String) -> float: + return float(item(kind, id).get("height", 1.0)) + + +## 각 구역의 첫 항목. 구역을 막 열었을 때 상점이 비어 보이지 않게 쓰인다. +func first_id_of_zone(kind: String, zone_index: int) -> String: + for d in items_of(kind): + if int(d.get("zone", 0)) == zone_index: + return String(d.id) + return "" + + +# --- 색과 깊이 --- + +## 스트립 가로 위치 f(0~1)에서의 값. +## 구역 중심에서는 그 구역 값 그대로, 경계에서는 이웃과 반씩 섞인다. +## 바닥과 물빛이 같은 함수를 쓰기 때문에 경계가 어긋나지 않는다. +func _ramp_bounds(f: float) -> Array: + 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] + + +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]) + + +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]) diff --git a/autoload/catalog.gd.uid b/autoload/catalog.gd.uid new file mode 100644 index 0000000..55ed537 --- /dev/null +++ b/autoload/catalog.gd.uid @@ -0,0 +1 @@ +uid://cll3sln3ona6i diff --git a/autoload/game_state.gd b/autoload/game_state.gd new file mode 100644 index 0000000..1f76d13 --- /dev/null +++ b/autoload/game_state.gd @@ -0,0 +1,457 @@ +extends Node +## 게임의 진행 상태와 파생 수치를 들고 있는 단일 저장소. +## 저장/불러오기는 SaveManager가, 밸런스 수치는 Catalog가 담당한다. +## +## 바다는 탑다운 격자이고, 산 것은 모두 격자 위 특정 칸에 놓인다. +## 즉 "몇 개 샀는가"가 아니라 "어디에 무엇이 있는가"가 진짜 상태다. + +## 구매·이동·업그레이드처럼 화면을 다시 그려야 하는 이산적인 변화. +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] = [] +var upgrades := {} ## id -> true +var unlocked_zones := 1 ## 왼쪽부터 몇 개 구역이 열려 있는지 (최소 1) +var first_played_unix := 0 + +# --- recalc()로 계산되는 파생 값 (저장하지 않음) --- +var bps := 0.0 ## 초당 거품 생산량 (모든 보너스 반영) +var beauty := 0.0 ## 총 아름다움 +var zone_beauty: Array[float] = [] ## 구역별 아름다움 +var click_power := 1.0 ## 클릭 한 번당 거품 +var beauty_mult := 1.0 + +var _counts := {} ## id -> 놓인 개수 +var _occupancy := PackedInt32Array() ## 칸 -> placements 인덱스, 빈 칸은 -1 + + +func _ready() -> void: + first_played_unix = int(Time.get_unix_time_from_system()) + recalc() + + +func _process(delta: float) -> void: + if bps > 0.0: + _earn(bps * delta) + + +func _earn(amount: float) -> void: + bubbles += amount + total_bubbles += amount + + +## 물을 클릭했을 때. 실제로 얻은 양을 돌려준다(연출용). +func click() -> float: + clicks += 1 + _earn(click_power) + return click_power + + +# --- 격자 --- + +func columns() -> int: + return Catalog.total_columns() + + +func rows() -> int: + return Catalog.GRID_ROWS + + +func in_grid(col: int, row: int) -> bool: + return col >= 0 and row >= 0 and col < columns() and row < rows() + + +func _cell(col: int, row: int) -> int: + return row * columns() + col + + +## 그 칸에 놓인 것의 placements 인덱스. 비었으면 -1. +func placement_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) + var cells: Array[Vector2i] = [] + for dx in s.x: + for dy in s.y: + if not in_grid(col + dx, row + dy): + return [] + cells.append(Vector2i(col + dx, row + dy)) + 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) + if not is_zone_unlocked(item_zone): + return false + var cells := footprint(kind, 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) + 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: + free += 1 + return free + + +## 그 아이템을 놓을 수 있는 첫 칸. 없으면 null 대신 Vector2i(-1, -1). +func first_free_spot(kind: String, id: String) -> Vector2i: + var z := Catalog.zone_of(kind, 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): + return Vector2i(col, row) + return Vector2i(-1, -1) + + +# --- 조회 --- + +func count_of(id: String) -> int: + return _counts.get(id, 0) + + +func has_ornament(id: String) -> bool: + return count_of(id) > 0 + + +func has_upgrade(id: String) -> bool: + return upgrades.has(id) + + +func producer_cost(id: String) -> float: + var d := Catalog.producer(id) + if d.is_empty(): + return INF + return ceil(float(d.base_cost) * pow(Catalog.COST_GROWTH, count_of(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 owned(kind: String, id: String) -> bool: + match kind: + "ornament": return has_ornament(id) + "upgrade": return has_upgrade(id) + return false + + +func is_zone_unlocked(index: int) -> bool: + return index < unlocked_zones + + +## 상점에 노출할지 여부. +## 아직 열리지 않은 구역의 물건은 숨기고, 열린 구역 안에서는 +## 가격의 절반까지 벌어본 적이 있으면 보여준다(점진 공개). +func is_revealed(kind: String, id: String) -> bool: + if not is_zone_unlocked(Catalog.zone_of(kind, id)): + return false + # 구역을 막 열었을 때 목록이 비어 보이지 않도록 그 구역의 첫 항목은 항상 보인다. + 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): + 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): + return -1 + if not can_place_at(kind, id, col, row): + return -1 + var cost := cost_of(kind, id) + if not is_finite(cost) or bubbles < cost: + return -1 + + bubbles -= cost + placements.append({"kind": kind, "id": id, "col": col, "row": row}) + recalc() + placed.emit(placements.size() - 1) + state_changed.emit() + return placements.size() - 1 + + +## 이미 놓인 것을 다른 칸으로 옮긴다. 공짜다. +func move(index: int, col: int, row: int) -> bool: + if index < 0 or index >= placements.size(): + return false + var p := placements[index] + if not can_place_at(p.kind, p.id, col, row, index): + return false + p.col = col + p.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) + + placements.remove_at(index) + bubbles += refund + recalc() + reloaded.emit() + state_changed.emit() + return refund + + +func buy_upgrade(id: String) -> bool: + if has_upgrade(id): + return false + var cost := cost_of("upgrade", id) + if not is_finite(cost) or bubbles < cost: + return false + bubbles -= cost + upgrades[id] = true + recalc() + state_changed.emit() + return true + + +# --- 구역 해금 --- + +func next_zone_index() -> int: + return unlocked_zones + + +func has_next_zone() -> bool: + return unlocked_zones < Catalog.zone_count() + + +func next_zone_cost() -> float: + if not has_next_zone(): + return INF + return float(Catalog.zone(next_zone_index()).unlock_cost) + + +## 다음 구역을 열려면 바로 앞 구역에 이만큼의 아름다움이 있어야 한다. +func next_zone_required_beauty() -> float: + if not has_next_zone(): + return 0.0 + return float(Catalog.zone(next_zone_index()).require_beauty) + + +func beauty_in_zone(index: int) -> float: + if index < 0 or index >= zone_beauty.size(): + return 0.0 + return zone_beauty[index] + + +## 다음 구역을 열 수 없다면 그 이유를, 열 수 있다면 빈 문자열을 돌려준다. +func next_zone_blocker() -> String: + if not has_next_zone(): + return "마지막 구역까지 모두 열었어요" + var need_beauty := next_zone_required_beauty() + var have := beauty_in_zone(next_zone_index() - 1) + if have < need_beauty: + return "%s 더 꾸며야 해요 (아름다움 %s / %s)" % [ + Korean.eul(String(Catalog.zone(next_zone_index() - 1).name)), + NumberFormat.short(have), NumberFormat.short(need_beauty), + ] + if bubbles < next_zone_cost(): + return "거품이 모자라요 (%s / %s)" % [ + NumberFormat.short(bubbles), NumberFormat.short(next_zone_cost()), + ] + return "" + + +func can_unlock_next() -> bool: + return next_zone_blocker().is_empty() + + +func unlock_next_zone() -> bool: + if not can_unlock_next(): + return false + bubbles -= next_zone_cost() + var opened := unlocked_zones + unlocked_zones += 1 + recalc() + zone_unlocked.emit(opened) + state_changed.emit() + return true + + +# --- 파생 값 재계산 --- + +func recalc() -> void: + _counts.clear() + _occupancy.resize(columns() * rows()) + _occupancy.fill(-1) + + zone_beauty.clear() + zone_beauty.resize(Catalog.zone_count()) + + 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) + 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): + _occupancy[_cell(c.x, c.y)] = i + + _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) + + 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) + 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) + + beauty = 0.0 + for b in zone_beauty: + beauty += b + + # 꾸밀수록 생산이 빨라지도록. 제곱근이라 초반 보상이 크고 후반은 완만하다. + 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 + + +func _add_zone_beauty(index: int, amount: float) -> void: + if index >= 0 and index < zone_beauty.size(): + zone_beauty[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}) + return { + "bubbles": bubbles, + "total_bubbles": total_bubbles, + "clicks": clicks, + "placements": rows_out, + "upgrades": upgrades.duplicate(), + "unlocked_zones": unlocked_zones, + "first_played_unix": first_played_unix, + } + + +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())) + + upgrades.clear() + for id in d.get("upgrades", {}): + upgrades[id] = true + + placements.clear() + recalc() + if d.has("placements"): + for raw in d["placements"]: + _restore(String(raw.get("kind", "producer")), String(raw.get("id", "")), + int(raw.get("col", 0)), int(raw.get("row", 0))) + else: + _migrate_from_counts(d) + + recalc() + reloaded.emit() + state_changed.emit() + + +## 저장된 칸이 비어 있으면 그대로, 겹치거나 규칙에 어긋나면 빈 칸을 찾아 옮겨 놓는다. +## 격자 크기나 구역 구성이 바뀌어도 진행도를 잃지 않기 위한 것. +func _restore(kind: String, id: String, col: int, row: int) -> void: + if Catalog.item(kind, id).is_empty(): + return + if not can_place_at(kind, id, col, row): + var spot := first_free_spot(kind, id) + if spot.x < 0: + return + col = spot.x + row = spot.y + placements.append({"kind": kind, "id": id, "col": col, "row": row}) + recalc() + + +## 격자가 없던 시절의 저장본(개수만 있음)을 빈 칸에 차례로 놓아 되살린다. +func _migrate_from_counts(d: Dictionary) -> void: + 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) + for id in d.get("ornaments", {}): + var spot := first_free_spot("ornament", String(id)) + if spot.x >= 0: + _restore("ornament", String(id), spot.x, spot.y) + + +func reset() -> void: + bubbles = 0.0 + total_bubbles = 0.0 + clicks = 0 + placements.clear() + upgrades.clear() + unlocked_zones = 1 + first_played_unix = int(Time.get_unix_time_from_system()) + recalc() + reloaded.emit() + state_changed.emit() diff --git a/autoload/game_state.gd.uid b/autoload/game_state.gd.uid new file mode 100644 index 0000000..1e62443 --- /dev/null +++ b/autoload/game_state.gd.uid @@ -0,0 +1 @@ +uid://bs7dqgqr66wdt diff --git a/autoload/save_manager.gd b/autoload/save_manager.gd new file mode 100644 index 0000000..c81cdd7 --- /dev/null +++ b/autoload/save_manager.gd @@ -0,0 +1,135 @@ +extends Node +## 저장/불러오기, 자동 저장, 오프라인 보상, 창 상태 보존. +## 저장 파일은 user:// 아래에 있다 (Windows: %APPDATA%\Godot\app_userdata\Blub Blub\). + +signal offline_earned(amount: float, seconds: int) +signal saved + +const SAVE_PATH := "user://save.json" +const BACKUP_PATH := "user://save.bak.json" +const SAVE_VERSION := 1 + +const AUTOSAVE_INTERVAL := 30.0 +## 자리를 비운 동안 최대 8시간까지만, 그것도 절반 효율로 쳐준다. +const OFFLINE_CAP_SECONDS := 8 * 3600 +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, +} + +## 테스트처럼 실제 저장 파일을 건드리면 안 되는 상황에서 끈다. +## 끄지 않으면 스모크 테스트가 종료될 때 초기화된 상태를 저장해 진행도를 날린다. +var persistence_enabled := true + +var _autosave_timer := 0.0 +var _saved_on_exit := false + + +func _ready() -> void: + if persistence_enabled: + load_game() + + +func _process(delta: float) -> void: + if not persistence_enabled: + return + _autosave_timer += delta + if _autosave_timer >= AUTOSAVE_INTERVAL: + _autosave_timer = 0.0 + save_game() + + +func _notification(what: int) -> void: + if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_PREDELETE: + if persistence_enabled and not _saved_on_exit: + _saved_on_exit = true + save_game() + + +func save_game() -> void: + if not persistence_enabled: + return + var payload := { + "version": SAVE_VERSION, + "saved_at": int(Time.get_unix_time_from_system()), + "state": GameState.to_dict(), + "window": window_state, + } + + # 기존 저장본을 백업해두고 덮어쓴다. 쓰다가 죽어도 한 세대는 남는다. + if FileAccess.file_exists(SAVE_PATH): + var old := FileAccess.get_file_as_string(SAVE_PATH) + if not old.is_empty(): + var bak := FileAccess.open(BACKUP_PATH, FileAccess.WRITE) + if bak: + bak.store_string(old) + bak.close() + + var f := FileAccess.open(SAVE_PATH, FileAccess.WRITE) + if f == null: + push_error("저장 실패: %s" % error_string(FileAccess.get_open_error())) + return + f.store_string(JSON.stringify(payload, "\t")) + f.close() + saved.emit() + + +func load_game() -> void: + var data := _read(SAVE_PATH) + if data.is_empty(): + data = _read(BACKUP_PATH) + if data.is_empty(): + 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] + + _grant_offline(int(data.get("saved_at", 0))) + + +func _read(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {} + var text := FileAccess.get_file_as_string(path) + if text.is_empty(): + return {} + var parsed: Variant = JSON.parse_string(text) + if typeof(parsed) != TYPE_DICTIONARY: + push_warning("저장 파일을 읽을 수 없습니다: %s" % path) + return {} + return parsed + + +## 마지막 저장 이후 흐른 시간만큼 생산량을 되돌려준다. +func _grant_offline(saved_at: int) -> void: + if saved_at <= 0 or GameState.bps <= 0.0: + return + var now := int(Time.get_unix_time_from_system()) + var elapsed := now - saved_at + if elapsed < OFFLINE_MIN_SECONDS: + return + var counted: int = mini(elapsed, OFFLINE_CAP_SECONDS) + var amount := GameState.bps * counted * OFFLINE_RATE + if amount <= 0.0: + return + GameState.bubbles += amount + GameState.total_bubbles += amount + offline_earned.emit(amount, elapsed) + + +func wipe() -> void: + for path in [SAVE_PATH, BACKUP_PATH]: + if FileAccess.file_exists(path): + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + GameState.reset() diff --git a/autoload/save_manager.gd.uid b/autoload/save_manager.gd.uid new file mode 100644 index 0000000..a6b6557 --- /dev/null +++ b/autoload/save_manager.gd.uid @@ -0,0 +1 @@ +uid://d0hhbrpalxgbg diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..9ef5f19 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,141 @@ +# Blub Blub 설계 메모 + +## 한 줄 요약 + +화면 아래 가장자리에 깔려 조용히 돌아가는 바닷속 스트립. +다른 창을 쓰는 동안 시선의 가장자리에 있고, 꾸미면 예뻐지고, 예뻐지면 더 빨리 자란다. +러스티의 농장/몬스터 빅팜 같은 "일하면서 곁눈질하는" 형태를 따른다. + +## 핵심 설계 판단 + +**칸이 곧 한계다.** 클리커 장르는 보통 한 종류를 무한히 사서 가격 곡선으로만 +속도를 조절한다. 그러면 배치는 장식이 되고, 꾸미기는 성장과 무관한 곁가지가 된다. +여기서는 격자 칸이 보유 개수를 직접 제한한다. "무엇을 어디에 놓을까"가 +매 순간 진짜 고민이 되고, 자리를 늘리는 유일한 길이 다음 구역을 여는 것이라 +꾸미기·성장·진행이 한 줄로 이어진다. + +**직교 격자 + 3/4 부감 시점을 쓴다.** 아이소메트릭은 가로·세로 두 축이 모두 화면 +높이를 먹기 때문에(높이 = (열+행) × 타일높이 / 2) 220px 스트립에서는 격자가 +10×3으로 쪼그라든다. 직교 격자는 세로가 행 수만큼만 필요해서 45×6 = 270칸이 나온다. + +그 위에 **바닥은 위에서, 물체는 옆에서** 그리는 고전적인 2D 관례를 얹었다. +러스티의 농장·스타듀밸리가 쓰는 방식이다. 완전한 탑다운은 물고기를 등에서만 보게 되고 +구조물이 납작한 얼룩으로 보여서, 무엇인지 알아볼 수가 없었다. + +구현은 단순하다. 노드의 원점을 **발자국의 아래 가운데**(물체가 바닥에 닿는 지점)에 두고 +그림을 위(-Y)로 그린다. 그러면 Y 정렬만으로 앞줄이 뒷줄을 가리는 순서가 저절로 맞는다. +`height`(칸 높이의 배수)가 1을 넘으면 뒷줄을 가리므로, 이 값이 곧 배치의 무게가 된다. + +**구역은 왼쪽에서 오른쪽으로 깊어진다.** 스트립이 가로로 길다는 형태를 그대로 진행 +방향으로 썼다. 왼쪽 끝이 수면에 가까운 얕은 바다, 오른쪽 끝이 열수구다. +한 화면에 지나온 길과 앞으로 갈 길이 같이 보이기 때문에, 위젯을 흘깃 볼 때마다 +"어디까지 왔는지"가 설명 없이 읽힌다. + +**다음 구역을 열려면 앞 구역을 꾸며야 한다.** 해금 조건을 거품만으로 두면 +플레이어는 앞 구역을 최소한만 건드리고 계속 오른쪽으로 달린다. 앞 구역의 +아름다움을 조건으로 걸어서, 진도를 빼려면 반드시 지금 있는 바다를 가꾸게 했다. + +**꾸미기와 성장을 하나로 묶었다.** 방치형 게임에서 꾸미기는 보통 "번 돈을 쓰는 곳"에 +그치고, 그러면 꾸미기가 성장의 경쟁 상대가 되어 아무도 꾸미지 않는다. +그래서 모든 구매물이 **아름다움(beauty)** 수치를 가지고, 아름다움이 다시 전체 생산량에 +곱해지도록 했다. 꾸미는 것과 최적으로 플레이하는 것이 같은 방향을 가리킨다. + +``` +아름다움 배율 = 1 + sqrt(아름다움) × 0.02 +``` + +제곱근이라 초반에는 한 개 사는 것이 체감되고(아름다움 100 → 1.20배), +후반에는 완만해져서(10,000 → 3.0배) 인플레이션을 막는다. + +## 수치 + +| 항목 | 값 | 위치 | +| --- | --- | --- | +| 생물 가격 성장률 | ×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` | +| 구역 해금 비용 | 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` | +| 스트립 높이 | 160 / 220 / 300px | `Main.HEIGHTS` | + +생물 10종·장식 10종을 구역당 2+2로 나눠 두었다. 한 구역을 70%쯤 채우면 +다음 구역 해금 비용이 대략 20~90분치 생산량이 되도록 잡아 두었지만, +칸 제한이 들어오면서 곡선이 크게 달라졌으므로 실제로 플레이해 보며 다시 맞춰야 한다. +현재 밸런스는 첫 몇 시간을 촘촘하게 만드는 데 맞춰져 있고, 실제로 플레이해 보며 +`Catalog`의 `base_cost` / `bps` / `beauty` 세 숫자만 조정하면 된다. + +## 데이터 추가하는 법 + +`autoload/catalog.gd`의 배열에 딕셔너리 한 줄을 넣으면 상점 목록, 수조 렌더링, +저장 형식이 전부 따라온다. 다른 파일은 건드릴 필요가 없다. + +```gdscript +{ + "id": "shrimp", "name": "청소새우", "shape": "fish", + "desc": "바닥을 훑고 다닌다.", + "color": Color(0.9, 0.4, 0.4), "base_cost": 400.0, "bps": 3.0, "beauty": 5.0, +}, +``` + +`shape`은 `plant` / `rock`(바닥 고정) / `fish` / `glow`(제자리 위를 떠다님) 중 하나이고, +`scenes/aquarium/tile_item.gd`가 형태별로 서 있는 모습을 직접 그린다. +`size`는 바닥에서 차지하는 칸(기본 1×1), `height`는 칸 높이의 몇 배로 서는지다. + +물빛 오버레이가 덮이면 형태가 바닥에 묻히므로, 모든 면에 어두운 테두리를 두른다 +(`_fill()`). 테두리를 빼면 무엇인지 읽히지 않는다. + +## 아트 + +지금은 스프라이트 없이 `_draw()`로 도형을 그린다. 색과 형태만으로 구분되는 자리 표시용이다. +실제 아트가 생기면 `assets/art/`에 넣고 `tile_item.gd`를 +`Sprite2D` 기반으로 바꾸면 된다(카탈로그에 `texture` 항목 추가). + +바닥은 `seabed.gd`가 칸마다 그리고, 그 위에 `water.gdshader`가 물빛과 +일렁이는 빛 그물을 덮는다. 깊이감은 이 오버레이의 `murk`가 만든다. + +## 창 동작 (Windows) + +- 투명·테두리 없음: `project.godot`의 `per_pixel_transparency/allowed` + `size/transparent` +- 도킹: `screen_get_usable_rect()`로 작업표시줄을 뺀 영역을 구해 폭 전체로 늘리고 + 위/아래 가장자리에 붙인다. 창을 끌어 옮기는 기능은 없다 — 위치는 계산되는 값이다. +- 스트레치 모드는 `disabled`. 화면 폭이 얼마든 1:1 픽셀로 그려야 글씨가 흐려지지 않는다. +- 셰이더는 화면에 닿는 쪽 모서리를 각지게, 바깥쪽만 둥글게 깎는다. + 바탕화면에서 자라난 것처럼 보이게 하려는 것. +- 클릭 통과: `DisplayServer.window_set_mouse_passthrough()`. + 빈 배열이면 통과 해제, 폴리곤을 주면 **그 안쪽만** 입력을 받는다. + 통과를 켜도 오른쪽 아래 모서리를 입력 영역으로 남겨 두어 되돌아올 길을 만든다. + +## 아직 안 만든 것 (우선순위 순) + +1. **꾸미기 배치 자유도** — 지금은 산 물건이 자기 구역 안 랜덤 위치에 놓인다. + 드래그로 옮기고 위치를 저장하면 "내 바다"라는 느낌이 훨씬 강해진다. + `DecorSprite`에 위치를 저장하고 `GameState.producers`를 개수 대신 + 인스턴스 배열로 바꾸는 작업이 필요하다. +2. **두 번째 화폐(진주)** — 아름다움에 비례해 아주 천천히 쌓이고, + 특별한 장식만 살 수 있게. 거품 인플레이션과 분리된 장기 목표가 생긴다. +3. **환생/이사** — 더 큰 수조로 옮기면서 영구 배율을 얻는 장르 표준 루프. +4. **소리** — 물소리와 거품 소리. 위젯이라 기본은 음소거가 맞을 듯. +5. **트레이 아이콘** — Godot 기본 기능에 없어서 GDExtension이나 별도 런처가 필요하다. + 당장은 작업표시줄 창으로 충분. +6. **폭을 화면 일부만 쓰기** — 지금은 항상 폭 전체다. 왼쪽/가운데/오른쪽 정렬과 + 폭 비율 선택이 있으면 넓은 모니터에서 덜 부담스럽다. + +## 한글 문장 + +구역 이름을 문장에 끼워 넣는 곳이 여럿이라 조사가 어긋나기 쉽다 +("켈프 숲를 더 꾸며야 해요"). `scripts/korean.gd`가 마지막 글자의 받침을 보고 +을/를·이/가·은/는을 골라준다. 새 문장을 쓸 때도 이걸 거쳐야 한다. + +## 저장 파일 보호 + +`SaveManager.persistence_enabled`를 끄면 저장/자동저장/종료 시 저장이 모두 멎는다. +스모크 테스트가 이걸 끈다. 끄지 않으면 테스트가 끝나면서 초기화된 상태를 +실제 저장 파일에 덮어써 진행도가 날아간다. diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..ef5c681 --- /dev/null +++ b/icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..3d21dd8 --- /dev/null +++ b/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dn0g2lm4ng2p3" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..01b239c --- /dev/null +++ b/project.godot @@ -0,0 +1,41 @@ +; Blub Blub — 바탕화면 바닷속 방치형 위젯 +; Godot 4.7 / GDScript / GL Compatibility 렌더러 + +config_version=5 + +[application] + +config/name="Blub Blub" +config/description="바탕화면 위에서 자라나는 바닷속 방치형 위젯" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") +config/icon="res://icon.svg" +run/max_fps=30 + +[autoload] + +Catalog="*res://autoload/catalog.gd" +GameState="*res://autoload/game_state.gd" +SaveManager="*res://autoload/save_manager.gd" + +[display] + +window/size/viewport_width=1280 +window/size/viewport_height=220 +window/size/resizable=false +window/size/borderless=true +window/size/transparent=true +window/size/always_on_top=true +window/per_pixel_transparency/allowed=true +window/stretch/mode="disabled" + +[gui] + +; Godot 기본 폰트에는 한글이 없다. 시스템 폰트를 기본 테마 폰트로 쓴다. +theme/custom_font="res://assets/ui_font.tres" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" +environment/defaults/default_clear_color=Color(0, 0, 0, 0) diff --git a/run.ps1 b/run.ps1 new file mode 100644 index 0000000..ff27047 --- /dev/null +++ b/run.ps1 @@ -0,0 +1,7 @@ +# 에디터 없이 게임만 바로 실행합니다. 사용: 우클릭 > PowerShell로 실행, 또는 .\run.ps1 +$godot = "$env:LOCALAPPDATA\Programs\Godot\Godot_v4.7.2-stable_win64.exe" +if (-not (Test-Path $godot)) { + Write-Error "Godot을 찾을 수 없습니다: $godot" + exit 1 +} +Start-Process -FilePath $godot -ArgumentList @("--path", $PSScriptRoot) -WorkingDirectory $PSScriptRoot diff --git a/scenes/aquarium/aquarium.gd b/scenes/aquarium/aquarium.gd new file mode 100644 index 0000000..4d39f3a --- /dev/null +++ b/scenes/aquarium/aquarium.gd @@ -0,0 +1,525 @@ +extends Control +class_name Aquarium +## 화면 하단에 깔리는 탑다운 바다. +## 가로를 구역 수만큼 나눠, 왼쪽(얕은 바다)에서 오른쪽(열수구)으로 갈수록 깊어진다. +## 구역마다 격자가 있고, 산 것은 플레이어가 고른 칸에 놓인다. + +## 알림을 띄워야 할 때 (해금 실패, 배치 실패, 환불 등). main이 받아 HUD로 넘긴다. +signal message(text: String) +## 빈 곳을 눌렀을 때. 거품 클릭 연출에 쓴다. +signal clicked(local_position: Vector2) +## 배치 모드가 켜지거나 꺼졌을 때. 상점이 선택 상태를 표시하는 데 쓴다. +signal placement_changed(active: bool, kind: String, id: String) +## 이미 놓인 것을 집었거나 놓았을 때. HUD가 휴지통을 보였다 감추는 데 쓴다. +signal drag_changed(dragging: bool) +## 물 위에서 우클릭. main이 메뉴를 연다. +signal context_menu_requested + +## 드래그한 것을 여기에 떨구면 치운다. 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 + +## 위쪽 이만큼은 정보 바가 앉는 자리라 격자에서 뺀다. +## 여기를 비워두지 않으면 첫 줄 칸이 바에 가려 보이지도, 눌리지도 않는다. +const GRID_TOP := 30.0 +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 := "" +var _hover := Vector2i(-1, -1) +var _press_position := Vector2.ZERO +var _press_index := -1 +var _drag_index := -1 + + +func _ready() -> void: + _rng.randomize() + _base_material = preload("res://assets/shaders/water_material.tres") + items.y_sort_enabled = true + ghost.hide() + _setup_bubbles() + + resized.connect(_on_layout_changed) + GameState.zone_unlocked.connect(_on_zone_unlocked) + GameState.reloaded.connect(_rebuild_items) + GameState.placed.connect(_on_placed) + + _on_layout_changed() + + +func _process(_delta: float) -> void: + # 잠긴 구역의 안내 문구는 거품이 쌓이면서 계속 바뀐다. + for index in _lock_reasons: + if index == GameState.next_zone_index(): + _lock_reasons[index].text = GameState.next_zone_blocker() + + +# --- 격자 좌표 --- + +## 격자가 실제로 깔리는 영역. 위쪽 정보 바 자리는 뺀다. +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()) + + +func cell_at(pos: Vector2) -> Vector2i: + var c := cell_size() + return Vector2i(int(floor(pos.x / c.x)), int(floor((pos.y - GRID_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(c.x * span.x, c.y * span.y)) + + +func zone_rect(index: int) -> Rect2: + var c := cell_size() + return Rect2( + Vector2(index * Catalog.ZONE_COLUMNS * c.x, 0.0), + 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) + return hover - Vector2i((s.x - 1) / 2, (s.y - 1) / 2) + + +# --- 화면 구성 --- + +## 크기가 바뀌거나 구역이 열리면 전부 다시 만든다. +## 스트립은 자주 바뀌지 않으므로 부분 갱신보다 통째로 다시 만드는 쪽이 단순하다. +func _on_layout_changed() -> void: + ground.rect_size = size + ground.grid_top = GRID_TOP + 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) + + +func _build_water() -> void: + for child in water.get_children(): + child.queue_free() + var n := Catalog.zone_count() + for i in n: + var r := zone_rect(i) + var rect := ColorRect.new() + rect.position = r.position + rect.size = r.size + rect.mouse_filter = Control.MOUSE_FILTER_IGNORE + var mat: ShaderMaterial = _base_material.duplicate() + # 경계 값을 이웃과 공유해서 이음매가 보이지 않게 한다. + mat.set_shader_parameter("tint_left", Catalog.ramp_color("tint", float(i) / n)) + 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("x_offset", r.position.x) + mat.set_shader_parameter("rect_size", r.size) + rect.material = mat + water.add_child(rect) + + +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 _build_zone_labels() -> void: + for child in zone_labels.get_children(): + child.queue_free() + for i in Catalog.zone_count(): + if not GameState.is_zone_unlocked(i): + continue + var r := zone_rect(i) + var l := Label.new() + l.text = Catalog.zone(i).name + # 바닥 오른쪽 아래 구석에 새겨 넣는다. + l.size = Vector2(r.size.x - 10.0, 16.0) + l.position = Vector2(r.position.x, r.size.y - 19.0) + l.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + l.mouse_filter = Control.MOUSE_FILTER_IGNORE + l.add_theme_font_size_override("font_size", 10) + # 바닥에 새긴 것처럼. 밝은 모래 위에서도 어두운 물속에서도 읽히려면 + # 흰색 반투명이 아니라 그 자리 모래색을 어둡게 쓴 편이 낫다. + var center := (float(i) + 0.5) / Catalog.zone_count() + l.add_theme_color_override("font_color", + Catalog.ramp_color("sand", center).darkened(0.45)) + zone_labels.add_child(l) + + +func _build_locks() -> void: + for child in locks.get_children(): + child.queue_free() + _lock_reasons.clear() + for i in Catalog.zone_count(): + if not GameState.is_zone_unlocked(i): + locks.add_child(_make_lock(i)) + + +func _make_lock(index: int) -> Button: + var r := zone_rect(index) + var z := Catalog.zone(index) + var is_next := index == GameState.next_zone_index() + + var veil := Button.new() + veil.position = r.position + veil.size = r.size + veil.add_theme_stylebox_override("normal", UIStyle.veil(0.70)) + veil.add_theme_stylebox_override("hover", UIStyle.veil(0.60 if is_next else 0.70)) + veil.add_theme_stylebox_override("pressed", UIStyle.veil(0.54 if is_next else 0.70)) + veil.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) + veil.pressed.connect(_on_lock_pressed.bind(index)) + + var box := VBoxContainer.new() + box.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + box.alignment = BoxContainer.ALIGNMENT_CENTER + box.mouse_filter = Control.MOUSE_FILTER_IGNORE + box.add_theme_constant_override("separation", 3) + veil.add_child(box) + + var name_label := Label.new() + name_label.text = z.name if is_next else "???" + name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + UIStyle.label(name_label, 15, Color(1, 1, 1, 0.75 if is_next else 0.35)) + box.add_child(name_label) + + if not is_next: + # 다음 차례가 아닌 구역은 무엇이 있는지도 알려주지 않는다. + return veil + + var cost_label := Label.new() + cost_label.text = "해금 %s" % NumberFormat.short(float(z.unlock_cost)) + cost_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + cost_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + UIStyle.label(cost_label, 13, UIStyle.GOLD) + box.add_child(cost_label) + + var reason := Label.new() + reason.text = GameState.next_zone_blocker() + reason.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + reason.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + reason.custom_minimum_size = Vector2(r.size.x - 24.0, 0.0) + reason.mouse_filter = Control.MOUSE_FILTER_IGNORE + UIStyle.label(reason, 11, UIStyle.INK_DIM) + box.add_child(reason) + _lock_reasons[index] = reason + + return veil + + +func _on_lock_pressed(index: int) -> void: + if index != GameState.next_zone_index(): + message.emit("%s부터 열어야 해요" % Catalog.zone(GameState.next_zone_index()).name) + return + var blocker := GameState.next_zone_blocker() + if not blocker.is_empty(): + message.emit(blocker) + return + GameState.unlock_next_zone() + + +func _on_zone_unlocked(index: int) -> void: + message.emit("%s 열렸어요" % Korean.i(String(Catalog.zone(index).name))) + _on_layout_changed() + + +# --- 놓인 것들 --- + +func _setup_bubbles() -> void: + bubbles.emitting = true + bubbles.lifetime = 3.5 + bubbles.emission_shape = CPUParticles2D.EMISSION_SHAPE_RECTANGLE + bubbles.gravity = Vector2.ZERO + bubbles.initial_velocity_min = 0.0 + bubbles.initial_velocity_max = 5.0 + # 위에서 내려다보므로 거품은 위로 흐르는 대신 제자리에서 부풀었다 사라진다. + bubbles.scale_amount_min = 0.4 + bubbles.scale_amount_max = 1.6 + bubbles.color = Color(1, 1, 1, 0.20) + + +func _rebuild_items() -> void: + for child in items.get_children(): + child.queue_free() + for i in GameState.placements.size(): + _make_item(i) + + +func _make_item(index: int) -> TileItem: + var p := GameState.placements[index] + var d := Catalog.item(p.kind, p.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) + return node + + +func _on_placed(index: int) -> void: + var node := _make_item(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 + var best_y := -INF + for child in items.get_children(): + var it := child as TileItem + if it == null: + continue + var r := it.visual_rect() + r.position += it.position + if r.has_point(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) + + +func _item_node(index: int) -> TileItem: + for child in items.get_children(): + var it := child as TileItem + if it and it.placement_index == index: + return it + return null + + +# --- 배치 모드 --- + +func begin_placement(kind: String, id: String) -> void: + if kind == "upgrade": + return + _cancel_drag() + _placing_kind = kind + _placing_id = id + placement_changed.emit(true, kind, id) + _update_ghost() + + +func cancel_placement() -> void: + if _placing_id.is_empty(): + return + _placing_kind = "" + _placing_id = "" + ghost.hide() + placement_changed.emit(false, "", "") + + +func is_placing() -> bool: + return not _placing_id.is_empty() + + +func _update_ghost() -> void: + if not is_placing() 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) + 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) + + if GameState.bubbles < GameState.cost_of(kind, id): + message.emit("거품이 모자라요") + cancel_placement() + return + if not GameState.can_place_at(kind, id, anchor.x, anchor.y): + message.emit(_why_not(kind, id, anchor)) + return + + GameState.place(kind, id, anchor.x, anchor.y) + # 계속 놓을 수 있으면 모드를 유지한다. 같은 걸 여러 개 깔 때 편하다. + if kind == "ornament" or GameState.bubbles < GameState.cost_of(kind, id): + cancel_placement() + else: + _update_ghost() + + +func _why_not(kind: String, id: String, anchor: Vector2i) -> String: + var item_zone := Catalog.zone_of(kind, 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) + 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 "이미 무언가 놓여 있어요" + + +# --- 입력 --- + +func _gui_input(event: InputEvent) -> void: + if event is InputEventMouseMotion: + _hover = cell_at(event.position) + if _drag_index >= 0: + _update_drag(event.position) + elif is_placing(): + _update_ghost() + elif _press_index >= 0 and event.position.distance_to(_press_position) > DRAG_THRESHOLD: + _begin_drag() + return + + if event is not InputEventMouseButton: + return + + if event.button_index == MOUSE_BUTTON_RIGHT and event.pressed: + if is_placing(): + cancel_placement() + elif _drag_index >= 0: + _cancel_drag() + else: + context_menu_requested.emit() + return + + if event.button_index != MOUSE_BUTTON_LEFT: + return + + if event.pressed: + _hover = cell_at(event.position) + if is_placing(): + _try_place() + return + _press_position = event.position + _press_index = _pick_at(event.position) + else: + if _drag_index >= 0: + _drop(event.position) + elif _press_index != PRESS_WAS_DRAG: + # 끌지 않고 뗐으면 그냥 물을 클릭한 것으로 친다. + clicked.emit(event.position) + _press_index = -1 + + +func _begin_drag() -> void: + if _press_index < 0: + return + _drag_index = _press_index + _press_index = PRESS_WAS_DRAG + drag_changed.emit(true) + + +func _update_drag(pos: Vector2) -> void: + var node := _item_node(_drag_index) + if node: + node.position = pos + + if trash_rect.has_point(pos): + 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) + + +func _drop(pos: Vector2) -> void: + var index := _drag_index + _drag_index = -1 + ghost.hide() + drag_changed.emit(false) + + if index < 0 or index >= GameState.placements.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)]) + return + + 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 _cancel_drag() -> void: + if _drag_index < 0: + return + _drag_index = -1 + ghost.hide() + drag_changed.emit(false) + _rebuild_items() + + +## 클릭 지점에서 거품이 터지는 연출. +func pop_at(pos: Vector2) -> void: + var burst := CPUParticles2D.new() + add_child(burst) + burst.position = pos + burst.emitting = true + burst.one_shot = true + burst.explosiveness = 1.0 + burst.amount = 10 + burst.lifetime = 0.6 + burst.gravity = Vector2.ZERO + burst.initial_velocity_min = 40.0 + burst.initial_velocity_max = 110.0 + burst.scale_amount_min = 0.5 + burst.scale_amount_max = 1.3 + burst.color = Color(1, 1, 1, 0.55) + get_tree().create_timer(1.0).timeout.connect(burst.queue_free) diff --git a/scenes/aquarium/aquarium.gd.uid b/scenes/aquarium/aquarium.gd.uid new file mode 100644 index 0000000..c2a9896 --- /dev/null +++ b/scenes/aquarium/aquarium.gd.uid @@ -0,0 +1 @@ +uid://ocdjqcs67mr8 diff --git a/scenes/aquarium/aquarium.tscn b/scenes/aquarium/aquarium.tscn new file mode 100644 index 0000000..2be14f2 --- /dev/null +++ b/scenes/aquarium/aquarium.tscn @@ -0,0 +1,61 @@ +[gd_scene load_steps=4 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"] + +[node name="Aquarium" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +clip_contents = true +script = ExtResource("1_aq") + +[node name="Ground" type="Node2D" parent="."] +script = ExtResource("2_ground") + +[node name="Items" type="Node2D" parent="."] + +[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="Bubbles" type="CPUParticles2D" parent="."] + +[node name="Ghost" type="Node2D" parent="."] +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="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 diff --git a/scenes/aquarium/grid_ghost.gd b/scenes/aquarium/grid_ghost.gd new file mode 100644 index 0000000..d316625 --- /dev/null +++ b/scenes/aquarium/grid_ghost.gd @@ -0,0 +1,26 @@ +extends Node2D +class_name GridGhost +## 배치·이동 중에 "여기에 놓입니다"를 보여주는 미리보기 사각형. + +const OK_FILL := Color(0.45, 1.0, 0.70, 0.22) +const OK_LINE := Color(0.55, 1.0, 0.78, 0.85) +const BAD_FILL := Color(1.0, 0.35, 0.35, 0.20) +const BAD_LINE := Color(1.0, 0.45, 0.45, 0.80) + +var area := Rect2() +var valid := true + + +func show_at(rect: Rect2, is_valid: bool) -> void: + area = rect + valid = is_valid + visible = true + queue_redraw() + + +func _draw() -> void: + if area.size.x <= 0.0: + return + var inset := area.grow(-2.0) + draw_rect(inset, BAD_FILL if not valid else OK_FILL, true) + draw_rect(inset, BAD_LINE if not valid else OK_LINE, false, 2.0) diff --git a/scenes/aquarium/grid_ghost.gd.uid b/scenes/aquarium/grid_ghost.gd.uid new file mode 100644 index 0000000..f869528 --- /dev/null +++ b/scenes/aquarium/grid_ghost.gd.uid @@ -0,0 +1 @@ +uid://x5tqoap2rmxw diff --git a/scenes/aquarium/seabed.gd b/scenes/aquarium/seabed.gd new file mode 100644 index 0000000..0cf230f --- /dev/null +++ b/scenes/aquarium/seabed.gd @@ -0,0 +1,52 @@ +extends Node2D +class_name Seabed +## 탑다운 바다의 바닥. 격자 칸마다 그 자리 구역의 모래색을 조금씩 달리해 깔고, +## 그 위에 아주 옅은 격자선을 얹어 "어디에 놓을 수 있는지"가 보이게 한다. + +var rect_size := Vector2(1280, 220) +## 위쪽 정보 바 자리. 여기에는 격자를 긋지 않는다. +var grid_top := 30.0 + +const GRID_LINE := Color(0, 0, 0, 0.13) +const SPECK := Color(0, 0, 0, 0.06) + + +## 칸마다 늘 같은 값을 주는 해시. 매번 다른 무늬가 나오면 재배치할 때마다 바닥이 깜빡인다. +func _hash(col: int, row: int, salt: int) -> float: + var h := (col * 73856093) ^ (row * 19349663) ^ (salt * 83492791) + return float(absi(h) % 1000) / 1000.0 + + +func _draw() -> void: + var cols := GameState.columns() + var rows := GameState.rows() + if rect_size.x < 4.0 or cols <= 0 or rows <= 0: + return + + var grid_height := maxf(rect_size.y - grid_top, 1.0) + var tw := rect_size.x / cols + 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)) + + 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) + var y := grid_top + row * th + # 칸 경계에 실선이 비지 않도록 1px씩 겹쳐 그린다. + draw_rect(Rect2(col * tw, y, tw + 1.0, th + 1.0), c) + + # 모래알 몇 점. 바닥이 완전히 평평해 보이는 걸 막는다. + for s in 2: + var px := (col + _hash(col, row, 10 + s)) * tw + var py := y + _hash(col, row, 20 + s) * th + draw_circle(Vector2(px, py), 1.0 + _hash(col, row, 30 + s), SPECK) + + for col in range(1, cols): + draw_line(Vector2(col * tw, grid_top), Vector2(col * tw, rect_size.y), GRID_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) diff --git a/scenes/aquarium/seabed.gd.uid b/scenes/aquarium/seabed.gd.uid new file mode 100644 index 0000000..24f5753 --- /dev/null +++ b/scenes/aquarium/seabed.gd.uid @@ -0,0 +1 @@ +uid://t23j7yuhibs7 diff --git a/scenes/aquarium/tile_item.gd b/scenes/aquarium/tile_item.gd new file mode 100644 index 0000000..84eacab --- /dev/null +++ b/scenes/aquarium/tile_item.gd @@ -0,0 +1,253 @@ +extends Node2D +class_name TileItem +## 격자 위에 놓인 것 하나. 3/4 부감 시점으로 그린다. +## +## 바닥은 위에서 내려다보지만 물건은 "서 있는" 모습으로 그린다. +## 노드의 원점은 발이 바닥에 닿는 지점(발자국 아래 가운데)이고, +## 그림은 거기서 위(-Y)로 자란다. 그래서 키가 큰 것은 뒷줄을 가린다. +## 아직 아트 에셋이 없어서 형태별로 직접 그린다. +## assets/art/ 에 스프라이트가 생기면 Sprite2D + offset 기반으로 바꾸면 된다. + +## placements 배열에서의 인덱스. 클릭·드래그로 무엇을 집었는지 알아내는 데 쓴다. +var placement_index := -1 + +var data: Dictionary = {} +var shape := "rock" +var color := Color.WHITE +## 바닥에서 차지하는 픽셀 크기. +var span := Vector2(34, 32) +## 서 있는 높이(픽셀). +var body_height := 32.0 +## 바닥에서 얼마나 떠 있는지(픽셀). 헤엄치는 것만 0보다 크다. +var hover := 0.0 + +var _t := 0.0 +var _phase := 0.0 +var _swim := Vector2.ZERO +var _swim_range := Vector2.ZERO +var _facing := 1.0 +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 + shape = String(item_data.get("shape", "rock")) + color = item_data.get("color", Color.WHITE) + _phase = rng.randf() * TAU + _shape_seed = rng.randf() * 10.0 + _facing = 1.0 if rng.randf() < 0.5 else -1.0 + set_area(area, cell, Catalog.height_of(kind, String(item_data.get("id", "")))) + + +## 놓인 자리가 정해졌을 때. 원점은 발자국의 아래 가운데, +## 즉 물건이 바닥에 닿는 지점이다. 그림은 거기서 위로 자란다. +func set_area(area: Rect2, cell: Vector2, height_tiles: float) -> void: + span = area.size + var cell_h := maxf(cell.y, 1.0) + body_height = cell_h * height_tiles + position = Vector2(area.position.x + area.size.x * 0.5, area.end.y) + + match shape: + "fish": + hover = cell_h * 0.45 + _swim_range = Vector2(span.x * 0.30, cell_h * 0.22) + "glow": + hover = cell_h * 0.60 + _swim_range = Vector2(span.x * 0.18, cell_h * 0.20) + _: + hover = 0.0 + _swim_range = Vector2.ZERO + queue_redraw() + + +## 그려지는 대략의 범위. 클릭으로 집을 때 쓴다. +func visual_rect() -> Rect2: + var top := -body_height - hover - _swim_range.y + return Rect2( + Vector2(-span.x * 0.5, top), + Vector2(span.x, absf(top) + 4.0)) + + +func _process(delta: float) -> void: + _t += 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: + _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(Vector2(_swim.x, -hover + _swim.y), 0.0, Vector2.ONE) + match shape: + "plant": _draw_plant() + "fish": _draw_fish() + "glow": _draw_glow() + _: _draw_rock() + draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) + + +## 바닥에서 위로 자라는 잎. 밑동은 좁고 끝으로 갈수록 흔들린다. +func _draw_plant() -> void: + var blades := 3 + int(_shape_seed) % 3 + # 칸 너비를 다 채우지 않는다. 옆 칸과 사이가 벌어져야 격자가 읽힌다. + var w := span.x * 0.32 + for i in blades: + var offset := (i - (blades - 1) * 0.5) / maxf(blades - 1, 1) * w + var height := body_height * (0.62 + fmod(_shape_seed * 3.7 + i * 0.43, 1.0) * 0.55) + var sway := sin(_t * 1.1 + _phase + i * 0.8) * span.x * 0.18 + var thick := maxf(span.x * 0.13, 2.0) + var tone := color.lerp(Color.BLACK, 0.06 + i * 0.05) + + # 밑동에서 끝까지 부드럽게 휘는 띠. + var left := PackedVector2Array() + var right := PackedVector2Array() + var steps := 5 + for s in steps + 1: + var f := float(s) / steps + var p := Vector2(offset + sway * f * f, -height * f) + var half := thick * (1.0 - f * 0.65) * 0.5 + left.append(p + Vector2(half, 0)) + right.append(p - Vector2(half, 0)) + right.reverse() + _fill(left + right, tone) + + draw_circle(Vector2(0, -span.y * 0.06), span.x * 0.16, color.darkened(0.25)) + + +## 옆에서 본 물고기. 물 위에 떠 있고 바닥에 그림자를 남긴다. +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)) + + # 꼬리지느러미. + _fill(PackedVector2Array([ + Vector2(-l * 0.55, 0.0), + Vector2(-l * 1.10, -l * 0.42 + wag), + Vector2(-l * 1.10, l * 0.38 + wag), + ]), color.darkened(0.30)) + + # 몸통. 코가 앞(+X), 등이 위(-Y). + _fill(PackedVector2Array([ + Vector2(l * 1.00, -l * 0.02), + Vector2(l * 0.45, -l * 0.46), + Vector2(-l * 0.15, -l * 0.44), + Vector2(-l * 0.58, -l * 0.12), + Vector2(-l * 0.58, l * 0.14), + Vector2(-l * 0.10, l * 0.42), + Vector2(l * 0.50, l * 0.36), + ]), color) + + # 등지느러미와 배 쪽 밝은 면. + _fill(PackedVector2Array([ + Vector2(l * 0.10, -l * 0.44), + Vector2(-l * 0.10, -l * 0.90), + Vector2(-l * 0.35, -l * 0.36), + ]), color.darkened(0.22)) + _fill(PackedVector2Array([ + Vector2(l * 0.55, l * 0.28), + Vector2(-l * 0.20, l * 0.40), + Vector2(-l * 0.35, l * 0.10), + Vector2(l * 0.40, l * 0.06), + ]), 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) + + +## 스스로 빛나며 떠 있는 것. 후광과 아래로 늘어진 촉수. +func _draw_glow() -> void: + var r := span.x * 0.26 + var pulse := 0.88 + sin(_t * 1.5 + _phase) * 0.12 + var bell := Vector2(0, -body_height * 0.45) + + 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)) + + # 아래로 늘어진 촉수를 먼저 그려 갓 뒤로 보내둔다. + for i in 4: + var x := (i - 1.5) * r * 0.48 + var strand := PackedVector2Array() + for s in 5: + var f := float(s) / 4.0 + 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), + maxf(r * 0.16, 1.2), true) + + # 반구형 갓. + var cap := PackedVector2Array() + for i in 13: + var a := PI + PI * (float(i) / 12.0) + 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)) + + +## 바위·구조물. 아래가 넓고 위로 갈수록 좁아지는 덩어리. +## 위가 넓어지면 버섯이나 전등갓처럼 보이므로 반드시 좁혀야 한다. +func _draw_rock() -> void: + var w := span.x * 0.42 + var h := body_height + var sides := 9 + + var body := PackedVector2Array() + for i in sides + 1: + var f := float(i) / sides + body.append(Vector2(-w * _rock_profile(f, 0.0), -h * f)) + for i in range(sides, -1, -1): + var f := float(i) / sides + body.append(Vector2(w * _rock_profile(f, 1.7), -h * f)) + _fill(body, color.darkened(0.30)) + + # 빛을 받는 왼쪽 면. + var lit := PackedVector2Array() + for p in body: + lit.append(Vector2(p.x * 0.58 - w * 0.16, p.y)) + _fill(lit, color, false) + + # 꼭대기 면. 부감이라 윗면이 살짝 보이지만, 몸통보다 넓으면 안 된다. + var top_w := w * _rock_profile(1.0, 0.0) + draw_set_transform(Vector2(0, -h), 0.0, 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) + + +## 바위의 높이 f(0=밑동, 1=꼭대기)에서 옆으로 벌어지는 정도. +## 납작한 것은 둥근 바위로, 키 큰 것은 기둥으로 보이도록 두 모양을 섞는다. +## 위로 갈수록 넓어지면 버섯이나 전등갓처럼 보이므로 절대 1을 넘기지 않는다. +func _rock_profile(f: float, salt: float) -> float: + # 발자국 너비에 비해 얼마나 높은지. 낮고 넓으면 둥근 바위, 좁고 높으면 기둥. + var tallness := clampf((body_height / maxf(span.x, 1.0) - 0.65) / 0.35, 0.0, 1.0) + var dome := pow(maxf(1.0 - pow(f, 1.7), 0.0), 0.55) + var column := 1.0 - f * 0.30 + var wobble := 0.93 + sin(f * 2.4 + _shape_seed + salt) * 0.07 + return lerpf(dome, column, tallness) * wobble + + +## 면을 칠하고 어두운 테두리를 두른다. +## 물빛 오버레이가 덮이면 형태가 바닥에 묻히기 때문에, 테두리가 없으면 무엇인지 안 읽힌다. +func _fill(points: PackedVector2Array, fill: Color, outline := true) -> void: + draw_colored_polygon(points, fill) + if outline and points.size() > 2: + var loop := points.duplicate() + loop.append(points[0]) + draw_polyline(loop, fill.darkened(0.55), maxf(span.x * 0.04, 1.0), true) diff --git a/scenes/aquarium/tile_item.gd.uid b/scenes/aquarium/tile_item.gd.uid new file mode 100644 index 0000000..93f9400 --- /dev/null +++ b/scenes/aquarium/tile_item.gd.uid @@ -0,0 +1 @@ +uid://cdtc2kqnfuc8o diff --git a/scenes/main.gd b/scenes/main.gd new file mode 100644 index 0000000..438ffc7 --- /dev/null +++ b/scenes/main.gd @@ -0,0 +1,284 @@ +extends Control +## 바탕화면 스트립의 껍데기. +## 창을 화면 가장자리에 폭 전체로 도킹시키고, 게임 화면(수조·HUD·상점)을 연결한다. + +enum MenuId { + ALWAYS_ON_TOP = 0, + CLICK_THROUGH = 1, + SAVE_NOW = 2, + RESET = 3, + QUIT = 4, +} + +## 높이 프리셋. 메뉴에서 고른다. +const HEIGHTS := [ + {"id": 10, "label": "높이 · 낮게", "px": 160}, + {"id": 11, "label": "높이 · 보통", "px": 220}, + {"id": 12, "label": "높이 · 높게", "px": 300}, +] +const EDGE_BOTTOM := 20 +const EDGE_TOP := 21 +const SCREEN_ID_BASE := 100 + +## 클릭 통과 중에도 이 크기의 모서리는 계속 클릭을 받는다. +## 이게 없으면 클릭 통과를 켠 순간 위젯으로 돌아올 방법이 없어진다. +const ESCAPE_HATCH := Vector2(84, 56) + +@onready var aquarium: Aquarium = $Aquarium +@onready var hud: HUDLayer = $HUD +@onready var shop: ShopPanel = $ShopPanel +@onready var menu: PopupMenu = $ContextMenu + +var _always_on_top := true +var _click_through := false +var _edge_bottom := true +var _strip_height := 220 +var _screen := 0 +## 화면 배율(125%면 1.25). 창 크기는 물리 픽셀, 게임 좌표는 이 값으로 나눈 논리 픽셀. +var _ui_scale := 1.0 + + +func _ready() -> void: + var state: Dictionary = SaveManager.window_state + _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)) + _strip_height = int(state.get("height", 220)) + _screen = int(state.get("screen", DisplayServer.window_get_current_screen())) + + var win := get_window() + win.transparent = true + win.borderless = true + get_viewport().transparent_bg = true + win.always_on_top = _always_on_top + + _apply_dock() + + shop.hide() + shop.closed.connect(_close_shop) + shop.item_selected.connect(_on_shop_item_selected) + 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.context_menu_requested.connect(_open_menu) + aquarium.placement_changed.connect(_on_placement_changed) + aquarium.drag_changed.connect(_on_drag_changed) + + _build_menu() + get_window().close_requested.connect(_quit) + + +# --- 도킹 --- + +## 창을 화면 폭 전체로 늘려 위 또는 아래 가장자리에 붙인다. +## 작업표시줄을 덮지 않도록 usable_rect를 기준으로 삼는다. +func _apply_dock() -> void: + _screen = clampi(_screen, 0, DisplayServer.get_screen_count() - 1) + var usable := DisplayServer.screen_get_usable_rect(_screen) + + # 고배율 화면에서 글씨가 깨알같이 작아지지 않도록 배율만큼 창을 키우고 + # 게임 좌표계는 그대로 둔다. Windows에서 screen_get_scale()은 항상 1이라 DPI로 구한다. + _ui_scale = clampf(DisplayServer.screen_get_dpi(_screen) / 96.0, 1.0, 3.0) + + var h := int(round(clampi(_strip_height, 120, 600) * _ui_scale)) + h = mini(h, maxi(usable.size.y - 60, 120)) + + var win := get_window() + 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 + ) + + _apply_passthrough() + _remember_window() + + +func _remember_window() -> void: + SaveManager.window_state["always_on_top"] = _always_on_top + SaveManager.window_state["click_through"] = _click_through + SaveManager.window_state["edge_bottom"] = _edge_bottom + SaveManager.window_state["height"] = _strip_height + SaveManager.window_state["screen"] = _screen + + +## 클릭 통과를 켜면 안쪽 모서리 하나만 입력을 받는 영역으로 남긴다. +## 빈 배열을 넘기면 통과가 꺼지고 창 전체가 다시 입력을 받는다. +func _apply_passthrough() -> void: + if not _click_through: + DisplayServer.window_set_mouse_passthrough(PackedVector2Array()) + return + # 통과 영역은 창의 물리 픽셀 좌표로 준다. + var s := Vector2(get_window().size) + var hatch := ESCAPE_HATCH * _ui_scale + # 화면 안쪽을 향한 모서리에 둔다. 아래 도킹이면 오른쪽 위. + var y0 := 0.0 if _edge_bottom else s.y - hatch.y + var y1 := hatch.y if _edge_bottom else s.y + DisplayServer.window_set_mouse_passthrough(PackedVector2Array([ + Vector2(s.x - hatch.x, y0), + Vector2(s.x, y0), + Vector2(s.x, y1), + Vector2(s.x - hatch.x, y1), + ])) + + +# --- 게임 입력 --- + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("ui_cancel") and aquarium.is_placing(): + aquarium.cancel_placement() + get_viewport().set_input_as_handled() + + +func _on_water_clicked(pos: Vector2) -> void: + var gained := GameState.click() + aquarium.pop_at(pos) + hud.float_number(pos, gained) + + +func _toggle_shop() -> void: + if shop.visible: + _close_shop() + else: + aquarium.cancel_placement() + shop.show() + + +func _close_shop() -> void: + shop.hide() + aquarium.cancel_placement() + + +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))) + return + aquarium.begin_placement(kind, id) + + +## 배치 중에는 상점을 잠시 치운다. 상점이 바다 오른쪽을 가리고 있어서 +## 열어 둔 채로는 뒤쪽 구역에 놓을 수가 없다. +func _on_placement_changed(active: bool, kind: String, id: String) -> void: + shop.set_selected(active, kind, id) + shop.visible = not active + if active: + hud.show_toast("놓을 칸을 클릭하세요 · 우클릭이나 Esc로 취소", 3.0) + + +func _on_drag_changed(dragging: bool) -> void: + hud.set_trash_visible(dragging) + aquarium.trash_rect = hud.trash_area() if dragging else Rect2() + + +# --- 메뉴 --- + +func _build_menu() -> void: + menu.clear() + menu.add_check_item("항상 위에 표시", MenuId.ALWAYS_ON_TOP) + menu.add_check_item("클릭 통과 (모서리로 복귀)", MenuId.CLICK_THROUGH) + menu.add_separator() + for h in HEIGHTS: + menu.add_radio_check_item(h.label, h.id) + menu.add_separator() + menu.add_radio_check_item("화면 아래에 붙이기", EDGE_BOTTOM) + menu.add_radio_check_item("화면 위에 붙이기", EDGE_TOP) + if DisplayServer.get_screen_count() > 1: + menu.add_separator() + for i in DisplayServer.get_screen_count(): + menu.add_radio_check_item("화면 %d" % (i + 1), SCREEN_ID_BASE + i) + menu.add_separator() + menu.add_item("지금 저장", MenuId.SAVE_NOW) + menu.add_item("처음부터 다시", MenuId.RESET) + menu.add_separator() + menu.add_item("종료", MenuId.QUIT) + menu.id_pressed.connect(_on_menu_id) + _sync_menu() + + +func _sync_menu() -> void: + _check(MenuId.ALWAYS_ON_TOP, _always_on_top) + _check(MenuId.CLICK_THROUGH, _click_through) + for h in HEIGHTS: + _check(h.id, _strip_height == int(h.px)) + _check(EDGE_BOTTOM, _edge_bottom) + _check(EDGE_TOP, not _edge_bottom) + if DisplayServer.get_screen_count() > 1: + for i in DisplayServer.get_screen_count(): + _check(SCREEN_ID_BASE + i, _screen == i) + + +func _check(id: int, on: bool) -> void: + var index := menu.get_item_index(id) + if index >= 0: + menu.set_item_checked(index, on) + + +func _open_menu() -> void: + _sync_menu() + menu.position = DisplayServer.mouse_get_position() + menu.popup() + + +func _on_menu_id(id: int) -> void: + if id >= SCREEN_ID_BASE: + _screen = id - SCREEN_ID_BASE + _apply_dock() + return + + for h in HEIGHTS: + if id == int(h.id): + _strip_height = int(h.px) + _apply_dock() + return + + match id: + EDGE_BOTTOM, EDGE_TOP: + _edge_bottom = id == EDGE_BOTTOM + _apply_dock() + MenuId.ALWAYS_ON_TOP: + _always_on_top = not _always_on_top + get_window().always_on_top = _always_on_top + _remember_window() + MenuId.CLICK_THROUGH: + _click_through = not _click_through + _apply_passthrough() + _remember_window() + if _click_through: + var corner := "오른쪽 위" if _edge_bottom else "오른쪽 아래" + hud.show_toast( + "클릭이 바탕화면으로 지나갑니다. %s 모서리를 눌러 돌아오세요." % corner, 5.0) + MenuId.SAVE_NOW: + _remember_window() + SaveManager.save_game() + hud.show_toast("저장했어요") + MenuId.RESET: + _confirm_reset() + MenuId.QUIT: + _quit() + + +func _confirm_reset() -> void: + var dialog := ConfirmationDialog.new() + dialog.title = "처음부터 다시" + dialog.dialog_text = "지금까지 모은 거품과 꾸민 수조가 모두 사라집니다.\n정말 진행할까요?" + dialog.ok_button_text = "초기화" + dialog.cancel_button_text = "취소" + add_child(dialog) + dialog.confirmed.connect(func(): + SaveManager.wipe() + aquarium.rebuild() + hud.show_toast("수조를 비웠어요") + ) + dialog.close_requested.connect(dialog.queue_free) + dialog.confirmed.connect(dialog.queue_free) + dialog.popup_centered() + + +func _quit() -> void: + _remember_window() + SaveManager.save_game() + get_tree().quit() diff --git a/scenes/main.gd.uid b/scenes/main.gd.uid new file mode 100644 index 0000000..b2a44c0 --- /dev/null +++ b/scenes/main.gd.uid @@ -0,0 +1 @@ +uid://d2lmk0fcjo351 diff --git a/scenes/main.tscn b/scenes/main.tscn new file mode 100644 index 0000000..06066b6 --- /dev/null +++ b/scenes/main.tscn @@ -0,0 +1,28 @@ +[gd_scene load_steps=5 format=3] + +[ext_resource type="Script" path="res://scenes/main.gd" id="1_main"] +[ext_resource type="PackedScene" path="res://scenes/aquarium/aquarium.tscn" id="2_aquarium"] +[ext_resource type="PackedScene" path="res://scenes/ui/hud.tscn" id="3_hud"] +[ext_resource type="PackedScene" path="res://scenes/ui/shop_panel.tscn" id="4_shop"] + +[node name="Main" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("1_main") + +[node name="Aquarium" parent="." instance=ExtResource("2_aquarium")] +layout_mode = 1 + +[node name="HUD" parent="." instance=ExtResource("3_hud")] +layout_mode = 1 + +[node name="ShopPanel" parent="." instance=ExtResource("4_shop")] +visible = false +layout_mode = 1 + +[node name="ContextMenu" type="PopupMenu" parent="."] diff --git a/scenes/ui/hud.gd b/scenes/ui/hud.gd new file mode 100644 index 0000000..d85db0e --- /dev/null +++ b/scenes/ui/hud.gd @@ -0,0 +1,107 @@ +extends Control +class_name HUDLayer +## 스트립 왼쪽 위에 붙는 한 줄짜리 정보 바와 짧은 알림. +## 스트립은 화면에 도킹되므로 창을 끌어 옮기는 기능은 없다. 위치는 메뉴에서 바꾼다. + +signal shop_pressed +signal menu_pressed + +@onready var control_bar: PanelContainer = $ControlBar +@onready var bubbles_label: Label = %BubblesLabel +@onready var bps_label: Label = %BpsLabel +@onready var beauty_label: Label = %BeautyLabel +@onready var zone_label: Label = %ZoneLabel +@onready var shop_button: Button = %ShopButton +@onready var menu_button: Button = %MenuButton +@onready var toast: Label = %Toast +@onready var trash: PanelContainer = %Trash +@onready var trash_label: Label = %TrashLabel + +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) + UIStyle.label(bps_label, 13, UIStyle.INK_DIM) + UIStyle.label(beauty_label, 13, UIStyle.GOLD) + UIStyle.label(zone_label, 13, UIStyle.ACCENT) + UIStyle.label(toast, 14, UIStyle.INK) + toast.modulate.a = 0.0 + + trash.add_theme_stylebox_override("panel", UIStyle.panel(Color(0.35, 0.06, 0.10, 0.88), 12)) + UIStyle.label(trash_label, 13, UIStyle.INK) + trash.hide() + + UIStyle.style_button(shop_button) + UIStyle.style_button(menu_button) + shop_button.pressed.connect(func(): shop_pressed.emit()) + menu_button.pressed.connect(func(): menu_pressed.emit()) + + GameState.zone_unlocked.connect(func(_i: int): _refresh_zone_label()) + GameState.state_changed.connect(_refresh_zone_label) + GameState.reloaded.connect(_refresh_zone_label) + SaveManager.offline_earned.connect(_on_offline_earned) + _refresh_zone_label() + + +func _process(_delta: float) -> void: + bubbles_label.text = NumberFormat.short(GameState.bubbles) + bps_label.text = "초당 %s" % NumberFormat.short(GameState.bps) + beauty_label.text = "아름다움 %s · x%.2f" % [ + NumberFormat.short(GameState.beauty), GameState.beauty_mult + ] + + +## 몇 개 구역을 열었고 다음은 무엇인지. 해금 조건 자체는 잠긴 구역 위에 직접 표시된다. +func _refresh_zone_label() -> void: + var opened := GameState.unlocked_zones + var total := Catalog.zone_count() + if not GameState.has_next_zone(): + zone_label.text = "구역 %d/%d · 모두 열었어요" % [opened, total] + return + var next_zone := Catalog.zone(GameState.next_zone_index()) + zone_label.text = "구역 %d/%d · 다음 %s %s" % [ + opened, total, next_zone.name, NumberFormat.short(GameState.next_zone_cost()) + ] + + +func _on_offline_earned(amount: float, seconds: int) -> void: + show_toast("%s 동안 거품 %s를 모았어요" % [ + NumberFormat.duration(seconds), NumberFormat.short(amount) + ], 4.5) + + +func show_toast(text: String, duration := 2.0) -> void: + toast.text = text + if _toast_tween and _toast_tween.is_valid(): + _toast_tween.kill() + _toast_tween = create_tween() + _toast_tween.tween_property(toast, "modulate:a", 1.0, 0.2) + _toast_tween.tween_interval(duration) + _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 + + +## 휴지통의 사각형. HUD와 바다가 같은 좌표계(Main의 전체 영역)를 쓰므로 그대로 넘길 수 있다. +func trash_area() -> Rect2: + return Rect2(trash.position, trash.size) diff --git a/scenes/ui/hud.gd.uid b/scenes/ui/hud.gd.uid new file mode 100644 index 0000000..bb94c0b --- /dev/null +++ b/scenes/ui/hud.gd.uid @@ -0,0 +1 @@ +uid://clxf1syisntmq diff --git a/scenes/ui/hud.tscn b/scenes/ui/hud.tscn new file mode 100644 index 0000000..3bb2fb6 --- /dev/null +++ b/scenes/ui/hud.tscn @@ -0,0 +1,121 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/ui/hud.gd" id="1_hud"] + +[node name="HUD" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("1_hud") + +[node name="ControlBar" type="PanelContainer" parent="."] +layout_mode = 1 +anchors_preset = 0 +offset_left = 12.0 +offset_top = 10.0 +offset_right = 12.0 +offset_bottom = 10.0 +mouse_filter = 2 + +[node name="HBox" type="HBoxContainer" parent="ControlBar"] +layout_mode = 2 +mouse_filter = 2 +theme_override_constants/separation = 14 + +[node name="BubblesLabel" type="Label" parent="ControlBar/HBox"] +unique_name_in_owner = true +layout_mode = 2 +mouse_filter = 2 +text = "0" +vertical_alignment = 1 + +[node name="Sep1" type="VSeparator" parent="ControlBar/HBox"] +layout_mode = 2 +mouse_filter = 2 + +[node name="BpsLabel" type="Label" parent="ControlBar/HBox"] +unique_name_in_owner = true +layout_mode = 2 +mouse_filter = 2 +text = "초당 0" +vertical_alignment = 1 + +[node name="BeautyLabel" type="Label" parent="ControlBar/HBox"] +unique_name_in_owner = true +layout_mode = 2 +mouse_filter = 2 +text = "아름다움 0" +vertical_alignment = 1 + +[node name="Sep2" type="VSeparator" parent="ControlBar/HBox"] +layout_mode = 2 +mouse_filter = 2 + +[node name="ZoneLabel" type="Label" parent="ControlBar/HBox"] +unique_name_in_owner = true +layout_mode = 2 +mouse_filter = 2 +text = "구역 1/5" +vertical_alignment = 1 + +[node name="Sep3" type="VSeparator" parent="ControlBar/HBox"] +layout_mode = 2 +mouse_filter = 2 + +[node name="ShopButton" type="Button" parent="ControlBar/HBox"] +unique_name_in_owner = true +custom_minimum_size = Vector2(78, 32) +layout_mode = 2 +size_flags_vertical = 4 +text = "상점" + +[node name="MenuButton" type="Button" parent="ControlBar/HBox"] +unique_name_in_owner = true +custom_minimum_size = Vector2(40, 32) +layout_mode = 2 +size_flags_vertical = 4 +text = "···" + +[node name="Trash" type="PanelContainer" parent="."] +unique_name_in_owner = true +layout_mode = 1 +anchors_preset = 7 +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -110.0 +offset_top = -50.0 +offset_right = 110.0 +offset_bottom = -12.0 +grow_horizontal = 2 +grow_vertical = 0 +mouse_filter = 2 + +[node name="TrashLabel" type="Label" parent="Trash"] +unique_name_in_owner = true +layout_mode = 2 +mouse_filter = 2 +text = "여기에 놓아 치우기 (절반 돌려받음)" +horizontal_alignment = 1 +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 +mouse_filter = 2 +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 3 diff --git a/scenes/ui/shop_panel.gd b/scenes/ui/shop_panel.gd new file mode 100644 index 0000000..6bc4623 --- /dev/null +++ b/scenes/ui/shop_panel.gd @@ -0,0 +1,241 @@ +extends PanelContainer +class_name ShopPanel +## 상점. 생물 / 장식 / 업그레이드 세 탭. +## +## 생물과 장식은 여기서 바로 사지지 않는다. 고르면 배치 모드로 들어가고, +## 실제 구매는 바다에서 칸을 클릭할 때 일어난다. 업그레이드만 즉시 구매다. +## 목록은 Catalog에서 바로 만들어지므로, 아이템을 추가할 때 이 파일은 건드릴 필요가 없다. + +signal closed +## 고른 아이템. kind가 "upgrade"면 즉시 구매, 아니면 배치 모드로 들어간다. +signal item_selected(kind: String, id: String) + +const TABS := [ + {"kind": "producer", "label": "생물"}, + {"kind": "ornament", "label": "장식"}, + {"kind": "upgrade", "label": "업그레이드"}, +] + +@onready var tab_bar: HBoxContainer = %TabBar +@onready var space_label: Label = %SpaceLabel +@onready var close_button: Button = %CloseButton +@onready var list: VBoxContainer = %List + +var _tab := 0 +var _tab_buttons: Array[Button] = [] +var _rows := {} ## id -> 행 정보 Dictionary +var _built_ids: Array[String] = [] +var _selected_id := "" + + +func _ready() -> void: + add_theme_stylebox_override("panel", UIStyle.panel(Color(0.02, 0.08, 0.15, 0.94), 16)) + UIStyle.style_button(close_button) + UIStyle.label(space_label, 11, UIStyle.INK_DIM) + close_button.pressed.connect(func(): closed.emit()) + + for i in TABS.size(): + var b := Button.new() + b.text = TABS[i].label + b.size_flags_horizontal = Control.SIZE_EXPAND_FILL + b.custom_minimum_size = Vector2(0, 28) + UIStyle.style_button(b, 8) + b.pressed.connect(_select_tab.bind(i)) + tab_bar.add_child(b) + _tab_buttons.append(b) + + GameState.state_changed.connect(_refresh_list) + GameState.reloaded.connect(_rebuild) + _select_tab(0) + + +func _process(_delta: float) -> void: + if visible: + _update_rows() + + +## 배치 모드가 켜지고 꺼질 때 main이 알려준다. 고른 행을 강조해 둔다. +func set_selected(active: bool, _kind: String, id: String) -> void: + _selected_id = id if active else "" + _update_rows() + + +func _select_tab(index: int) -> void: + _tab = index + for i in _tab_buttons.size(): + _tab_buttons[i].modulate = Color.WHITE if i == index else Color(1, 1, 1, 0.55) + _rebuild() + + +func _current_items() -> Array: + return Catalog.items_of(TABS[_tab].kind) + + +func _visible_ids() -> Array[String]: + var kind: String = TABS[_tab].kind + var 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): + continue + ids.append(String(d.id)) + return ids + + +## 목록에 보여야 할 항목이 달라졌을 때만 다시 만든다. +func _refresh_list() -> void: + if _visible_ids() != _built_ids: + _rebuild() + + +func _rebuild() -> void: + for child in list.get_children(): + child.queue_free() + _rows.clear() + _built_ids = _visible_ids() + + var kind: String = TABS[_tab].kind + for d in _current_items(): + if String(d.id) in _built_ids: + list.add_child(_make_row(kind, d)) + + if _built_ids.is_empty(): + var empty := Label.new() + empty.text = "아직 살 수 있는 게 없어요.\n거품을 조금 더 모아보세요." + empty.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + empty.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + empty.custom_minimum_size = Vector2(0, 70) + UIStyle.label(empty, 12, UIStyle.INK_DIM) + list.add_child(empty) + + _update_rows() + + +func _make_row(kind: String, d: Dictionary) -> Button: + var row := Button.new() + row.custom_minimum_size = Vector2(0, 48) + UIStyle.style_button(row, 10) + row.pressed.connect(func(): item_selected.emit(kind, String(d.id))) + + # Button은 컨테이너가 아니라서 내용은 전체 크기에 앵커로 붙인다. + var margin := MarginContainer.new() + margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + margin.mouse_filter = Control.MOUSE_FILTER_IGNORE + margin.add_theme_constant_override("margin_left", 10) + margin.add_theme_constant_override("margin_right", 10) + margin.add_theme_constant_override("margin_top", 5) + margin.add_theme_constant_override("margin_bottom", 5) + row.add_child(margin) + + var hbox := HBoxContainer.new() + hbox.mouse_filter = Control.MOUSE_FILTER_IGNORE + hbox.add_theme_constant_override("separation", 10) + margin.add_child(hbox) + + var swatch := ColorRect.new() + swatch.color = d.get("color", UIStyle.ACCENT) + swatch.custom_minimum_size = Vector2(5, 0) + swatch.mouse_filter = Control.MOUSE_FILTER_IGNORE + hbox.add_child(swatch) + + var left := VBoxContainer.new() + left.size_flags_horizontal = Control.SIZE_EXPAND_FILL + left.alignment = BoxContainer.ALIGNMENT_CENTER + left.mouse_filter = Control.MOUSE_FILTER_IGNORE + left.add_theme_constant_override("separation", 1) + hbox.add_child(left) + + var name_label := Label.new() + name_label.text = _name_text(kind, d) + name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + UIStyle.label(name_label, 13) + left.add_child(name_label) + + var effect_label := Label.new() + effect_label.text = _effect_text(kind, d) + effect_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + UIStyle.label(effect_label, 10, UIStyle.INK_DIM) + left.add_child(effect_label) + + var right := VBoxContainer.new() + right.mouse_filter = Control.MOUSE_FILTER_IGNORE + right.alignment = BoxContainer.ALIGNMENT_CENTER + right.add_theme_constant_override("separation", 1) + hbox.add_child(right) + + var cost_label := Label.new() + cost_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + cost_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + UIStyle.label(cost_label, 13, UIStyle.GOLD) + right.add_child(cost_label) + + var owned_label := Label.new() + owned_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + owned_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + UIStyle.label(owned_label, 10, UIStyle.INK_DIM) + right.add_child(owned_label) + + _rows[String(d.id)] = { + "kind": kind, "button": row, "cost": cost_label, "owned": owned_label, + } + return row + + +## 여러 칸을 차지하는 것은 크기를 이름 옆에 적어준다. 자리 계획에 필요한 정보다. +func _name_text(kind: String, d: Dictionary) -> String: + var s := Catalog.size_of(kind, String(d.id)) + if s == Vector2i.ONE: + return String(d.name) + return "%s (%d×%d칸)" % [d.name, s.x, s.y] + + +func _effect_text(kind: String, d: Dictionary) -> String: + match kind: + "producer": + return "%s · 초당 +%s · 아름다움 +%s" % [ + Catalog.zone(int(d.get("zone", 0))).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)) + ] + "upgrade": + return String(d.desc) + return "" + + +func _update_rows() -> void: + for id in _rows: + var r: Dictionary = _rows[id] + var kind: String = r.kind + var cost := GameState.cost_of(kind, id) + var affordable := GameState.bubbles >= cost + + 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.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 = "" + + _update_space_label() + + +## 구역별로 빈 칸이 얼마나 남았는지. 칸이 곧 한계이므로 늘 보이게 둔다. +func _update_space_label() -> void: + if TABS[_tab].kind == "upgrade": + space_label.text = "칸을 차지하지 않아요" + return + var parts := PackedStringArray() + 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) diff --git a/scenes/ui/shop_panel.gd.uid b/scenes/ui/shop_panel.gd.uid new file mode 100644 index 0000000..d57ccd2 --- /dev/null +++ b/scenes/ui/shop_panel.gd.uid @@ -0,0 +1 @@ +uid://bn8cwiiwdmbsg diff --git a/scenes/ui/shop_panel.tscn b/scenes/ui/shop_panel.tscn new file mode 100644 index 0000000..2374ddd --- /dev/null +++ b/scenes/ui/shop_panel.tscn @@ -0,0 +1,54 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/ui/shop_panel.gd" id="1_shop"] + +[node name="ShopPanel" type="PanelContainer"] +anchors_preset = 11 +anchor_left = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -560.0 +offset_top = 10.0 +offset_right = -12.0 +offset_bottom = -10.0 +grow_horizontal = 0 +grow_vertical = 2 +script = ExtResource("1_shop") + +[node name="VBox" type="VBoxContainer" parent="."] +layout_mode = 2 +theme_override_constants/separation = 5 + +[node name="Header" type="HBoxContainer" parent="VBox"] +layout_mode = 2 +theme_override_constants/separation = 6 + +[node name="TabBar" type="HBoxContainer" parent="VBox/Header"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 5 + +[node name="CloseButton" type="Button" parent="VBox/Header"] +unique_name_in_owner = true +custom_minimum_size = Vector2(34, 28) +layout_mode = 2 +size_flags_vertical = 4 +text = "✕" + +[node name="SpaceLabel" type="Label" parent="VBox"] +unique_name_in_owner = true +layout_mode = 2 +mouse_filter = 2 +text = "남은 칸" + +[node name="Scroll" type="ScrollContainer" parent="VBox"] +layout_mode = 2 +size_flags_vertical = 3 +horizontal_scroll_mode = 0 + +[node name="List" type="VBoxContainer" parent="VBox/Scroll"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 5 diff --git a/scripts/korean.gd b/scripts/korean.gd new file mode 100644 index 0000000..0dce882 --- /dev/null +++ b/scripts/korean.gd @@ -0,0 +1,32 @@ +class_name Korean +## 한글 조사 처리. 앞 글자의 받침 유무에 따라 을/를, 이/가, 은/는이 갈린다. +## 구역 이름을 문장에 끼워 넣을 때 "켈프 숲를" 같은 어색한 말이 나오지 않게 한다. + +const HANGUL_FIRST := 0xAC00 +const HANGUL_LAST := 0xD7A3 + + +## 마지막 글자에 받침이 있는지. 한글 음절이 아니면 없는 것으로 친다. +static func has_final(word: String) -> bool: + if word.is_empty(): + return false + var c := word.unicode_at(word.length() - 1) + if c < HANGUL_FIRST or c > HANGUL_LAST: + return false + return (c - HANGUL_FIRST) % 28 != 0 + + +static func josa(word: String, with_final: String, without_final: String) -> String: + return word + (with_final if has_final(word) else without_final) + + +static func eul(word: String) -> String: + return josa(word, "을", "를") + + +static func i(word: String) -> String: + return josa(word, "이", "가") + + +static func eun(word: String) -> String: + return josa(word, "은", "는") diff --git a/scripts/korean.gd.uid b/scripts/korean.gd.uid new file mode 100644 index 0000000..377a951 --- /dev/null +++ b/scripts/korean.gd.uid @@ -0,0 +1 @@ +uid://bo2o0ge2vbhp1 diff --git a/scripts/number_format.gd b/scripts/number_format.gd new file mode 100644 index 0000000..029f495 --- /dev/null +++ b/scripts/number_format.gd @@ -0,0 +1,40 @@ +class_name NumberFormat +## 방치형 게임 특유의 큰 수를 짧게 표기한다. 1234 -> "1.23K" + +const SUFFIXES := ["", "K", "M", "B", "T", "aa", "ab", "ac", "ad", "ae", "af", "ag"] + + +static func short(value: float) -> String: + if value < 0.0: + return "-" + short(-value) + if value < 1000.0: + # 1 미만은 소수점을 보여줘야 초당 생산량이 0으로 보이지 않는다. + if value < 10.0 and value != floor(value): + return "%.1f" % value + return "%d" % int(value) + + var tier := 0 + var v := value + while v >= 1000.0 and tier < SUFFIXES.size() - 1: + v /= 1000.0 + tier += 1 + + if v < 10.0: + return "%.2f%s" % [v, SUFFIXES[tier]] + if v < 100.0: + return "%.1f%s" % [v, SUFFIXES[tier]] + return "%d%s" % [int(v), SUFFIXES[tier]] + + +## 경과 시간을 "3시간 12분" 형태로. +static func duration(seconds: int) -> String: + if seconds < 60: + return "%d초" % seconds + var m := seconds / 60 + if m < 60: + return "%d분" % m + var h := m / 60 + var rem := m % 60 + if h < 24: + return "%d시간 %d분" % [h, rem] if rem > 0 else "%d시간" % h + return "%d일 %d시간" % [h / 24, h % 24] diff --git a/scripts/number_format.gd.uid b/scripts/number_format.gd.uid new file mode 100644 index 0000000..6ade097 --- /dev/null +++ b/scripts/number_format.gd.uid @@ -0,0 +1 @@ +uid://crfj2c2detfxk diff --git a/scripts/ui_style.gd b/scripts/ui_style.gd new file mode 100644 index 0000000..b8a76fb --- /dev/null +++ b/scripts/ui_style.gd @@ -0,0 +1,64 @@ +class_name UIStyle +## 위젯 UI의 색과 스타일박스를 한곳에서 정의한다. +## 테마 리소스(.tres) 대신 코드로 두어, 값 하나 바꾸면 전체가 따라오게 했다. + +const INK := Color(0.96, 0.99, 1.0) +const INK_DIM := Color(0.78, 0.88, 0.94) +const ACCENT := Color(0.62, 0.92, 1.0) +const GOLD := Color(1.0, 0.85, 0.45) +const PANEL_BG := Color(0.03, 0.10, 0.18, 0.82) +const ROW_BG := Color(1, 1, 1, 0.06) +const ROW_BG_HOVER := Color(1, 1, 1, 0.13) +const ROW_BG_DISABLED := Color(0, 0, 0, 0.18) + + +static func panel(bg: Color = PANEL_BG, radius: int = 14) -> StyleBoxFlat: + var sb := StyleBoxFlat.new() + sb.bg_color = bg + sb.set_corner_radius_all(radius) + sb.content_margin_left = 12 + sb.content_margin_right = 12 + sb.content_margin_top = 10 + sb.content_margin_bottom = 10 + return sb + + +static func row(bg: Color, radius: int = 10) -> StyleBoxFlat: + var sb := StyleBoxFlat.new() + sb.bg_color = bg + sb.set_corner_radius_all(radius) + sb.content_margin_left = 10 + sb.content_margin_right = 10 + sb.content_margin_top = 8 + sb.content_margin_bottom = 8 + return sb + + +## 버튼의 네 가지 상태 스타일을 한 번에 입힌다. +static func style_button(b: Button, radius: int = 10) -> void: + b.add_theme_stylebox_override("normal", row(ROW_BG, radius)) + b.add_theme_stylebox_override("hover", row(ROW_BG_HOVER, radius)) + b.add_theme_stylebox_override("pressed", row(ROW_BG_HOVER, radius)) + b.add_theme_stylebox_override("disabled", row(ROW_BG_DISABLED, radius)) + b.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) + b.add_theme_color_override("font_color", INK) + b.add_theme_color_override("font_hover_color", INK) + b.add_theme_color_override("font_pressed_color", ACCENT) + b.add_theme_color_override("font_disabled_color", Color(0.6, 0.66, 0.72, 0.7)) + + +static func label(l: Label, size: int, color: Color = INK) -> void: + l.add_theme_font_size_override("font_size", size) + l.add_theme_color_override("font_color", color) + # 물 위에서도 글씨가 읽히도록 옅은 외곽선을 준다. + l.add_theme_color_override("font_shadow_color", Color(0, 0.05, 0.1, 0.55)) + l.add_theme_constant_override("shadow_offset_x", 0) + l.add_theme_constant_override("shadow_offset_y", 1) + l.add_theme_constant_override("shadow_outline_size", 2) + + +## 잠긴 구역을 덮는 어두운 베일. +static func veil(alpha: float) -> StyleBoxFlat: + var sb := StyleBoxFlat.new() + sb.bg_color = Color(0.01, 0.03, 0.07, alpha) + return sb diff --git a/scripts/ui_style.gd.uid b/scripts/ui_style.gd.uid new file mode 100644 index 0000000..3662e8f --- /dev/null +++ b/scripts/ui_style.gd.uid @@ -0,0 +1 @@ +uid://b7lu7lfy32jd8 diff --git a/test.ps1 b/test.ps1 new file mode 100644 index 0000000..a756272 --- /dev/null +++ b/test.ps1 @@ -0,0 +1,4 @@ +# 경제 로직 스모크 테스트를 돌립니다. 사용: .\test.ps1 +$godot = "$env:LOCALAPPDATA\Programs\Godot\Godot_v4.7.2-stable_win64_console.exe" +& $godot --headless --path $PSScriptRoot res://tests/smoke.tscn +exit $LASTEXITCODE diff --git a/tests/smoke.gd b/tests/smoke.gd new file mode 100644 index 0000000..ccbcb30 --- /dev/null +++ b/tests/smoke.gd @@ -0,0 +1,357 @@ +extends Node +## 경제·배치 로직 스모크 테스트. 저장 파일은 건드리지 않고 메모리 안에서만 돈다. +## Godot_v4.7.2-stable_win64_console.exe --headless --path . res://tests/smoke.tscn + +var _failures := 0 + + +func _ready() -> void: + # 이 테스트는 실제 저장 파일을 건드리면 안 된다. + # 끄지 않으면 종료할 때 초기화된 상태가 저장되어 진행도가 날아간다. + SaveManager.persistence_enabled = false + GameState.reset() + + _test_click() + _test_producer_cost_curve() + _test_place() + _test_footprint_and_multi_tile() + _test_zone_boundaries() + _test_move() + _test_remove_refund() + _test_space_is_the_limit() + _test_zone_gating() + _test_zone_unlock() + _test_zone_beauty_is_per_zone() + _test_ramp_continuity() + _test_serialization_round_trip() + _test_legacy_save_migration() + _test_number_format() + _test_korean_particles() + + if _failures == 0: + print("모든 검사 통과") + else: + print("실패 %d건" % _failures) + get_tree().quit(1 if _failures > 0 else 0) + + +func check(label: String, condition: bool, detail := "") -> void: + if condition: + print(" ok ", label) + else: + _failures += 1 + print(" FAIL ", label, " ", detail) + + +## 모든 구역을 열고 거품을 넉넉히 준 상태에서 시작한다. +func _rich_and_open() -> void: + GameState.reset() + GameState.unlocked_zones = Catalog.zone_count() + GameState.bubbles = 1e15 + + +func _test_click() -> void: + print("클릭") + 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)) + + +func _test_producer_cost_curve() -> 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]) + + +func _test_place() -> void: + print("배치") + GameState.reset() + GameState.bubbles = 1000.0 + 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)) + + check("같은 칸에 또 놓을 수 없다", GameState.place("producer", "seaweed", 2, 3) == -1) + check("격자 밖에는 놓을 수 없다", GameState.place("producer", "seaweed", 2, 99) == -1) + + GameState.bubbles = 5.0 + check("잔액이 모자라면 못 놓는다", GameState.place("producer", "bubble_stone", 5, 0) == -1) + check("실패했으면 잔액 그대로", is_equal_approx(GameState.bubbles, 5.0)) + + +func _test_footprint_and_multi_tile() -> void: + print("여러 칸짜리") + _rich_and_open() + var span := Catalog.size_of("ornament", "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 covered := 0 + for col in range(left, left + 3): + for row in 2: + if GameState.placement_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) + + +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)) + # 항아리(2x1)를 마지막 열에 놓으면 다음 구역까지 걸친다. + check("구역을 걸치면 못 놓는다", + not GameState.can_place_at("ornament", "amphora", last, 0)) + check("한 구역 안에 들어가면 놓인다", + GameState.can_place_at("ornament", "amphora", last - 1, 0)) + + +func _test_move() -> void: + print("옮기기") + _rich_and_open() + var index := GameState.place("producer", "seaweed", 1, 1) + var spent := GameState.bubbles + + check("빈 칸으로 옮겨진다", GameState.move(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) + + 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)) + + +func _test_remove_refund() -> void: + print("치우기와 환불") + _rich_and_open() + GameState.place("producer", "seaweed", 0, 0) + var index := GameState.place("producer", "seaweed", 1, 0) + var before := GameState.bubbles + # 두 번째 해초 가격은 올림(110 * 1.15). 그 절반이 돌아온다. + var expected: float = floor(ceil(110.0 * 1.15) * Catalog.REFUND_RATE) + + var refund := GameState.remove(index) + 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) + + 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) + + +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, + str(GameState.free_cells_in_zone(0))) + + var placed := 0 + while true: + var spot := GameState.first_free_spot("producer", "seaweed") + if spot.x < 0: + break + if GameState.place("producer", "seaweed", spot.x, spot.y) < 0: + break + placed += 1 + + check("칸이 차면 더 못 놓는다", placed == total, str(placed)) + check("남은 칸 0", GameState.free_cells_in_zone(0) == 0) + check("거품이 남아도 못 놓는다", GameState.place("producer", "seaweed", 0, 0) == -1) + check("다른 구역은 아직 비어 있다", + GameState.free_cells_in_zone(1) == total, str(GameState.free_cells_in_zone(1))) + + +func _test_zone_gating() -> void: + print("구역 잠금") + GameState.reset() + 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("실패했으면 잔액 그대로", 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) + + +func _test_zone_unlock() -> void: + print("구역 해금") + GameState.reset() + var need_beauty := GameState.next_zone_required_beauty() + var cost := GameState.next_zone_cost() + check("2구역 해금 조건이 정의되어 있다", need_beauty > 0.0 and cost > 0.0, + "beauty=%f cost=%f" % [need_beauty, cost]) + + GameState.bubbles = cost * 4.0 + check("앞 구역을 안 꾸미면 못 연다", not GameState.can_unlock_next()) + 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: + break + check("칸 안에서 조건을 채울 수 있다", GameState.beauty_in_zone(0) >= need_beauty, + "%f / %f" % [GameState.beauty_in_zone(0), need_beauty]) + + GameState.bubbles = cost + check("조건을 채우면 열 수 있다", GameState.can_unlock_next(), GameState.next_zone_blocker()) + check("해금 성공", GameState.unlock_next_zone()) + check("해금 비용이 빠진다", is_equal_approx(GameState.bubbles, 0.0), str(GameState.bubbles)) + check("열린 구역이 2개", GameState.unlocked_zones == 2) + + +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 + + check("0구역에 3", is_equal_approx(GameState.beauty_in_zone(0), 3.0), + str(GameState.beauty_in_zone(0))) + check("1구역에 8", is_equal_approx(GameState.beauty_in_zone(1), 8.0), + str(GameState.beauty_in_zone(1))) + check("총합은 11", is_equal_approx(GameState.beauty, 11.0), str(GameState.beauty)) + check("건드리지 않은 구역은 0", is_equal_approx(GameState.beauty_in_zone(4), 0.0)) + + +func _test_ramp_continuity() -> void: + print("색 램프") + var n := Catalog.zone_count() + check("왼쪽 끝은 첫 구역 색", + Catalog.ramp_color("tint", 0.0).is_equal_approx(Catalog.ZONES[0].tint)) + 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 + 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) + + +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.bubbles = 12345.0 + GameState.recalc() + var bps_before := GameState.bps + + # 실제 저장 경로와 똑같이 JSON 문자열을 거쳐서 되돌린다. + var text := JSON.stringify(GameState.to_dict()) + GameState.reset() + 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())) + var wreck := Catalog.ZONE_COLUMNS + 1 + check("배치된 칸까지 그대로", + GameState.placement_at(2, 1) >= 0 and GameState.placement_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) + check("열린 구역 수 복원", GameState.unlocked_zones == Catalog.zone_count(), + str(GameState.unlocked_zones)) + check("초당 생산량이 그대로", is_equal_approx(GameState.bps, bps_before), + "%f vs %f" % [GameState.bps, bps_before]) + + +func _test_legacy_save_migration() -> void: + print("옛 저장본 이전") + GameState.reset() + # 격자가 없던 시절의 저장본. 개수만 있고 위치가 없다. + GameState.from_dict({ + "bubbles": 500.0, + "unlocked_zones": 2, + "producers": {"seaweed": 4, "clownfish": 2}, + "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) + + GameState.from_dict({"bubbles": 1.0}) + check("구역 개념이 없던 저장본은 1구역으로", GameState.unlocked_zones == 1) + + +func _test_number_format() -> void: + print("숫자 표기") + check("999", NumberFormat.short(999.0) == "999", NumberFormat.short(999.0)) + check("1.23K", NumberFormat.short(1234.0) == "1.23K", NumberFormat.short(1234.0)) + check("12.3M", NumberFormat.short(12_345_678.0) == "12.3M", NumberFormat.short(12_345_678.0)) + check("1.00B", NumberFormat.short(1e9) == "1.00B", NumberFormat.short(1e9)) + check("소수점", NumberFormat.short(2.5) == "2.5", NumberFormat.short(2.5)) + check("시간 표기", NumberFormat.duration(3 * 3600 + 720) == "3시간 12분", + NumberFormat.duration(3 * 3600 + 720)) + + +func _test_korean_particles() -> void: + print("한글 조사") + check("받침 있으면 을", Korean.eul("켈프 숲") == "켈프 숲을", Korean.eul("켈프 숲")) + check("받침 없으면 를", Korean.eul("얕은 바다") == "얕은 바다를", Korean.eul("얕은 바다")) + check("받침 있으면 이", Korean.i("등불") == "등불이", Korean.i("등불")) + check("받침 없으면 가", Korean.i("심해") == "심해가", Korean.i("심해")) + var ok := true + for z in Catalog.ZONES: + if not Korean.eul(String(z.name)).begins_with(String(z.name)): + ok = false + check("구역 이름 전부 처리된다", ok) diff --git a/tests/smoke.gd.uid b/tests/smoke.gd.uid new file mode 100644 index 0000000..61334f9 --- /dev/null +++ b/tests/smoke.gd.uid @@ -0,0 +1 @@ +uid://bldorg6w5weud diff --git a/tests/smoke.tscn b/tests/smoke.tscn new file mode 100644 index 0000000..faeed87 --- /dev/null +++ b/tests/smoke.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/smoke.gd" id="1_smoke"] + +[node name="Smoke" type="Node"] +script = ExtResource("1_smoke")