2026-09-15 12:02:57 +02:00
|
|
|
@tool
|
2026-09-14 18:36:02 +02:00
|
|
|
extends Node3D
|
2026-09-15 12:02:57 +02:00
|
|
|
## 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
|
2026-09-14 19:08:00 +02:00
|
|
|
|
|
|
|
|
var _flee_dirs: Dictionary = {}
|
2026-09-15 12:02:57 +02:00
|
|
|
var _throw_cd: float = 1.2
|
|
|
|
|
var _reif_alive: bool = false
|
|
|
|
|
var _reif_fight_active: bool = false
|
|
|
|
|
var _gate_open: bool = false
|
2026-09-14 18:36:02 +02:00
|
|
|
|
|
|
|
|
func _ready() -> void:
|
2026-09-15 12:02:57 +02:00
|
|
|
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()
|
2026-09-14 18:36:02 +02:00
|
|
|
GameState.current_scene_id = "village"
|
|
|
|
|
GameState.heal_full()
|
2026-09-15 12:02:57 +02:00
|
|
|
_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).
|
2026-09-14 19:08:00 +02:00
|
|
|
var ui := get_node_or_null("/root/UIRoot")
|
2026-09-15 12:02:57 +02:00
|
|
|
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)
|
2026-09-14 19:08:00 +02:00
|
|
|
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"
|
2026-09-15 12:02:57 +02:00
|
|
|
_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",
|
|
|
|
|
]
|
2026-09-14 19:08:00 +02:00
|
|
|
for n in get_children():
|
2026-09-15 12:02:57 +02:00
|
|
|
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
|
2026-09-14 19:08:00 +02:00
|
|
|
_flee_dirs[n] = Vector3(randf_range(-1, 1), 0, randf_range(-1, 1)).normalized()
|
|
|
|
|
|
|
|
|
|
func _process(delta: float) -> void:
|
2026-09-15 12:02:57 +02:00
|
|
|
if Engine.is_editor_hint():
|
|
|
|
|
return
|
2026-09-14 19:08:00 +02:00
|
|
|
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]
|