바탕화면 바닷속 방치형 위젯 첫 커밋

화면 아래 가장자리에 가로로 깔리는 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:
2026-09-01 17:59:50 +09:00
commit 446d8d177c
48 changed files with 3584 additions and 0 deletions
+135
View File
@@ -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()