바탕화면 바닷속 방치형 위젯 첫 커밋
화면 아래 가장자리에 가로로 깔리는 Godot 4.7 위젯. 거품을 모아 바다를 꾸미고, 꾸밀수록 거품이 더 빨리 모인다. - 도킹 창: 투명·테두리 없음, 화면 폭 전체, 위/아래 가장자리 스냅, 높이 프리셋, 클릭 통과(모서리로 복귀), DPI 배율 보정 - 3/4 부감 격자: 구역당 9x6, 전체 45x6 = 270칸. 바닥은 위에서, 물체는 서 있는 모습으로 그리고 Y 정렬로 앞뒤를 가린다 - 구역 5개(얕은 바다 -> 열수구): 앞 구역을 꾸며야 다음이 열린다 - 칸이 곧 한계인 경제: 배치/이동/치우기(절반 환불), 여러 칸짜리 장식 - 저장: 30초 자동 저장, 백업 한 세대, 8시간 상한 오프라인 보상, 격자 이전 형식 저장본 자동 이전 - 스모크 테스트 60여 개 (test.ps1) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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])
|
||||
@@ -0,0 +1 @@
|
||||
uid://cll3sln3ona6i
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bs7dqgqr66wdt
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://d0hhbrpalxgbg
|
||||
Reference in New Issue
Block a user