446d8d177c
화면 아래 가장자리에 가로로 깔리는 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>
136 lines
3.7 KiB
GDScript
136 lines
3.7 KiB
GDScript
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()
|