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", {}))