Explosion_der_Spiele/scripts/autoload/game_state.gd
copilot 1cc8aa9b9d Add village 3D wiring, voice lines, and 1.74 m world scale.
Houses ~8.5 m; chimps capped ~0.85 m with safer axis fitting. Large GLB/PBR packs stay local until Git LFS (Forgejo HTTP 413).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:02:57 +02:00

360 lines
10 KiB
GDScript

extends Node
## Global game flow matching DESIGN.md.
signal hp_changed(hp: int, max_hp: int)
signal died(cause_key: String)
signal scene_changed(scene_id: String)
signal status_changed
const SAVE_PATH := "user://save.json"
const SCENE_PATHS := {
"title_screen": "res://scenes/title_screen.tscn",
"intro_cutscene": "res://scenes/intro_cutscene.tscn",
## Hauptwelt: Dorf + Reif-Arena + Wiese (contiguous). Legacy ids redirect here.
"village": "res://scenes/village.tscn",
"shoe_boss": "res://scenes/village.tscn",
"meadow": "res://scenes/village.tscn",
"massage_studio": "res://scenes/village.tscn",
"catscene": "res://scenes/catscene.tscn",
"farm_path": "res://scenes/farm_path.tscn",
"farm": "res://scenes/farm.tscn",
"dressler_boss": "res://scenes/dressler_boss.tscn",
"moon": "res://scenes/moon.tscn",
"final_boss": "res://scenes/final_boss.tscn",
"end_screen": "res://scenes/end_screen.tscn",
"death_screen": "res://scenes/death_screen.tscn",
}
## Default linear beats (branches override via goto_scene / flags).
## Village/shoe/meadow are one overworld — next story beat is Catscene.
const SCENE_ORDER: Array[String] = [
"title_screen",
"intro_cutscene",
"village",
"catscene",
"farm_path",
"farm",
"dressler_boss",
"moon",
"final_boss",
"end_screen",
]
var current_scene_id: String = "title_screen"
## Resume target that survives title/death visits (not wiped when opening title).
var continue_scene_id: String = ""
## Optional: { "scene": String, "x": float, "y": float, "z": float }
var saved_player_pose: Dictionary = {}
var max_hp: int = 4
var hp: int = 4
var death_count: int = 0
var last_death_cause_key: String = "death_default"
var playtime_seconds: float = 0.0
var house_built: bool = false
var flags: Dictionary = {}
## Status: fat (tiramisu), glitched (3 glitzo hits), urhahn shock
var fat_time_left: float = 0.0
var glitch_hits: int = 0
var shock_time_left: float = 0.0
const _NO_CONTINUE_SCENES := ["title_screen", "death_screen", ""]
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
load_game()
func _process(delta: float) -> void:
if get_tree().paused:
return
if current_scene_id in ["title_screen", "death_screen"]:
return
playtime_seconds += delta
if playtime_seconds >= 3600.0:
Achievements.unlock("played_hour")
var dirty := false
if fat_time_left > 0.0:
fat_time_left = max(0.0, fat_time_left - delta)
dirty = true
if shock_time_left > 0.0:
shock_time_left = max(0.0, shock_time_left - delta)
dirty = true
if dirty:
status_changed.emit()
func is_fat() -> bool:
return fat_time_left > 0.0
func is_shocked() -> bool:
return shock_time_left > 0.0
func is_glitched_slow() -> bool:
return glitch_hits >= 3
func apply_fat(seconds: float = 600.0) -> void:
fat_time_left = max(fat_time_left, seconds)
Achievements.unlock("tiramisu_tonne")
status_changed.emit()
func apply_shock(seconds: float = 10.0) -> void:
shock_time_left = max(shock_time_left, seconds)
status_changed.emit()
func register_glitzo_hit() -> void:
glitch_hits += 1
status_changed.emit()
take_damage(1, "death_glitzo")
func clear_glitch() -> void:
glitch_hits = 0
status_changed.emit()
func _normalize_scene_id(scene_id: String) -> String:
if scene_id in ["massage_studio", "shoe_boss", "meadow"]:
return "village"
return scene_id
func is_continue_scene(scene_id: String) -> bool:
var sid := _normalize_scene_id(scene_id)
return sid not in _NO_CONTINUE_SCENES and SCENE_PATHS.has(sid)
func has_continue_save() -> bool:
return is_continue_scene(continue_scene_id)
func get_continue_scene_id() -> String:
if not has_continue_save():
return ""
return _normalize_scene_id(continue_scene_id)
func continue_game() -> void:
var sid := get_continue_scene_id()
if sid.is_empty():
start_new_game()
return
goto_scene(sid)
func _clear_run_progress() -> void:
## Fresh run: inventory + quest flags + statuses. Keep meta (achievements/shop/settings/deaths).
hp = max_hp
glitch_hits = 0
fat_time_left = 0.0
shock_time_left = 0.0
flags.clear()
house_built = false
continue_scene_id = ""
saved_player_pose.clear()
Inventory.deserialize({"items": {}})
status_changed.emit()
hp_changed.emit(hp, max_hp)
func _capture_player_pose_if_any() -> void:
if not is_continue_scene(current_scene_id):
return
var tree := get_tree()
if tree == null:
return
var player := tree.get_first_node_in_group("player")
if player is Node3D:
var p := (player as Node3D).global_position
saved_player_pose = {
"scene": _normalize_scene_id(current_scene_id),
"x": p.x,
"y": p.y,
"z": p.z,
}
func consume_saved_player_position() -> Variant:
## Returns Vector3 to restore, or null if none / wrong scene.
if saved_player_pose.is_empty():
return null
var pose_scene := _normalize_scene_id(str(saved_player_pose.get("scene", "")))
if pose_scene != _normalize_scene_id(current_scene_id):
return null
if not saved_player_pose.has("x"):
return null
return Vector3(
float(saved_player_pose.get("x", 0.0)),
float(saved_player_pose.get("y", 0.0)),
float(saved_player_pose.get("z", 0.0)),
)
func goto_scene(scene_id: String) -> void:
## Legacy saves / links: shoe boss, meadow, massage are the village overworld.
scene_id = _normalize_scene_id(scene_id)
if not SCENE_PATHS.has(scene_id):
push_error("Unknown scene: %s" % scene_id)
return
## Leaving a gameplay scene via story/teleport: drop pose unless staying on same id.
if scene_id != current_scene_id and not saved_player_pose.is_empty():
var pose_scene := _normalize_scene_id(str(saved_player_pose.get("scene", "")))
if pose_scene != scene_id:
saved_player_pose.clear()
current_scene_id = scene_id
if is_continue_scene(scene_id):
continue_scene_id = scene_id
scene_changed.emit(scene_id)
if scene_id == "moon":
Achievements.unlock("moon_cheese")
save_game()
# VN dialog / pause must not leak across scenes (breaks title timers).
var tree := get_tree()
if tree == null:
tree = Engine.get_main_loop() as SceneTree
if tree == null:
push_error("goto_scene: no SceneTree")
return
if tree.paused:
tree.paused = false
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("force_close_dialog"):
ui.force_close_dialog()
tree.change_scene_to_file(SCENE_PATHS[scene_id])
func goto_next() -> void:
match current_scene_id:
"village", "shoe_boss", "meadow", "massage_studio":
## Contiguous overworld: only advance after massage — never teleport to shoe/meadow.
if get_flag("snake_massaged", false) or get_flag("bear_saved", false):
goto_scene("catscene")
## else: stay put (ProgressHouse / ExitTrigger show hints in village.gd)
"catscene":
goto_scene("farm_path")
"farm_path":
if get_flag("planet_destroyed", false):
goto_scene("moon")
else:
goto_scene("farm")
"farm":
goto_scene("dressler_boss")
"dressler_boss":
goto_scene("moon")
"moon":
goto_scene("final_boss")
"final_boss":
goto_scene("end_screen")
_:
var idx := SCENE_ORDER.find(current_scene_id)
if idx < 0 or idx >= SCENE_ORDER.size() - 1:
goto_scene("end_screen")
else:
goto_scene(SCENE_ORDER[idx + 1])
func skip_to_final_boss() -> void:
Achievements.unlock("milk_skip")
flags["milk_skip_used"] = true
goto_scene("final_boss")
func reset_to_intro() -> void:
## Catscene skip punishment.
hp = max_hp
glitch_hits = 0
fat_time_left = 0.0
shock_time_left = 0.0
flags.clear()
Inventory.deserialize({"items": {}})
goto_scene("intro_cutscene")
func start_new_game() -> void:
hp = max_hp
glitch_hits = 0
fat_time_left = 0.0
shock_time_left = 0.0
flags.clear()
house_built = false
goto_scene("intro_cutscene")
func heal_full() -> void:
hp = max_hp
glitch_hits = 0
hp_changed.emit(hp, max_hp)
func take_damage(amount: int = 1, cause_key: String = "death_default") -> void:
hp = max(0, hp - amount)
hp_changed.emit(hp, max_hp)
if hp <= 0:
die(cause_key)
func die(cause_key: String = "death_default") -> void:
last_death_cause_key = cause_key
death_count += 1
died.emit(cause_key)
if death_count >= 100:
Achievements.unlock("died_100")
save_game()
goto_scene("death_screen")
func revive_continue() -> void:
heal_full()
## Return to last non-death gameplay scene, default village.
var back := str(flags.get("last_gameplay_scene", "village"))
if back == "death_screen" or back == "title_screen":
back = "village"
goto_scene(back)
func quit_to_title() -> void:
heal_full()
goto_scene("title_screen")
func set_flag(key: String, value: Variant = true) -> void:
flags[key] = value
save_game()
func get_flag(key: String, default_value: Variant = false) -> Variant:
return flags.get(key, default_value)
func remember_gameplay_scene() -> void:
if current_scene_id not in ["title_screen", "death_screen", "intro_cutscene", "catscene"]:
flags["last_gameplay_scene"] = current_scene_id
func save_game() -> void:
remember_gameplay_scene()
var data := {
"hp": hp,
"max_hp": max_hp,
"death_count": death_count,
"last_death_cause_key": last_death_cause_key,
"playtime_seconds": playtime_seconds,
"current_scene_id": current_scene_id,
"house_built": house_built,
"flags": flags,
"fat_time_left": fat_time_left,
"glitch_hits": glitch_hits,
"inventory": Inventory.serialize(),
"achievements": Achievements.serialize(),
"shop": Shop.serialize(),
"settings": Settings.serialize(),
}
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file == null:
return
file.store_string(JSON.stringify(data, "\t"))
func load_game() -> void:
if not FileAccess.file_exists(SAVE_PATH):
return
var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
if file == null:
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
if typeof(parsed) != TYPE_DICTIONARY:
return
var data: Dictionary = parsed
hp = int(data.get("hp", max_hp))
max_hp = int(data.get("max_hp", max_hp))
death_count = int(data.get("death_count", 0))
last_death_cause_key = str(data.get("last_death_cause_key", "death_default"))
playtime_seconds = float(data.get("playtime_seconds", 0.0))
current_scene_id = str(data.get("current_scene_id", "title_screen"))
if current_scene_id in ["shoe_boss", "meadow", "massage_studio"]:
current_scene_id = "village"
house_built = bool(data.get("house_built", false))
flags = data.get("flags", {})
if str(flags.get("last_gameplay_scene", "")) in ["shoe_boss", "meadow", "massage_studio"]:
flags["last_gameplay_scene"] = "village"
fat_time_left = float(data.get("fat_time_left", 0.0))
glitch_hits = int(data.get("glitch_hits", 0))
Inventory.deserialize(data.get("inventory", {}))
Achievements.deserialize(data.get("achievements", {}))
Shop.deserialize(data.get("shop", {}))
Settings.deserialize(data.get("settings", {}))