Files
blub-blub/autoload/save_manager.gd
T
ttipo 89069b06f0 클리커에서 집·일꾼 방치형 구조로 전환
거품은 이제 클릭이 아니라 바다에 있는 생물이 시간에 따라 번다. 화면 가장자리에
깔아 두는 위젯인데 클릭이 주된 수입원이면 연타가 최적 플레이가 되어 형태와
어긋난다. 클릭 수익과 클릭 업그레이드를 걷어내고 업그레이드는 전부 생산 배율로
바꿨다. 물을 만지면 거품이 퍼지지만 얻는 것은 없다.

격자 칸을 차지하는 것은 집뿐이고, 생물은 칸이 아니라 집에 산다(한 채당 3마리).
칸 → 집 → 생물 → 거품으로 한계가 두 겹 걸린다. 예전에는 생물과 장식이 같은
자원(칸)을 놓고 경쟁해서 최적 플레이가 늘 "장식을 사지 말 것"이었는데, 집과
일꾼으로 나누니 그 경쟁이 의존으로 바뀐다. 집은 같은 종류도 여러 채 지을 수 있고
거품을 벌지 않는다 — 정원과 생산을 한 물건이 주면 곱연산이 두 번 걸린다.

어느 생물이 어느 집에 사는지는 저장하지 않는다. 집은 정원만 주므로 어디 살든
결과가 같고, 저장하면 재배정과 인덱스 관리만 붙는다. 그릴 때 구역마다 배정한다.
그래서 저장 형식에서 생물은 위치 없이 {id: 마리 수}다. 옛 저장본은 그때의
장식을 집으로, 생물을 마릿수로 옮겨오고 정원이 모자라면 집을 세워 메운다.

수입이 생물에서만 나오므로 막다른 길을 두 곳에서 막았다. 마지막 생물은 내보낼 수
없고, 살던 생물이 갈 곳이 없어지는 집은 헐 수 없다.

집과 생물의 그림 형태를 갈랐다. 집 10채 중 8채가 생물과 같은 rock이어서 항아리와
거품돌이 색만 다른 같은 덩어리로 보였다. 집은 vessel/wreck/pillar만, 생물은
plant/rock/fish/glow만 쓴다. 헤엄치는 생물은 집을 도는 타원 궤도에 올라가고,
원점이 궤도를 따라 움직이므로 Y 정렬이 앞뒤를 알아서 바꿔준다 — 뒤로 돌면 집에
가렸다가 앞으로 나온다. 얕은 바다 생물이 거품돌과 해초뿐이라 이 움직임을 볼 수
없어서 구피를 넣었다.

실행해 보고 잡은 버그 셋:
- 저장본이 없으면 load_game이 그냥 반환해 빈 바다로 시작했다. 집도 생물도 없어
  아무 일도 일어나지 않는다. 이제 새 게임을 시작한다.
- 창 높이·도킹·모니터 설정이 복원되지 않고 기본값으로 덮어써지고 있었다.
  window_state에 미리 있던 키만 복사하던 것을 저장본에 있는 것을 전부 싣도록 고쳤다.
- 정보 바가 격자 첫 줄을 덮어 왼쪽 위에 놓인 것이 보이지 않았다. GRID_TOP 상수를
  버리고 HUD가 잰 실제 바 높이를 받아 쓴다.
- main.gd가 초기화할 때 존재하지 않는 aquarium.rebuild()를 부르고 있었다.

스모크 테스트 159개.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uze4mF1NAGfFRio77cMFqg
2026-09-01 22:39:25 +09:00

136 lines
4.2 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가 _remember_window()로 채우고 _ready()에서 읽는다.
## 기본값은 여기가 아니라 main.gd의 get(키, 기본값)에만 둔다. 두 군데 두면 어긋난다.
## 지금 들어오는 키: always_on_top / click_through / edge_bottom / height / screen
var window_state := {}
## 테스트처럼 실제 저장 파일을 건드리면 안 되는 상황에서 끈다.
## 끄지 않으면 스모크 테스트가 종료될 때 초기화된 상태를 저장해 진행도를 날린다.
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():
# 저장본이 없다 = 이 컴퓨터에서 처음 켰다. 시작 집과 생물을 놓아준다.
# 이게 없으면 빈 바다에서 시작해 아무 일도 일어나지 않는다.
GameState.reset()
return
GameState.from_dict(data.get("state", {}))
# 저장본에 있는 창 설정을 전부 실어 온다. 예전에는 window_state에 미리
# 들어 있던 키만 복사해서, 높이·도킹·모니터 설정이 조용히 사라졌다.
for key in data.get("window", {}):
window_state[key] = data["window"][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()