@tool extends Node3D ## Hauptwelt: Dorf + Zaun/Tor + Wiese nebeneinander. Knopf → Reif vor Ort → Tor öffnet → Wiese zu Fuß. ## @tool: editor_preview_spawns shows deterministic ModelProp stand-ins at slot positions ## (never owned/saved; runtime clears them before random spawn). const HOUSES_DIR := "res://assets/3DModelle/houses" const ANIMALS_DIR := "res://assets/3DModelle/animals" const MASSEUR_HOUSE_MODEL := "res://assets/3DModelle/houses/massour/massourhaus.glb" const COW_MODEL := "res://assets/3DModelle/animals/cow/cow.glb" const MODEL_PROP_SCENE := "res://scenes/prefabs/model_prop.tscn" const ENEMY_SCENE := "res://scenes/prefabs/enemy.tscn" const HOUSE_POOL_SKIP := ["massour"] const ANIMAL_POOL_SKIP := ["cow"] ## cow = Masseur-Kuh only const EDITOR_PREVIEW_PREFIX := "_EditorPreview_" ## Heights from data/model_scales.json via SpriteFit (scale lab SSOT). const HOUSE_SLOTS: Array[Dictionary] = [ {"pos": Vector3(7.0, 0.0, -6.0), "yaw": 15.0, "mul": 1.0}, {"pos": Vector3(-9.0, 0.0, -7.0), "yaw": 40.0, "mul": 0.95}, {"pos": Vector3(12.0, 0.0, -11.0), "yaw": -25.0, "mul": 0.9}, {"pos": Vector3(-4.0, 0.0, -14.0), "yaw": 85.0, "mul": 1.05}, {"pos": Vector3(14.0, 0.0, -3.0), "yaw": -50.0, "mul": 0.92}, {"pos": Vector3(-14.0, 0.0, -12.0), "yaw": 130.0, "mul": 0.88}, ] const ANIMAL_SLOTS: Array[Dictionary] = [ {"pos": Vector3(2.5, 0.0, 2.0), "yaw": 40.0, "mul": 1.0}, {"pos": Vector3(-2.0, 0.0, -1.5), "yaw": -70.0, "mul": 0.9}, {"pos": Vector3(6.5, 0.0, 0.5), "yaw": 15.0, "mul": 0.85}, {"pos": Vector3(-8.0, 0.0, 1.0), "yaw": 110.0, "mul": 1.05}, {"pos": Vector3(1.0, 0.0, -6.0), "yaw": -30.0, "mul": 1.1}, {"pos": Vector3(-4.5, 0.0, -5.5), "yaw": 200.0, "mul": 0.8}, {"pos": Vector3(9.0, 0.0, -4.0), "yaw": -100.0, "mul": 0.75}, ] ## When on (editor only): spawn representative houses/animals at slot positions. @export var editor_preview_spawns: bool = true: set(v): editor_preview_spawns = v if Engine.is_editor_hint() and is_node_ready(): _editor_refresh_preview() @onready var choice_ui: CanvasLayer = $ChoiceUI var _flee_dirs: Dictionary = {} var _throw_cd: float = 1.2 var _reif_alive: bool = false var _reif_fight_active: bool = false var _gate_open: bool = false func _ready() -> void: if Engine.is_editor_hint(): _editor_refresh_preview() set_process(false) set_physics_process(false) return ## Drop any leftover editor stand-ins before runtime spawn (no double houses). _free_editor_previews() GameState.current_scene_id = "village" GameState.heal_full() _clear_legacy_house_nodes() _spawn_random_houses() _spawn_random_animals() _spawn_masseur_house() _spawn_cow_masseur() _spawn_progress_house() _setup_table_button() _setup_reif() _setup_gate() if choice_ui: choice_ui.visible = false # Opening bark must not hard-block entry (blocking VN + player mouse capture race). var ui := get_node_or_null("/root/UIRoot") if ui and ui.has_method("show_dialog_key"): if Inventory.has_item("snake") and not GameState.get_flag("snake_massaged", false): ui.show_dialog_key("dialog_villager_snake", 3.5, false) else: ui.show_dialog_key("dialog_villager_run", 3.5, false) var dentist := get_node_or_null("Dentist") if dentist: dentist.give_item_id = "" dentist.achievement_id = "" dentist.quest_flag = "" dentist.dialog_key = "dialog_villager_snake" var fan := get_node_or_null("NPC_HongkongFan") if fan: fan.give_item_id = "" fan.dialog_key = "dialog_villager_run" _setup_flee() func _notification(what: int) -> void: if what == NOTIFICATION_EDITOR_PRE_SAVE: ## Belt-and-suspenders: never persist preview meshes into village.tscn. _strip_preview_owners() func _free_editor_previews() -> void: var to_free: Array[Node] = [] for child in get_children(): if str(child.name).begins_with(EDITOR_PREVIEW_PREFIX): to_free.append(child) for n in to_free: remove_child(n) n.free() func _strip_preview_owners() -> void: for child in get_children(): if str(child.name).begins_with(EDITOR_PREVIEW_PREFIX): _clear_owner_recursive(child) func _clear_owner_recursive(n: Node) -> void: n.owner = null for c in n.get_children(): _clear_owner_recursive(c) func _editor_refresh_preview() -> void: if not Engine.is_editor_hint(): return _free_editor_previews() if not editor_preview_spawns: return ## Deterministic pool picks so layout is stable while editing (runtime still random). var houses := _list_random_house_models() var animals := _list_random_animal_models() var i := 0 for slot in HOUSE_SLOTS: if houses.is_empty(): break var path: String = houses[i % houses.size()] _spawn_prop( "%sRandomHouse_%d" % [EDITOR_PREVIEW_PREFIX, i], path, slot["pos"], float(slot.get("yaw", 0.0)), SpriteFit.fit_height_for(path, SpriteFit.WORLD_HOUSE_HEIGHT), SpriteFit.fit_max_width_for(path, SpriteFit.WORLD_HOUSE_MAX_WIDTH), float(slot.get("mul", 1.0)) ) i += 1 i = 0 for slot in ANIMAL_SLOTS: if animals.is_empty(): break var apath: String = animals[i % animals.size()] _spawn_prop( "%sRandomAnimal_%d" % [EDITOR_PREVIEW_PREFIX, i], apath, slot["pos"], float(slot.get("yaw", 0.0)), SpriteFit.animal_height(apath), SpriteFit.animal_max_width(apath), float(slot.get("mul", 1.0)), false, false ) i += 1 if ResourceLoader.exists(MASSEUR_HOUSE_MODEL): _spawn_prop( "%sMasseurHouse" % EDITOR_PREVIEW_PREFIX, MASSEUR_HOUSE_MODEL, Vector3(-5.5, 0.0, 2.5), -40.0, SpriteFit.fit_height_for(MASSEUR_HOUSE_MODEL, SpriteFit.WORLD_HOUSE_HEIGHT), SpriteFit.fit_max_width_for(MASSEUR_HOUSE_MODEL, SpriteFit.WORLD_HOUSE_MAX_WIDTH), 1.0, false ) if ResourceLoader.exists(COW_MODEL): _spawn_prop( "%sCowMasseur" % EDITOR_PREVIEW_PREFIX, COW_MODEL, Vector3(-4.2, 0.0, 3.8), 140.0, SpriteFit.fit_height_for(COW_MODEL, SpriteFit.WORLD_COW_HEIGHT), SpriteFit.fit_max_width_for(COW_MODEL, 2.4), 1.0, true, false ) if not houses.is_empty(): var ppath: String = houses[0] _spawn_prop( "%sProgressHouse" % EDITOR_PREVIEW_PREFIX, ppath, Vector3(0.0, 0.0, -16.0), 0.0, SpriteFit.fit_height_for(ppath, SpriteFit.WORLD_HOUSE_HEIGHT), SpriteFit.fit_max_width_for(ppath, SpriteFit.WORLD_HOUSE_MAX_WIDTH), 1.0, false ) func _setup_table_button() -> void: var btn := get_node_or_null("Table/TableButton") if btn == null: return btn.set("on_success_goto", "") btn.set("can_interact", true) btn.set("prompt_key", "prompt_press_button") if btn.has_signal("interacted") and not btn.interacted.is_connected(_on_table_button): btn.interacted.connect(_on_table_button) func _on_table_button(_prop: Node = null) -> void: if GameState.get_flag("reif_defeated", false): var ui := get_node_or_null("/root/UIRoot") if ui and ui.has_method("show_dialog_key"): ui.show_dialog_key("exit_after_reif", 3.5) return if _reif_fight_active: return _start_reif_fight() func _setup_reif() -> void: var reif := get_node_or_null("Reif") if reif == null: return if GameState.get_flag("reif_defeated", false): reif.queue_free() _reif_alive = false _reif_fight_active = false return ## Dormant until table button — no chase / no interact yet. reif.visible = false reif.set("ai_enabled", false) reif.process_mode = Node.PROCESS_MODE_DISABLED if reif.is_in_group("interactable"): reif.remove_from_group("interactable") if reif.is_in_group("enemy"): reif.remove_from_group("enemy") _reif_alive = true _reif_fight_active = false func _start_reif_fight() -> void: var reif := get_node_or_null("Reif") if reif == null: reif = _spawn_reif() if reif == null: return _reif_fight_active = true _reif_alive = true reif.visible = true reif.process_mode = Node.PROCESS_MODE_INHERIT if not reif.is_in_group("interactable"): reif.add_to_group("interactable") if not reif.is_in_group("enemy"): reif.add_to_group("enemy") reif.set("ai_enabled", true) reif.set("move_speed", 3.2) reif.set("attack_damage", 1) reif.set("attack_cooldown", 1.1) reif.set("attack_range", 1.25) reif.set("death_cause_key", "death_shoe") reif.set("shout_key", "dialog_reif") reif.set("shout_interval", 5.0) reif.set("billboard_height", SpriteFit.WORLD_BOSS_HEIGHT) reif.set("max_hp", 8) reif.set("hp", 8) reif.set("defeat_flag", "reif_defeated") if reif.has_signal("defeated") and not reif.defeated.is_connected(_on_reif_defeated): reif.defeated.connect(_on_reif_defeated) var ui := get_node_or_null("/root/UIRoot") if ui and ui.has_method("show_dialog_key"): ui.show_dialog_key("dialog_reif") func _spawn_reif() -> Node3D: var packed: PackedScene = load(ENEMY_SCENE) if packed == null: return null var reif: Node3D = packed.instantiate() reif.name = "Reif" reif.set("max_hp", 8) reif.set("texture_path", "res://assets/sprites/placeholders/reif.png") reif.set("billboard_height", SpriteFit.WORLD_BOSS_HEIGHT) reif.set("defeat_flag", "reif_defeated") add_child(reif) reif.global_position = Vector3(0.0, 0.1, -1.5) return reif func _physics_process(delta: float) -> void: if Engine.is_editor_hint(): return if not _reif_fight_active or not _reif_alive: return var reif := get_node_or_null("Reif") if reif == null or not is_instance_valid(reif): _reif_alive = false return _throw_cd -= delta if _throw_cd > 0.0: return _throw_cd = randf_range(1.6, 2.4) _throw_shoe(reif as Node3D) func _throw_shoe(reif: Node3D) -> void: var player := get_tree().get_first_node_in_group("player") if player == null or not (player is Node3D): return var dist := reif.global_position.distance_to((player as Node3D).global_position) if dist < 2.0 and randf() > 0.35: return var scene: PackedScene = load("res://scenes/prefabs/shoe_projectile.tscn") if scene == null: return var shoe: Node3D = scene.instantiate() add_child(shoe) shoe.global_position = reif.global_position + Vector3(0, 1.1, 0) var target: Vector3 = (player as Node3D).global_position + Vector3(0, 0.9, 0) var to_target := target - shoe.global_position var horiz := Vector3(to_target.x, 0.0, to_target.z) var speed := 9.0 if horiz.length() > 0.01: horiz = horiz.normalized() else: horiz = Vector3(0, 0, 1) var vy := 3.5 + dist * 0.35 shoe.set("velocity", Vector3(horiz.x * speed, vy, horiz.z * speed)) shoe.set("damage", 1) var ui := get_node_or_null("/root/UIRoot") if ui and ui.has_method("show_dialog") and randf() < 0.4: ui.show_dialog_key("dialog_reif", 1.5, false) AudioDirector.play_hit() func _on_reif_defeated() -> void: _reif_alive = false _reif_fight_active = false for child in get_children(): if child.is_in_group("shoe_projectile"): child.queue_free() _spawn_tiramisu_tonne() GameState.set_flag("reif_defeated", true) _open_gate(true) var ui := get_node_or_null("/root/UIRoot") if ui and ui.has_method("show_dialog"): ui.show_dialog(Locale.tr_key("prompt_eat_tiramisu") + "\n" + Locale.tr_key("exit_after_reif"), 5.0) func _spawn_tiramisu_tonne() -> void: if get_node_or_null("TiramisuTonne") != null: return var scene: PackedScene = load("res://scenes/prefabs/npc.tscn") if scene == null: return var tonne: Node3D = scene.instantiate() tonne.name = "TiramisuTonne" tonne.set("dialog_key", "dialog_tiramisu") tonne.set("texture_path", "res://assets/sprites/placeholders/tiramisu.png") tonne.set("billboard_height", 1.8) tonne.set("billboard_max_width", 2.2) tonne.set("give_item_id", "") tonne.set("on_success_goto", "") tonne.set("quest_flag", "") add_child(tonne) tonne.global_position = Vector3(0, 0.1, -2) var bill = tonne.get_node_or_null("BillboardSprite") if bill: if bill.has_method("set_target_height"): bill.set_target_height(1.8) bill.set("max_width", 2.2) if bill.has_method("set_texture"): var tex: Texture2D = load("res://assets/sprites/placeholders/tiramisu.png") if tex: bill.set_texture(tex) if tonne.has_signal("interacted"): tonne.interacted.connect(_on_tonne_interacted) func _on_tonne_interacted(_n: Node = null) -> void: if GameState.get_flag("tiramisu_eaten", false): return GameState.set_flag("tiramisu_eaten", true) if not Inventory.has_item("tiramisu"): Inventory.add_item("tiramisu", 1) GameState.apply_fat(600.0) var ui := get_node_or_null("/root/UIRoot") if ui and ui.has_method("show_dialog"): ui.show_dialog_key("dialog_tiramisu", 5.0) var tonne := get_node_or_null("TiramisuTonne") if tonne: tonne.queue_free() func _setup_gate() -> void: var gate := get_node_or_null("MeadowGate") var label := gate.get_node_or_null("GateLabel") if gate else null if GameState.get_flag("reif_defeated", false): _open_gate(false) elif label is Label3D: (label as Label3D).text = Locale.tr_key("gate_locked") func _open_gate(animate: bool) -> void: _gate_open = true var gate := get_node_or_null("MeadowGate") if gate == null: return var col := gate.get_node_or_null("CollisionShape3D") if col is CollisionShape3D: (col as CollisionShape3D).disabled = true ## Swing open toward meadow (+X). if animate: var tw := create_tween() tw.tween_property(gate, "rotation_degrees:y", -95.0, 0.85).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) else: gate.rotation_degrees.y = -95.0 var label := gate.get_node_or_null("GateLabel") if label is Label3D: (label as Label3D).text = Locale.tr_key("gate_open") func _scan_glb_pool(root_dir: String, skip_folders: Array) -> Array[String]: var out: Array[String] = [] var dir := DirAccess.open(root_dir) if dir == null: return out dir.list_dir_begin() var folder := dir.get_next() while folder != "": if dir.current_is_dir() and not folder.begins_with(".") and folder not in skip_folders: var sub := "%s/%s" % [root_dir, folder] var subdir := DirAccess.open(sub) if subdir: subdir.list_dir_begin() var fname := subdir.get_next() while fname != "": if not subdir.current_is_dir() and fname.to_lower().ends_with(".glb"): var path := "%s/%s" % [sub, fname] if ResourceLoader.exists(path): out.append(path) fname = subdir.get_next() folder = dir.get_next() return out func _list_random_house_models() -> Array[String]: return _scan_glb_pool(HOUSES_DIR, HOUSE_POOL_SKIP) func _list_random_animal_models() -> Array[String]: return _scan_glb_pool(ANIMALS_DIR, ANIMAL_POOL_SKIP) func _clear_legacy_house_nodes() -> void: for child in get_children(): if str(child.name).begins_with("House"): child.queue_free() func _spawn_prop(name_: String, path: String, pos: Vector3, yaw: float, fit_h: float, fit_w: float, mul: float, interact: bool = false, collide: bool = true) -> Node3D: var packed: PackedScene = load(MODEL_PROP_SCENE) if packed == null: return null var prop: Node3D = packed.instantiate() prop.name = name_ prop.set("model_path", path) prop.set("fit_height", fit_h) prop.set("fit_max_width", fit_w) prop.set("ground_align", true) prop.set("model_scale", Vector3(mul, mul, mul)) prop.set("model_rotation_degrees", Vector3(0.0, yaw, 0.0)) prop.set("can_interact", interact) ## Must set before add_child — ModelProp builds collision in _ready. prop.set("add_collision", collide) add_child(prop) ## Editor previews must not be written into village.tscn on save. prop.owner = null prop.position = pos return prop func _spawn_random_houses() -> void: var pool := _list_random_house_models() if pool.is_empty(): push_warning("village: no random house models under %s" % HOUSES_DIR) return var i := 0 for slot in HOUSE_SLOTS: var path: String = pool[randi() % pool.size()] _spawn_prop( "RandomHouse_%d" % i, path, slot["pos"], float(slot.get("yaw", 0.0)), SpriteFit.fit_height_for(path, SpriteFit.WORLD_HOUSE_HEIGHT), SpriteFit.fit_max_width_for(path, SpriteFit.WORLD_HOUSE_MAX_WIDTH), float(slot.get("mul", 1.0)) ) i += 1 func _spawn_random_animals() -> void: var pool := _list_random_animal_models() if pool.is_empty(): push_warning("village: no random animal models under %s" % ANIMALS_DIR) return var i := 0 for slot in ANIMAL_SLOTS: var path: String = pool[randi() % pool.size()] var fit_h := SpriteFit.animal_height(path) var fit_w := SpriteFit.animal_max_width(path) _spawn_prop( "RandomAnimal_%d" % i, path, slot["pos"], float(slot.get("yaw", 0.0)), fit_h, fit_w, float(slot.get("mul", 1.0)), false, false ## animals do not block walking ) i += 1 func _spawn_masseur_house() -> void: if not ResourceLoader.exists(MASSEUR_HOUSE_MODEL): push_warning("village: massourhaus.glb missing") return _spawn_prop( "MasseurHouse", MASSEUR_HOUSE_MODEL, Vector3(-5.5, 0.0, 2.5), -40.0, SpriteFit.fit_height_for(MASSEUR_HOUSE_MODEL, SpriteFit.WORLD_HOUSE_HEIGHT), SpriteFit.fit_max_width_for(MASSEUR_HOUSE_MODEL, SpriteFit.WORLD_HOUSE_MAX_WIDTH), 1.0, false ) func _spawn_progress_house() -> void: ## Outdoor: [E] leaves overworld → Catscene (after snake massage). var pool := _list_random_house_models() if pool.is_empty(): return var path: String = pool[0] var house := _spawn_prop( "ProgressHouse", path, Vector3(0.0, 0.0, -16.0), 0.0, SpriteFit.fit_height_for(path, SpriteFit.WORLD_HOUSE_HEIGHT), SpriteFit.fit_max_width_for(path, SpriteFit.WORLD_HOUSE_MAX_WIDTH), 1.0, true ) if house == null: return house.set("prompt_key", "prompt_enter") if house.has_signal("interacted"): house.interacted.connect(_on_progress_house) func _on_progress_house(_prop: Node = null) -> void: var ui := get_node_or_null("/root/UIRoot") if Inventory.has_item("snake") and not GameState.get_flag("snake_massaged", false): if ui and ui.has_method("show_dialog_key"): ui.show_dialog_key("dialog_villager_snake") return if GameState.get_flag("snake_massaged", false) or GameState.get_flag("bear_saved", false): GameState.goto_scene("catscene") return if not GameState.get_flag("reif_defeated", false): if ui and ui.has_method("show_dialog_key"): ui.show_dialog_key("hint_press_button_first", 4.0) return if ui and ui.has_method("show_dialog_key"): ui.show_dialog_key("hint_go_meadow_gate", 4.0) func _spawn_cow_masseur() -> void: if not ResourceLoader.exists(COW_MODEL): push_warning("village: cow.glb missing") return var cow := _spawn_prop( "CowMasseur", COW_MODEL, Vector3(-4.2, 0.0, 3.8), 140.0, SpriteFit.fit_height_for(COW_MODEL, SpriteFit.WORLD_COW_HEIGHT), SpriteFit.fit_max_width_for(COW_MODEL, 2.4), 1.0, true, false ## keep interact range clear ) if cow == null: return cow.set("prompt_key", "prompt_talk") if cow.has_signal("interacted"): cow.interacted.connect(_on_cow) func _on_cow(_npc: Node = null) -> void: if choice_ui == null: return choice_ui.visible = true Input.mouse_mode = Input.MOUSE_MODE_VISIBLE $ChoiceUI/Panel/Margin/VBox/Label.text = Locale.tr_key("dialog_cow") $ChoiceUI/Panel/Margin/VBox/MassageBtn.text = Locale.tr_key("choice_massage_now") $ChoiceUI/Panel/Margin/VBox/DrinkBtn.text = Locale.tr_key("choice_drink_first") AudioDirector.play_dialog_line("dialog_cow") func _close_choices() -> void: if choice_ui: choice_ui.visible = false Input.mouse_mode = Input.MOUSE_MODE_CAPTURED func _on_massage_pressed() -> void: _close_choices() if not Inventory.has_item("snake"): var ui0 := get_node_or_null("/root/UIRoot") if ui0 and ui0.has_method("show_dialog_key"): ui0.show_dialog_key("dialog_villager_snake") return Inventory.remove_item("snake", 1) GameState.set_flag("snake_massaged", true) Achievements.unlock("snake_massage") if not GameState.get_flag("bear_saved", false): Inventory.add_item("tooth", 1) GameState.set_flag("bear_saved", true) var meadow := get_node_or_null("MeadowZone") if meadow and meadow.has_method("notify_snake_massaged"): meadow.notify_snake_massaged() var ui := get_node_or_null("/root/UIRoot") if ui and ui.has_method("show_dialog_key"): ui.show_dialog_key("dialog_cow_massage", 5.0) func _on_drink_pressed() -> void: _close_choices() Inventory.add_item("milk", 1) var ui := get_node_or_null("/root/UIRoot") if ui and ui.has_method("show_dialog_key"): ui.show_dialog_key("dialog_cow_drink", 4.0) func _setup_flee() -> void: const FLEE_SKIP := [ "TableButton", "Table", "MasseurHouse", "CowMasseur", "ProgressHouse", "Exit", "Player", "Ground", "MeadowGround", "WorldEnvironment", "DirectionalLight3D", "MeadowFence", "MeadowGate", "MeadowZone", "Reif", "TiramisuTonne", ] for n in get_children(): if n.name in FLEE_SKIP or str(n.name).begins_with(EDITOR_PREVIEW_PREFIX) or str(n.name).begins_with("RandomHouse_") or str(n.name).begins_with("RandomAnimal_") or str(n.name).begins_with("House") or str(n.name).begins_with("Fence"): continue if n is ModelProp: continue if n.is_in_group("interactable") and n is Node3D: var tex := str(n.get("texture_path")) if tex.ends_with("house.png") or tex.ends_with("table.png") or tex.ends_with("button.png"): continue _flee_dirs[n] = Vector3(randf_range(-1, 1), 0, randf_range(-1, 1)).normalized() func _process(delta: float) -> void: if Engine.is_editor_hint(): return for n in _flee_dirs.keys(): if not is_instance_valid(n): continue var node: Node3D = n node.global_position += _flee_dirs[n] * 1.8 * delta if node.global_position.length() > 22.0: _flee_dirs[n] = -_flee_dirs[n]