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>
This commit is contained in:
parent
1d9e0a6532
commit
1cc8aa9b9d
507 changed files with 12991 additions and 579 deletions
|
|
@ -1,15 +1,16 @@
|
|||
extends Node
|
||||
## Music / SFX / Voice + Burger-Cham every 120s on title/loading.
|
||||
## Music / SFX / Voice (Piper lines under assets/audio/voice/{lang}/{dialog_key}.wav).
|
||||
|
||||
signal burger_cham_triggered
|
||||
|
||||
const BURGER_INTERVAL := 120.0
|
||||
|
||||
# TODO: Replace placeholder streams with real takes (see README asset list).
|
||||
const MUSIC_TITLE := "res://assets/audio/placeholders/music_title.wav"
|
||||
const SFX_EXPLOSION := "res://assets/audio/placeholders/sfx_explosion.wav"
|
||||
const SFX_HIT := "res://assets/audio/placeholders/sfx_hit.wav"
|
||||
const SFX_BURGER := "res://assets/audio/placeholders/sfx_burger.wav"
|
||||
|
||||
## Fallback language-picker clips if keyed Piper files missing
|
||||
const VOICE_DE := "res://assets/audio/placeholders/voice_de.wav"
|
||||
const VOICE_EN := "res://assets/audio/placeholders/voice_en.wav"
|
||||
const VOICE_BY := "res://assets/audio/placeholders/voice_by.wav"
|
||||
|
|
@ -19,6 +20,7 @@ var _sfx: AudioStreamPlayer
|
|||
var _voice: AudioStreamPlayer
|
||||
var _burger_timer: float = 0.0
|
||||
var burger_cham_enabled: bool = true
|
||||
var _last_voice_length: float = 0.0
|
||||
|
||||
func _ready() -> void:
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
|
|
@ -69,6 +71,9 @@ func play_hit() -> void:
|
|||
play_sfx(SFX_HIT)
|
||||
|
||||
func play_language_voice(lang: String) -> void:
|
||||
var key := "lang_voice_%s" % lang
|
||||
if play_dialog_line(key) > 0.0:
|
||||
return
|
||||
var path := VOICE_DE
|
||||
match lang:
|
||||
"en":
|
||||
|
|
@ -80,11 +85,48 @@ func play_language_voice(lang: String) -> void:
|
|||
return
|
||||
_voice.stream = stream
|
||||
_voice.play()
|
||||
_last_voice_length = _stream_length(stream)
|
||||
|
||||
## Plays Piper line for locale key. Returns clip length in seconds (0 if missing).
|
||||
func play_dialog_line(key: String) -> float:
|
||||
if key == "":
|
||||
return 0.0
|
||||
if not (key.begins_with("dialog_") or key.begins_with("lang_voice_")):
|
||||
return 0.0
|
||||
var lang := "de"
|
||||
if Settings:
|
||||
lang = Settings.language
|
||||
var path := "res://assets/audio/voice/%s/%s.wav" % [lang, key]
|
||||
var stream := _load_stream(path)
|
||||
if stream == null and lang != "de":
|
||||
path = "res://assets/audio/voice/de/%s.wav" % key
|
||||
stream = _load_stream(path)
|
||||
if stream == null:
|
||||
_last_voice_length = 0.0
|
||||
return 0.0
|
||||
_voice.stop()
|
||||
_voice.stream = stream
|
||||
_voice.play()
|
||||
_last_voice_length = _stream_length(stream)
|
||||
return _last_voice_length
|
||||
|
||||
func last_voice_length() -> float:
|
||||
return _last_voice_length
|
||||
|
||||
func stop_voice() -> void:
|
||||
_voice.stop()
|
||||
|
||||
func trigger_burger_cham() -> void:
|
||||
play_sfx(SFX_BURGER)
|
||||
burger_cham_triggered.emit()
|
||||
|
||||
func _stream_length(stream: AudioStream) -> float:
|
||||
if stream == null:
|
||||
return 0.0
|
||||
if stream.has_method("get_length"):
|
||||
return float(stream.get_length())
|
||||
return 0.0
|
||||
|
||||
func _load_stream(path: String) -> AudioStream:
|
||||
if ResourceLoader.exists(path):
|
||||
return load(path)
|
||||
|
|
|
|||
|
|
@ -11,10 +11,11 @@ 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/shoe_boss.tscn",
|
||||
"meadow": "res://scenes/meadow.tscn",
|
||||
"massage_studio": "res://scenes/massage_studio.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",
|
||||
|
|
@ -26,13 +27,11 @@ const SCENE_PATHS := {
|
|||
}
|
||||
|
||||
## 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",
|
||||
"shoe_boss",
|
||||
"meadow",
|
||||
"massage_studio",
|
||||
"catscene",
|
||||
"farm_path",
|
||||
"farm",
|
||||
|
|
@ -43,6 +42,10 @@ const SCENE_ORDER: Array[String] = [
|
|||
]
|
||||
|
||||
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
|
||||
|
|
@ -56,6 +59,8 @@ 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()
|
||||
|
|
@ -105,27 +110,114 @@ 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()
|
||||
get_tree().change_scene_to_file(SCENE_PATHS[scene_id])
|
||||
# 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":
|
||||
goto_scene("shoe_boss")
|
||||
"shoe_boss":
|
||||
goto_scene("meadow")
|
||||
"meadow":
|
||||
goto_scene("massage_studio")
|
||||
"massage_studio":
|
||||
goto_scene("catscene")
|
||||
"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":
|
||||
|
|
@ -254,8 +346,12 @@ func load_game() -> void:
|
|||
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", {}))
|
||||
|
|
|
|||
|
|
@ -47,6 +47,12 @@ func has_item(item_id: String, amount: int = 1) -> bool:
|
|||
func get_count(item_id: String) -> int:
|
||||
return int(_items.get(item_id, 0))
|
||||
|
||||
func get_icon_path(item_id: String) -> String:
|
||||
var def: Variant = _defs.get(item_id, {})
|
||||
if typeof(def) == TYPE_DICTIONARY:
|
||||
return str(def.get("icon", ""))
|
||||
return ""
|
||||
|
||||
func get_all() -> Dictionary:
|
||||
return _items.duplicate()
|
||||
|
||||
|
|
@ -75,7 +81,7 @@ func use_item(item_id: String) -> bool:
|
|||
item_used.emit(item_id)
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_boss_unclever"), 4.0)
|
||||
ui.show_dialog_key("dialog_boss_unclever", 4.0)
|
||||
GameState.goto_scene("final_boss")
|
||||
return true
|
||||
"wedding_egg":
|
||||
|
|
|
|||
|
|
@ -1,52 +1,109 @@
|
|||
@tool
|
||||
extends Node3D
|
||||
class_name BillboardSprite
|
||||
## Mario-tree billboard: upright Quad (perpendicular to ground), yaw-only toward camera.
|
||||
## PlaneMesh defaults to flat on XZ — that made sprites look like floor stickers.
|
||||
## Mario-tree billboard: upright Quad, yaw-only toward camera.
|
||||
## Size always via SpriteFit (world height), never raw pixels × constant.
|
||||
## @tool: faces the editor viewport camera while editing.
|
||||
|
||||
@export var texture: Texture2D
|
||||
@export var pixel_size: float = 0.05
|
||||
@export var modulate_color: Color = Color.WHITE
|
||||
@export var texture: Texture2D:
|
||||
set(v):
|
||||
texture = v
|
||||
if is_node_ready():
|
||||
_apply()
|
||||
## Meters; prefer SpriteFit.WORLD_* constants when setting from code.
|
||||
@export var target_height: float = 1.74:
|
||||
set(v):
|
||||
target_height = v
|
||||
if is_node_ready():
|
||||
_apply()
|
||||
@export var max_width: float = 2.2:
|
||||
set(v):
|
||||
max_width = v
|
||||
if is_node_ready():
|
||||
_apply()
|
||||
@export var modulate_color: Color = Color.WHITE:
|
||||
set(v):
|
||||
modulate_color = v
|
||||
if is_node_ready():
|
||||
_set_albedo(modulate_color if not _highlight else _highlight_color)
|
||||
|
||||
@onready var _mesh_instance: MeshInstance3D = $MeshInstance3D
|
||||
|
||||
var _quad_height: float = 1.0
|
||||
var _highlight: bool = false
|
||||
var _highlight_color: Color = Color(1.35, 1.25, 0.35)
|
||||
|
||||
func _ready() -> void:
|
||||
_ensure_mesh()
|
||||
_apply()
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
_face_camera_yaw_only()
|
||||
if Engine.is_editor_hint():
|
||||
return
|
||||
if _highlight and _mesh_instance and _mesh_instance.material_override:
|
||||
var pulse := 0.85 + 0.15 * sin(Time.get_ticks_msec() * 0.012)
|
||||
(_mesh_instance.material_override as BaseMaterial3D).albedo_color = _highlight_color * pulse
|
||||
|
||||
func set_texture(tex: Texture2D) -> void:
|
||||
texture = tex
|
||||
_apply()
|
||||
|
||||
func set_target_height(h: float) -> void:
|
||||
target_height = h
|
||||
_apply()
|
||||
|
||||
func set_highlight(enabled: bool, color: Color = Color(1.35, 1.25, 0.35)) -> void:
|
||||
if Engine.is_editor_hint():
|
||||
return
|
||||
_highlight = enabled
|
||||
_highlight_color = color
|
||||
if not enabled:
|
||||
_set_albedo(modulate_color)
|
||||
|
||||
func flash(color: Color = Color(2.0, 2.0, 1.2), duration: float = 0.12) -> void:
|
||||
if Engine.is_editor_hint():
|
||||
return
|
||||
_set_albedo(color)
|
||||
var tree := get_tree()
|
||||
if tree == null:
|
||||
tree = Engine.get_main_loop() as SceneTree
|
||||
if tree:
|
||||
await tree.create_timer(duration, true).timeout
|
||||
if is_instance_valid(self) and not _highlight:
|
||||
_set_albedo(modulate_color)
|
||||
|
||||
func _ensure_mesh() -> void:
|
||||
if _mesh_instance == null:
|
||||
_mesh_instance = get_node_or_null("MeshInstance3D") as MeshInstance3D
|
||||
|
||||
func _set_albedo(c: Color) -> void:
|
||||
_ensure_mesh()
|
||||
if _mesh_instance and _mesh_instance.material_override:
|
||||
(_mesh_instance.material_override as BaseMaterial3D).albedo_color = c
|
||||
|
||||
func _apply() -> void:
|
||||
_ensure_mesh()
|
||||
if _mesh_instance == null:
|
||||
return
|
||||
# QuadMesh is vertical (XY), facing +Z — correct default for standing sprites.
|
||||
var quad := QuadMesh.new()
|
||||
var w := 1.0
|
||||
var h := 1.0
|
||||
if texture != null:
|
||||
w = float(texture.get_width()) * pixel_size
|
||||
h = float(texture.get_height()) * pixel_size
|
||||
var size := SpriteFit.world_quad_size(texture, target_height, max_width)
|
||||
var w := size.x
|
||||
var h := size.y
|
||||
quad.size = Vector2(w, h)
|
||||
_quad_height = h
|
||||
_mesh_instance.mesh = quad
|
||||
# Pivot at feet: lift mesh so bottom sits on this node's origin.
|
||||
_mesh_instance.position = Vector3(0.0, h * 0.5, 0.0)
|
||||
_mesh_instance.rotation = Vector3.ZERO
|
||||
|
||||
var mat := StandardMaterial3D.new()
|
||||
# No engine billboard — we yaw the Node3D ourselves so orientation stays upright.
|
||||
mat.billboard_mode = BaseMaterial3D.BILLBOARD_DISABLED
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
|
||||
mat.alpha_scissor_threshold = 0.1
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED # visible from both sides
|
||||
mat.albedo_color = modulate_color
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
mat.albedo_color = modulate_color if not _highlight else _highlight_color
|
||||
if texture != null:
|
||||
mat.albedo_texture = texture
|
||||
_mesh_instance.material_override = mat
|
||||
|
|
@ -59,6 +116,4 @@ func _face_camera_yaw_only() -> void:
|
|||
to_cam.y = 0.0
|
||||
if to_cam.length_squared() < 0.0001:
|
||||
return
|
||||
# QuadMesh faces +Z; look_at aims -Z at the target — aim -Z away from camera
|
||||
# so +Z faces the camera.
|
||||
look_at(global_position - to_cam, Vector3.UP)
|
||||
|
|
|
|||
|
|
@ -14,10 +14,13 @@ func _ready() -> void:
|
|||
add_to_group("enemy")
|
||||
add_to_group("cheese_projectile")
|
||||
var bill := get_node_or_null("BillboardSprite")
|
||||
if bill and bill.has_method("set_texture"):
|
||||
var tex: Texture2D = load("res://assets/sprites/placeholders/cheese.png")
|
||||
if tex:
|
||||
bill.set_texture(tex)
|
||||
if bill:
|
||||
if bill.has_method("set_target_height"):
|
||||
bill.set_target_height(SpriteFit.WORLD_PROP_HEIGHT)
|
||||
if bill.has_method("set_texture"):
|
||||
var tex: Texture2D = load("res://assets/sprites/placeholders/cheese.png")
|
||||
if tex:
|
||||
bill.set_texture(tex)
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
global_position += velocity * delta
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
@tool
|
||||
extends Node3D
|
||||
class_name CheeseSeller
|
||||
## Places a cheese table and periodically launches moldy cheese at the player.
|
||||
## DESIGN: stab the cheese (projectile), not the seller — seller dies of grief when cheese destroyed.
|
||||
## @tool: billboard look only in editor (no cheese firing).
|
||||
|
||||
@export var texture_path: String = "res://assets/sprites/placeholders/cheese.png"
|
||||
@export var texture_path: String = "res://assets/sprites/placeholders/cheese_seller.png":
|
||||
set(v):
|
||||
texture_path = v
|
||||
if Engine.is_editor_hint() and is_node_ready():
|
||||
_apply_billboard_look()
|
||||
@export var fire_interval: float = 2.8
|
||||
@export var cheese_speed: float = 7.0
|
||||
|
||||
|
|
@ -14,16 +20,28 @@ var _grief: int = 0
|
|||
@onready var _billboard: BillboardSprite = $BillboardSprite
|
||||
|
||||
func _ready() -> void:
|
||||
_apply_billboard_look()
|
||||
if Engine.is_editor_hint():
|
||||
set_process(false)
|
||||
return
|
||||
add_to_group("interactable")
|
||||
add_to_group("cheese_seller")
|
||||
_timer = randf() * fire_interval
|
||||
if _billboard:
|
||||
|
||||
|
||||
func _apply_billboard_look() -> void:
|
||||
if _billboard == null:
|
||||
_billboard = get_node_or_null("BillboardSprite") as BillboardSprite
|
||||
if _billboard == null:
|
||||
return
|
||||
_billboard.set_target_height(SpriteFit.WORLD_PROP_HEIGHT)
|
||||
if texture_path != "" and ResourceLoader.exists(texture_path):
|
||||
var tex: Texture2D = load(texture_path)
|
||||
if tex:
|
||||
_billboard.set_texture(tex)
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not _alive:
|
||||
if Engine.is_editor_hint() or not _alive:
|
||||
return
|
||||
_timer -= delta
|
||||
if _timer <= 0.0:
|
||||
|
|
@ -52,16 +70,23 @@ func notify_cheese_destroyed() -> void:
|
|||
_alive = false
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_cheese"))
|
||||
ui.show_dialog_key("dialog_cheese")
|
||||
queue_free()
|
||||
|
||||
func on_interact(_player: Node) -> void:
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_cheese"))
|
||||
ui.show_dialog_key("dialog_cheese")
|
||||
|
||||
func take_hit(_amount: int = 1) -> void:
|
||||
## Hitting the seller does nothing useful (DESIGN).
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_cheese"))
|
||||
ui.show_dialog_key("dialog_cheese")
|
||||
|
||||
func get_interact_prompt() -> String:
|
||||
return "%s — %s" % [Locale.tr_key("prompt_talk"), Locale.tr_key("dialog_cheese")]
|
||||
|
||||
func set_interaction_highlight(enabled: bool, color: Color = Color(1.4, 1.35, 0.4)) -> void:
|
||||
if _billboard and _billboard.has_method("set_highlight"):
|
||||
_billboard.set_highlight(enabled, color)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,41 @@
|
|||
extends Area3D
|
||||
class_name ExitTrigger
|
||||
## Walk into this volume to advance or jump to a scene.
|
||||
## Level exit volume. Floating door billboard ONLY for indoor exits (show_door).
|
||||
## Outdoor transitions into buildings use ModelProp [E], not this door art.
|
||||
|
||||
@export var next_scene_id: String = ""
|
||||
@export var use_goto_next: bool = true
|
||||
@export var require_flag: String = ""
|
||||
## Indoor only: floating door billboard. Outside houses leave this false.
|
||||
@export var show_door: bool = false
|
||||
@export var texture_path: String = "res://assets/sprites/placeholders/door.png"
|
||||
@export var billboard_height: float = 2.05
|
||||
|
||||
func _ready() -> void:
|
||||
body_entered.connect(_on_body_entered)
|
||||
collision_layer = 0
|
||||
collision_mask = 2 # player layer
|
||||
add_to_group("exit_trigger")
|
||||
if show_door:
|
||||
_ensure_door_art()
|
||||
|
||||
func _ensure_door_art() -> void:
|
||||
if texture_path == "":
|
||||
return
|
||||
var bill := get_node_or_null("BillboardSprite")
|
||||
if bill == null:
|
||||
var packed: PackedScene = load("res://scenes/prefabs/billboard_sprite.tscn")
|
||||
if packed == null:
|
||||
return
|
||||
bill = packed.instantiate()
|
||||
bill.name = "BillboardSprite"
|
||||
add_child(bill)
|
||||
if bill.has_method("set_target_height"):
|
||||
bill.set_target_height(billboard_height)
|
||||
if bill.has_method("set_texture"):
|
||||
var tex: Texture2D = load(texture_path)
|
||||
if tex:
|
||||
bill.set_texture(tex)
|
||||
|
||||
func _on_body_entered(body: Node3D) -> void:
|
||||
if not body.is_in_group("player"):
|
||||
|
|
@ -17,7 +43,7 @@ func _on_body_entered(body: Node3D) -> void:
|
|||
if require_flag != "" and not GameState.get_flag(require_flag, false):
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("exit_hint") + " (?)")
|
||||
ui.show_dialog(Locale.tr_key("exit_locked"), 3.0)
|
||||
return
|
||||
if use_goto_next and next_scene_id == "":
|
||||
GameState.goto_next()
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
extends Area3D
|
||||
class_name HazardZone
|
||||
## Damages the player on enter.
|
||||
## Damages the player on enter. Optional billboard art via texture_path.
|
||||
|
||||
@export var damage: int = 1
|
||||
@export var death_cause_key: String = "death_default"
|
||||
@export var cooldown: float = 1.0
|
||||
@export var texture_path: String = ""
|
||||
@export var billboard_height: float = 1.4
|
||||
|
||||
var _ready_to_hurt: bool = true
|
||||
|
||||
|
|
@ -12,6 +14,25 @@ func _ready() -> void:
|
|||
body_entered.connect(_on_body_entered)
|
||||
collision_layer = 0
|
||||
collision_mask = 2
|
||||
_ensure_billboard()
|
||||
|
||||
func _ensure_billboard() -> void:
|
||||
if texture_path == "":
|
||||
return
|
||||
var bill := get_node_or_null("BillboardSprite")
|
||||
if bill == null:
|
||||
var packed: PackedScene = load("res://scenes/prefabs/billboard_sprite.tscn")
|
||||
if packed == null:
|
||||
return
|
||||
bill = packed.instantiate()
|
||||
bill.name = "BillboardSprite"
|
||||
add_child(bill)
|
||||
if bill.has_method("set_target_height"):
|
||||
bill.set_target_height(billboard_height)
|
||||
if bill.has_method("set_texture"):
|
||||
var tex: Texture2D = load(texture_path)
|
||||
if tex:
|
||||
bill.set_texture(tex)
|
||||
|
||||
func _on_body_entered(body: Node3D) -> void:
|
||||
if not _ready_to_hurt:
|
||||
|
|
|
|||
|
|
@ -1,43 +1,154 @@
|
|||
@tool
|
||||
extends Node3D
|
||||
class_name HittableEntity
|
||||
## Generic enemy / boss stub with hit points.
|
||||
## Billboard enemy/boss with optional chase + melee AI (DESIGN.md combat).
|
||||
## @tool: applies texture/height in the editor (AI disabled while editing).
|
||||
|
||||
signal defeated
|
||||
signal hit(remaining_hp: int)
|
||||
|
||||
@export var max_hp: int = 4
|
||||
@export var texture_path: String = "res://assets/sprites/placeholders/enemy.png"
|
||||
@export var texture_path: String = "res://assets/sprites/placeholders/enemy.png":
|
||||
set(v):
|
||||
texture_path = v
|
||||
if Engine.is_editor_hint() and is_node_ready():
|
||||
_apply_billboard_look()
|
||||
@export var billboard_height: float = 1.74:
|
||||
set(v):
|
||||
billboard_height = v
|
||||
if Engine.is_editor_hint() and is_node_ready():
|
||||
_apply_billboard_look()
|
||||
@export var defeat_flag: String = ""
|
||||
@export var achievement_on_defeat: String = ""
|
||||
|
||||
@export_group("AI")
|
||||
@export var ai_enabled: bool = true
|
||||
@export var move_speed: float = 2.8
|
||||
@export var attack_range: float = 1.15 ## Shorter than player reach so melee isn't face-hug only
|
||||
@export var attack_cooldown: float = 1.15
|
||||
@export var hold_distance: float = 0.95 ## Stop closing once inside this * attack_range
|
||||
@export var attack_damage: int = 1
|
||||
@export var death_cause_key: String = "death_default"
|
||||
@export var shout_key: String = ""
|
||||
@export var shout_interval: float = 4.5
|
||||
@export var aggro_range: float = 28.0
|
||||
@export var flee_below_hp: int = 0 ## If >0 and hp <= this, flee instead of chase
|
||||
@export var invulnerable: bool = false ## Urhahn-style: player hits do nothing useful
|
||||
@export var instakill: bool = false ## Contact attack kills instantly
|
||||
@export var glitzo_mode: bool = false ## Attacks use register_glitzo_hit (3=slow, 4=dead)
|
||||
|
||||
var hp: int = 4
|
||||
var _alive: bool = true
|
||||
var _attack_cd: float = 0.0
|
||||
var _shout_cd: float = 1.0
|
||||
|
||||
@onready var _billboard: BillboardSprite = $BillboardSprite
|
||||
|
||||
func _ready() -> void:
|
||||
_apply_billboard_look()
|
||||
if Engine.is_editor_hint():
|
||||
set_physics_process(false)
|
||||
return
|
||||
hp = max_hp
|
||||
add_to_group("enemy")
|
||||
add_to_group("interactable")
|
||||
if _billboard:
|
||||
|
||||
|
||||
func _apply_billboard_look() -> void:
|
||||
if _billboard == null:
|
||||
_billboard = get_node_or_null("BillboardSprite") as BillboardSprite
|
||||
if _billboard == null:
|
||||
return
|
||||
_billboard.set_target_height(billboard_height)
|
||||
if texture_path != "" and ResourceLoader.exists(texture_path):
|
||||
var tex: Texture2D = load(texture_path)
|
||||
if tex:
|
||||
_billboard.set_texture(tex)
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if Engine.is_editor_hint() or not _alive or not ai_enabled:
|
||||
return
|
||||
if GameState.current_scene_id in ["title_screen", "death_screen"]:
|
||||
return
|
||||
if get_tree().paused:
|
||||
return
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("is_dialog_open") and ui.is_dialog_open():
|
||||
return
|
||||
_attack_cd = maxf(0.0, _attack_cd - delta)
|
||||
_shout_cd = maxf(0.0, _shout_cd - delta)
|
||||
var player := get_tree().get_first_node_in_group("player")
|
||||
if player == null or not (player is Node3D):
|
||||
return
|
||||
var ppos: Vector3 = (player as Node3D).global_position
|
||||
var to_player := ppos - global_position
|
||||
to_player.y = 0.0
|
||||
var dist := to_player.length()
|
||||
if dist > aggro_range:
|
||||
return
|
||||
if shout_key != "" and _shout_cd <= 0.0 and dist < 12.0:
|
||||
_shout_cd = shout_interval
|
||||
if ui and ui.has_method("show_dialog_key"):
|
||||
ui.show_dialog_key(shout_key, 2.0, false)
|
||||
elif ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key(shout_key), 2.0, shout_key, false)
|
||||
var fleeing := flee_below_hp > 0 and hp <= flee_below_hp
|
||||
var stop_at := attack_range * hold_distance
|
||||
if fleeing:
|
||||
if dist > 0.05:
|
||||
global_position -= to_player.normalized() * move_speed * delta
|
||||
global_position.y = 0.1
|
||||
elif dist > stop_at:
|
||||
# Close in, but stop short of the player so their longer reach matters
|
||||
global_position += to_player.normalized() * move_speed * delta
|
||||
global_position.y = 0.1
|
||||
if not fleeing and dist <= attack_range and _attack_cd <= 0.0:
|
||||
_do_attack()
|
||||
|
||||
func _do_attack() -> void:
|
||||
_attack_cd = attack_cooldown
|
||||
AudioDirector.play_hit()
|
||||
if instakill:
|
||||
GameState.apply_shock(10.0)
|
||||
GameState.die(death_cause_key)
|
||||
return
|
||||
if glitzo_mode:
|
||||
GameState.register_glitzo_hit()
|
||||
return
|
||||
GameState.take_damage(attack_damage, death_cause_key)
|
||||
|
||||
func take_hit(amount: int = 1) -> void:
|
||||
if not _alive:
|
||||
return
|
||||
if invulnerable:
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog_key("dialog_urhahn", 2.0)
|
||||
return
|
||||
hp = max(0, hp - amount)
|
||||
hit.emit(hp)
|
||||
AudioDirector.play_hit()
|
||||
if hp <= 0:
|
||||
_alive = false
|
||||
ai_enabled = false
|
||||
if defeat_flag != "":
|
||||
GameState.set_flag(defeat_flag, true)
|
||||
if achievement_on_defeat != "":
|
||||
Achievements.unlock(achievement_on_defeat)
|
||||
defeated.emit()
|
||||
queue_free()
|
||||
call_deferred("queue_free")
|
||||
|
||||
func on_interact(_player: Node) -> void:
|
||||
take_hit(1)
|
||||
if invulnerable:
|
||||
_do_attack()
|
||||
else:
|
||||
take_hit(1)
|
||||
|
||||
func get_interact_prompt() -> String:
|
||||
if invulnerable:
|
||||
return Locale.tr_key("prompt_attack")
|
||||
return Locale.tr_key("prompt_attack")
|
||||
|
||||
func set_interaction_highlight(enabled: bool, color: Color = Color(1.5, 0.45, 0.35)) -> void:
|
||||
if _billboard and _billboard.has_method("set_highlight"):
|
||||
_billboard.set_highlight(enabled, color)
|
||||
|
|
|
|||
|
|
@ -1,38 +1,80 @@
|
|||
@tool
|
||||
extends Node3D
|
||||
class_name InteractableNPC
|
||||
## Billboard NPC with dialog key and optional quest hook.
|
||||
## @tool: applies texture/height in the editor so NPCs are WYSIWYG (no quest logic).
|
||||
|
||||
signal interacted(npc: InteractableNPC)
|
||||
|
||||
@export var dialog_key: String = "dialog_hongkong_npc"
|
||||
@export var texture_path: String = "res://assets/sprites/placeholders/npc.png"
|
||||
@export var texture_path: String = "res://assets/sprites/placeholders/npc.png":
|
||||
set(v):
|
||||
texture_path = v
|
||||
if Engine.is_editor_hint() and is_node_ready():
|
||||
_apply_billboard_look()
|
||||
@export var billboard_height: float = 1.74:
|
||||
set(v):
|
||||
billboard_height = v
|
||||
if Engine.is_editor_hint() and is_node_ready():
|
||||
_apply_billboard_look()
|
||||
@export var billboard_max_width: float = 2.2:
|
||||
set(v):
|
||||
billboard_max_width = v
|
||||
if Engine.is_editor_hint() and is_node_ready():
|
||||
_apply_billboard_look()
|
||||
@export var can_interact: bool = true ## false = deco only (e.g. village house)
|
||||
@export var give_item_id: String = ""
|
||||
@export var give_once: bool = false ## Don't stack if player already has the item
|
||||
@export var despawn_after_give: bool = false ## Remove world pickup after taking
|
||||
@export var require_item_id: String = ""
|
||||
@export var consume_required: bool = false
|
||||
@export var quest_flag: String = ""
|
||||
@export var achievement_id: String = ""
|
||||
@export var on_success_goto: String = ""
|
||||
@export var prompt_key: String = "" ## Optional override, e.g. prompt_press_button
|
||||
|
||||
@onready var _billboard: BillboardSprite = $BillboardSprite
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("interactable")
|
||||
if _billboard:
|
||||
_apply_billboard_look()
|
||||
if Engine.is_editor_hint():
|
||||
return
|
||||
if can_interact:
|
||||
add_to_group("interactable")
|
||||
|
||||
|
||||
func _apply_billboard_look() -> void:
|
||||
if _billboard == null:
|
||||
_billboard = get_node_or_null("BillboardSprite") as BillboardSprite
|
||||
if _billboard == null:
|
||||
return
|
||||
_billboard.max_width = billboard_max_width
|
||||
_billboard.set_target_height(billboard_height)
|
||||
if texture_path != "" and ResourceLoader.exists(texture_path):
|
||||
var tex: Texture2D = load(texture_path)
|
||||
if tex:
|
||||
_billboard.set_texture(tex)
|
||||
|
||||
func on_interact(_player: Node) -> void:
|
||||
if Engine.is_editor_hint() or not can_interact:
|
||||
return
|
||||
interacted.emit(self)
|
||||
# World buttons / portals: go immediately (optional short bark via dialog_key)
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key(dialog_key))
|
||||
if dialog_key != "" and on_success_goto == "":
|
||||
if ui and ui.has_method("show_dialog_key"):
|
||||
ui.show_dialog_key(dialog_key)
|
||||
elif ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key(dialog_key), 3.5, dialog_key)
|
||||
if require_item_id != "" and not Inventory.has_item(require_item_id):
|
||||
return
|
||||
if require_item_id != "" and consume_required:
|
||||
Inventory.remove_item(require_item_id, 1)
|
||||
if give_item_id != "":
|
||||
Inventory.add_item(give_item_id, 1)
|
||||
if not (give_once and Inventory.has_item(give_item_id)):
|
||||
Inventory.add_item(give_item_id, 1)
|
||||
if despawn_after_give:
|
||||
call_deferred("queue_free")
|
||||
if quest_flag != "":
|
||||
GameState.set_flag(quest_flag, true)
|
||||
if quest_flag == "house_built":
|
||||
|
|
@ -40,4 +82,32 @@ func on_interact(_player: Node) -> void:
|
|||
if achievement_id != "":
|
||||
Achievements.unlock(achievement_id)
|
||||
if on_success_goto != "":
|
||||
# Optional one-liner before leave (non-blocking bark if UI supports it)
|
||||
if dialog_key != "" and ui and ui.has_method("show_dialog_key"):
|
||||
ui.show_dialog_key(dialog_key, 1.2, false)
|
||||
GameState.goto_scene(on_success_goto)
|
||||
|
||||
func get_interact_prompt() -> String:
|
||||
if prompt_key != "":
|
||||
return Locale.tr_key(prompt_key)
|
||||
if texture_path.ends_with("button.png"):
|
||||
return Locale.tr_key("prompt_press_button")
|
||||
if texture_path.ends_with("tiramisu.png"):
|
||||
return Locale.tr_key("prompt_eat_tiramisu")
|
||||
if give_item_id != "":
|
||||
var item_name := Locale.tr_key("item_" + give_item_id)
|
||||
return "%s — %s" % [Locale.tr_key("prompt_pick_up"), item_name]
|
||||
if on_success_goto != "":
|
||||
return Locale.tr_key("prompt_enter")
|
||||
if quest_flag == "house_built":
|
||||
return Locale.tr_key("prompt_build")
|
||||
if dialog_key != "":
|
||||
var label := Locale.tr_key(dialog_key)
|
||||
if label.length() > 42:
|
||||
label = label.substr(0, 40) + "…"
|
||||
return "%s — %s" % [Locale.tr_key("prompt_talk"), label]
|
||||
return Locale.tr_key("prompt_use")
|
||||
|
||||
func set_interaction_highlight(enabled: bool, color: Color = Color(1.4, 1.35, 0.4)) -> void:
|
||||
if _billboard and _billboard.has_method("set_highlight"):
|
||||
_billboard.set_highlight(enabled, color)
|
||||
|
|
|
|||
334
scripts/combat/model_prop.gd
Normal file
334
scripts/combat/model_prop.gd
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
@tool
|
||||
extends Node3D
|
||||
class_name ModelProp
|
||||
## 3D GLB/prop with optional interact. Auto-fits wild asset scales via fit_height.
|
||||
## When use_scale_catalog is on, data/model_scales.json overrides fit_* for this path.
|
||||
## @tool: loads + fits the GLB in the editor viewport (does not own/save the mesh child).
|
||||
## Optional StaticBody3D box from fitted AABB (layer 1 = Ground) — runtime only.
|
||||
|
||||
signal interacted(prop: ModelProp)
|
||||
|
||||
@export_file("*.glb", "*.gltf") var model_path: String = "":
|
||||
set(v):
|
||||
if model_path == v:
|
||||
return
|
||||
model_path = v
|
||||
if Engine.is_editor_hint() and is_node_ready():
|
||||
respawn_model()
|
||||
## Extra multiplier after fit (usually 1,1,1). Used for slight per-slot variation.
|
||||
@export var model_scale: Vector3 = Vector3.ONE:
|
||||
set(v):
|
||||
model_scale = v
|
||||
if Engine.is_editor_hint() and is_node_ready() and _model != null:
|
||||
_apply_fit()
|
||||
@export var model_rotation_degrees: Vector3 = Vector3.ZERO:
|
||||
set(v):
|
||||
model_rotation_degrees = v
|
||||
if _model != null:
|
||||
_model.rotation_degrees = model_rotation_degrees
|
||||
if Engine.is_editor_hint() and is_node_ready():
|
||||
_apply_fit()
|
||||
## Target AABB height in meters. 0 = use model_scale only (legacy).
|
||||
@export var fit_height: float = 0.0:
|
||||
set(v):
|
||||
fit_height = v
|
||||
if Engine.is_editor_hint() and is_node_ready() and _model != null:
|
||||
_apply_fit()
|
||||
## Cap horizontal size after height-fit (0 = no cap).
|
||||
@export var fit_max_width: float = 0.0:
|
||||
set(v):
|
||||
fit_max_width = v
|
||||
if Engine.is_editor_hint() and is_node_ready() and _model != null:
|
||||
_apply_fit()
|
||||
## Sit AABB bottom on this node's y=0.
|
||||
@export var ground_align: bool = true:
|
||||
set(v):
|
||||
ground_align = v
|
||||
if Engine.is_editor_hint() and is_node_ready() and _model != null:
|
||||
_apply_fit()
|
||||
## Apply SpriteFit catalog entry for model_path (scale lab SSOT).
|
||||
@export var use_scale_catalog: bool = true:
|
||||
set(v):
|
||||
use_scale_catalog = v
|
||||
if Engine.is_editor_hint() and is_node_ready() and _model != null:
|
||||
_apply_fit()
|
||||
## Add StaticBody3D + BoxShape3D from fitted AABB (houses on; animals/interior often off).
|
||||
@export var add_collision: bool = true:
|
||||
set(v):
|
||||
add_collision = v
|
||||
if is_node_ready():
|
||||
_rebuild_collision()
|
||||
## Shrink each side of the collision box (meters). Keeps a little approach room for interact.
|
||||
@export var collision_inset: float = 0.15:
|
||||
set(v):
|
||||
collision_inset = v
|
||||
if is_node_ready():
|
||||
_rebuild_collision()
|
||||
@export var can_interact: bool = false
|
||||
@export var prompt_key: String = ""
|
||||
@export var dialog_key: String = ""
|
||||
@export var on_success_goto: String = ""
|
||||
|
||||
var _model: Node3D
|
||||
var _highlight_meshes: Array[MeshInstance3D] = []
|
||||
var _highlight_mat: StandardMaterial3D
|
||||
var _collision_body: StaticBody3D
|
||||
## Fitted mesh AABB in prop-local space (after fit / ground align).
|
||||
var _local_aabb: AABB = AABB()
|
||||
var _has_local_aabb: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
_highlight_mat = StandardMaterial3D.new()
|
||||
_highlight_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
_highlight_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
_highlight_mat.albedo_color = Color(1.4, 1.35, 0.4, 0.35)
|
||||
## Runtime only: mirror catalog into exports for play-mode inspection.
|
||||
## Never do this in editor — would dirty/save fit_* into the scene.
|
||||
if not Engine.is_editor_hint():
|
||||
_apply_catalog_overrides()
|
||||
_spawn_model()
|
||||
if Engine.is_editor_hint():
|
||||
return
|
||||
if can_interact:
|
||||
add_to_group("interactable")
|
||||
|
||||
|
||||
func _apply_catalog_overrides() -> void:
|
||||
if not use_scale_catalog or model_path == "":
|
||||
return
|
||||
var entry := SpriteFit.scale_entry(model_path)
|
||||
if entry.is_empty():
|
||||
return
|
||||
if entry.has("fit_height"):
|
||||
fit_height = float(entry["fit_height"])
|
||||
if entry.has("fit_max_width"):
|
||||
fit_max_width = float(entry["fit_max_width"])
|
||||
|
||||
|
||||
func _resolved_fit_height() -> float:
|
||||
if use_scale_catalog and model_path != "":
|
||||
var entry := SpriteFit.scale_entry(model_path)
|
||||
if entry.has("fit_height"):
|
||||
return float(entry["fit_height"])
|
||||
return fit_height
|
||||
|
||||
|
||||
func _resolved_fit_max_width() -> float:
|
||||
if use_scale_catalog and model_path != "":
|
||||
var entry := SpriteFit.scale_entry(model_path)
|
||||
if entry.has("fit_max_width"):
|
||||
return float(entry["fit_max_width"])
|
||||
return fit_max_width
|
||||
|
||||
|
||||
func set_fit_params(height_m: float, max_width_m: float, reapply: bool = true) -> void:
|
||||
fit_height = height_m
|
||||
fit_max_width = max_width_m
|
||||
if reapply:
|
||||
_apply_fit()
|
||||
|
||||
|
||||
func respawn_model() -> void:
|
||||
_clear_model()
|
||||
if not Engine.is_editor_hint():
|
||||
_apply_catalog_overrides()
|
||||
_spawn_model()
|
||||
|
||||
|
||||
func _clear_model() -> void:
|
||||
_clear_collision()
|
||||
if is_instance_valid(_model):
|
||||
remove_child(_model)
|
||||
_model.free()
|
||||
_model = null
|
||||
_highlight_meshes.clear()
|
||||
_has_local_aabb = false
|
||||
|
||||
|
||||
func _spawn_model() -> void:
|
||||
_clear_model()
|
||||
if model_path == "" or not ResourceLoader.exists(model_path):
|
||||
if model_path != "":
|
||||
push_warning("ModelProp missing model: %s" % model_path)
|
||||
return
|
||||
var packed: PackedScene = load(model_path)
|
||||
if packed == null:
|
||||
return
|
||||
_model = packed.instantiate() as Node3D
|
||||
if _model == null:
|
||||
return
|
||||
_model.rotation_degrees = model_rotation_degrees
|
||||
_model.scale = Vector3.ONE
|
||||
_model.position = Vector3.ZERO
|
||||
add_child(_model)
|
||||
## Preview mesh must not be saved into the edited scene.
|
||||
_model.owner = null
|
||||
_collect_meshes(_model)
|
||||
_apply_fit()
|
||||
|
||||
func _collect_meshes(n: Node) -> void:
|
||||
if n is MeshInstance3D:
|
||||
_highlight_meshes.append(n as MeshInstance3D)
|
||||
for c in n.get_children():
|
||||
_collect_meshes(c)
|
||||
|
||||
func _xform_to_self(n: Node3D) -> Transform3D:
|
||||
## Local transform chain — avoids get_tree()/global_transform when not needed.
|
||||
var xf := Transform3D.IDENTITY
|
||||
var cur: Node = n
|
||||
while cur != null and cur != self:
|
||||
if cur is Node3D:
|
||||
xf = (cur as Node3D).transform * xf
|
||||
cur = cur.get_parent()
|
||||
return xf
|
||||
|
||||
func _aabb_in_self_space() -> AABB:
|
||||
var merged := AABB()
|
||||
var first := true
|
||||
for mi in _highlight_meshes:
|
||||
if not is_instance_valid(mi) or mi.mesh == null:
|
||||
continue
|
||||
var piece: AABB = _xform_to_self(mi) * mi.get_aabb()
|
||||
if first:
|
||||
merged = piece
|
||||
first = false
|
||||
else:
|
||||
merged = merged.merge(piece)
|
||||
return merged
|
||||
|
||||
func _apply_fit() -> void:
|
||||
if _model == null or _highlight_meshes.is_empty():
|
||||
return
|
||||
# Measure unscaled (rotation already applied).
|
||||
_model.scale = Vector3.ONE
|
||||
_model.position = Vector3.ZERO
|
||||
var aabb := _aabb_in_self_space()
|
||||
var size := aabb.size
|
||||
## Prefer Y as height; if the mesh is sideways (Y << longest axis), use longest.
|
||||
var measure := size.y
|
||||
var longest := maxf(size.x, maxf(size.y, size.z))
|
||||
if measure <= 0.0001 or measure < longest * 0.55:
|
||||
measure = longest
|
||||
if measure <= 0.0001:
|
||||
_model.scale = model_scale
|
||||
_cache_local_aabb()
|
||||
_rebuild_collision()
|
||||
return
|
||||
var s := 1.0
|
||||
var use_h := _resolved_fit_height()
|
||||
var use_w := _resolved_fit_max_width()
|
||||
if use_h > 0.0:
|
||||
s = use_h / measure
|
||||
if use_w > 0.0:
|
||||
var horiz := maxf(size.x, size.z) * s
|
||||
## Also cap the longest axis so sideways models cannot become giants.
|
||||
var long_after := longest * s
|
||||
if horiz > use_w:
|
||||
s *= use_w / horiz
|
||||
long_after = longest * s
|
||||
if long_after > use_h * 1.35 and use_h > 0.0:
|
||||
s *= (use_h * 1.35) / maxf(long_after, 0.0001)
|
||||
_model.scale = model_scale * s
|
||||
if ground_align:
|
||||
aabb = _aabb_in_self_space()
|
||||
_model.position.y -= aabb.position.y
|
||||
_cache_local_aabb()
|
||||
_rebuild_collision()
|
||||
|
||||
func _cache_local_aabb() -> void:
|
||||
if _highlight_meshes.is_empty():
|
||||
_has_local_aabb = false
|
||||
return
|
||||
_local_aabb = _aabb_in_self_space()
|
||||
_has_local_aabb = _local_aabb.size.length() > 0.0001
|
||||
|
||||
func _clear_collision() -> void:
|
||||
if is_instance_valid(_collision_body):
|
||||
remove_child(_collision_body)
|
||||
_collision_body.free()
|
||||
_collision_body = null
|
||||
|
||||
func _rebuild_collision() -> void:
|
||||
_clear_collision()
|
||||
## Runtime only — avoid editor physics / dirtying scenes with collision children.
|
||||
if Engine.is_editor_hint() or not add_collision or not _has_local_aabb:
|
||||
return
|
||||
var aabb := _local_aabb
|
||||
var inset := maxf(collision_inset, 0.0)
|
||||
var size := Vector3(
|
||||
maxf(aabb.size.x - inset * 2.0, 0.05),
|
||||
maxf(aabb.size.y - inset, 0.05),
|
||||
maxf(aabb.size.z - inset * 2.0, 0.05)
|
||||
)
|
||||
## Keep box sitting on ground (y=0): only inset sides + top, not bottom.
|
||||
var center := Vector3(
|
||||
aabb.position.x + aabb.size.x * 0.5,
|
||||
size.y * 0.5,
|
||||
aabb.position.z + aabb.size.z * 0.5
|
||||
)
|
||||
var body := StaticBody3D.new()
|
||||
body.name = "Collision"
|
||||
body.collision_layer = 1 ## same as Ground — player mask is 1
|
||||
body.collision_mask = 0
|
||||
var shape_node := CollisionShape3D.new()
|
||||
var box := BoxShape3D.new()
|
||||
box.size = size
|
||||
shape_node.shape = box
|
||||
shape_node.position = center
|
||||
body.add_child(shape_node)
|
||||
add_child(body)
|
||||
body.owner = null
|
||||
_collision_body = body
|
||||
|
||||
func get_interact_distance(from_global: Vector3) -> float:
|
||||
## Distance to nearest point on fitted AABB so large house boxes stay reachable.
|
||||
if not _has_local_aabb:
|
||||
return global_position.distance_to(from_global)
|
||||
var local := to_local(from_global)
|
||||
var min_p := _local_aabb.position
|
||||
var max_p := _local_aabb.position + _local_aabb.size
|
||||
var closest := Vector3(
|
||||
clampf(local.x, min_p.x, max_p.x),
|
||||
clampf(local.y, min_p.y, max_p.y),
|
||||
clampf(local.z, min_p.z, max_p.z)
|
||||
)
|
||||
return from_global.distance_to(to_global(closest))
|
||||
|
||||
func on_interact(_player: Node) -> void:
|
||||
if Engine.is_editor_hint() or not can_interact:
|
||||
return
|
||||
interacted.emit(self)
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if dialog_key != "" and on_success_goto == "":
|
||||
if ui and ui.has_method("show_dialog_key"):
|
||||
ui.show_dialog_key(dialog_key)
|
||||
if on_success_goto != "":
|
||||
if dialog_key != "" and ui and ui.has_method("show_dialog_key"):
|
||||
ui.show_dialog_key(dialog_key, 1.0, false)
|
||||
GameState.goto_scene(on_success_goto)
|
||||
|
||||
func get_interact_prompt() -> String:
|
||||
if prompt_key != "":
|
||||
return Locale.tr_key(prompt_key)
|
||||
if on_success_goto != "":
|
||||
return Locale.tr_key("prompt_press_button")
|
||||
return Locale.tr_key("prompt_use")
|
||||
|
||||
func set_interaction_highlight(enabled: bool, color: Color = Color(1.4, 1.35, 0.4)) -> void:
|
||||
## MeshInstance3D has no modulate — use material_overlay tint instead.
|
||||
if Engine.is_editor_hint() or _highlight_mat == null:
|
||||
return
|
||||
_highlight_mat.albedo_color = Color(color.r, color.g, color.b, 0.35)
|
||||
for mi in _highlight_meshes:
|
||||
if not is_instance_valid(mi):
|
||||
continue
|
||||
mi.material_overlay = _highlight_mat if enabled else null
|
||||
|
||||
|
||||
func _get_configuration_warnings() -> PackedStringArray:
|
||||
var warnings: PackedStringArray = []
|
||||
if model_path == "":
|
||||
warnings.append("ModelProp: set model_path to a .glb/.gltf")
|
||||
elif not ResourceLoader.exists(model_path):
|
||||
warnings.append("ModelProp: model_path does not exist: %s" % model_path)
|
||||
return warnings
|
||||
40
scripts/combat/quirxel_projectile.gd
Normal file
40
scripts/combat/quirxel_projectile.gd
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
extends Area3D
|
||||
class_name QuirxelProjectile
|
||||
## Flying pixel square that seeks / shoots toward the player (final boss).
|
||||
|
||||
var velocity: Vector3 = Vector3.ZERO
|
||||
var lifetime: float = 6.0
|
||||
var hp: int = 1
|
||||
|
||||
func _ready() -> void:
|
||||
collision_layer = 4
|
||||
collision_mask = 2
|
||||
monitoring = true
|
||||
body_entered.connect(_on_body)
|
||||
add_to_group("enemy")
|
||||
add_to_group("quirxel")
|
||||
var bill := get_node_or_null("BillboardSprite")
|
||||
if bill:
|
||||
if bill.has_method("set_target_height"):
|
||||
bill.set_target_height(SpriteFit.WORLD_PROP_HEIGHT * 0.45)
|
||||
if bill.has_method("set_texture"):
|
||||
var tex: Texture2D = load("res://assets/sprites/placeholders/quirxel.png")
|
||||
if tex:
|
||||
bill.set_texture(tex)
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
global_position += velocity * delta
|
||||
lifetime -= delta
|
||||
if lifetime <= 0.0:
|
||||
call_deferred("queue_free")
|
||||
|
||||
func take_hit(_amount: int = 1) -> void:
|
||||
hp -= 1
|
||||
AudioDirector.play_hit()
|
||||
if hp <= 0:
|
||||
call_deferred("queue_free")
|
||||
|
||||
func _on_body(body: Node3D) -> void:
|
||||
if body.is_in_group("player"):
|
||||
GameState.take_damage(1, "death_quirxel")
|
||||
call_deferred("queue_free")
|
||||
48
scripts/combat/shoe_projectile.gd
Normal file
48
scripts/combat/shoe_projectile.gd
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
extends Area3D
|
||||
class_name ShoeProjectile
|
||||
## Thrown shoe from Schuhmacher Reif. Hit = damage / death_shoe.
|
||||
|
||||
var velocity: Vector3 = Vector3.ZERO
|
||||
var lifetime: float = 4.5
|
||||
var hp: int = 1
|
||||
var damage: int = 1
|
||||
|
||||
func _ready() -> void:
|
||||
collision_layer = 4
|
||||
collision_mask = 2
|
||||
monitoring = true
|
||||
body_entered.connect(_on_body)
|
||||
add_to_group("enemy")
|
||||
add_to_group("shoe_projectile")
|
||||
var bill := get_node_or_null("BillboardSprite")
|
||||
if bill:
|
||||
if bill.has_method("set_target_height"):
|
||||
bill.set_target_height(SpriteFit.WORLD_PROP_HEIGHT * 0.75)
|
||||
if bill.has_method("set_texture"):
|
||||
var tex: Texture2D = load("res://assets/sprites/placeholders/shoe.png")
|
||||
if tex:
|
||||
bill.set_texture(tex)
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
# Slight arc
|
||||
velocity.y -= 6.0 * delta
|
||||
global_position += velocity * delta
|
||||
if global_position.y < 0.15:
|
||||
global_position.y = 0.15
|
||||
velocity.y = absf(velocity.y) * 0.25
|
||||
velocity.x *= 0.85
|
||||
velocity.z *= 0.85
|
||||
lifetime -= delta
|
||||
if lifetime <= 0.0:
|
||||
call_deferred("queue_free")
|
||||
|
||||
func take_hit(_amount: int = 1) -> void:
|
||||
hp -= 1
|
||||
AudioDirector.play_hit()
|
||||
if hp <= 0:
|
||||
call_deferred("queue_free")
|
||||
|
||||
func _on_body(body: Node3D) -> void:
|
||||
if body.is_in_group("player"):
|
||||
GameState.take_damage(damage, "death_shoe")
|
||||
call_deferred("queue_free")
|
||||
472
scripts/dev/scale_lab.gd
Normal file
472
scripts/dev/scale_lab.gd
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
extends Node3D
|
||||
## Editor scale lab: tune ModelProp fit_* vs measuring wall + 1.74 m player.
|
||||
## Open scenes/dev/scale_lab.tscn — Save writes data/model_scales.json (runtime SSOT).
|
||||
|
||||
const MODELS_ROOT := "res://assets/3DModelle"
|
||||
const MODEL_PROP_SCENE := "res://scenes/prefabs/model_prop.tscn"
|
||||
const WALL_MAX_M := 4.0
|
||||
const PREVIEW_POS := Vector3(0.0, 0.0, 0.0)
|
||||
const PLAYER_REF_POS := Vector3(-1.6, 0.0, 0.6)
|
||||
|
||||
var _models: Array[Dictionary] = [] ## {key, path, label}
|
||||
var _selected: int = -1
|
||||
var _preview: ModelProp
|
||||
var _working: Dictionary = {} ## key -> {fit_height, fit_max_width, notes?}
|
||||
var _status_label: Label
|
||||
var _height_spin: SpinBox
|
||||
var _width_spin: SpinBox
|
||||
var _list: ItemList
|
||||
var _updating_ui: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
_build_environment()
|
||||
_build_measuring_wall()
|
||||
_build_player_reference()
|
||||
_build_ui()
|
||||
_load_working_from_catalog()
|
||||
_scan_models()
|
||||
_populate_list()
|
||||
if not _models.is_empty():
|
||||
_list.select(0)
|
||||
_select_index(0)
|
||||
_update_camera()
|
||||
|
||||
|
||||
func _build_environment() -> void:
|
||||
var env := Environment.new()
|
||||
env.background_mode = Environment.BG_COLOR
|
||||
env.background_color = Color(0.55, 0.72, 0.88)
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
env.ambient_light_color = Color(0.85, 0.88, 0.92)
|
||||
env.ambient_light_energy = 0.65
|
||||
$WorldEnvironment.environment = env
|
||||
|
||||
|
||||
func _build_measuring_wall() -> void:
|
||||
var wall_root := Node3D.new()
|
||||
wall_root.name = "MeasuringWall"
|
||||
wall_root.position = Vector3(0.0, 0.0, -2.2)
|
||||
add_child(wall_root)
|
||||
|
||||
## Back panel
|
||||
var panel := MeshInstance3D.new()
|
||||
var box := BoxMesh.new()
|
||||
box.size = Vector3(6.0, WALL_MAX_M + 0.4, 0.08)
|
||||
panel.mesh = box
|
||||
panel.position = Vector3(0.0, (WALL_MAX_M + 0.4) * 0.5, 0.0)
|
||||
var panel_mat := StandardMaterial3D.new()
|
||||
panel_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
panel_mat.albedo_color = Color(0.92, 0.92, 0.88)
|
||||
panel.material_override = panel_mat
|
||||
wall_root.add_child(panel)
|
||||
|
||||
## Tick marks every 0.1 m; stronger at 0.5 / 1.0
|
||||
for i in range(int(WALL_MAX_M * 10.0) + 1):
|
||||
var y := float(i) * 0.1
|
||||
var major := (i % 10) == 0
|
||||
var mid := (i % 5) == 0
|
||||
var tick := MeshInstance3D.new()
|
||||
var tbox := BoxMesh.new()
|
||||
var len := 0.35 if major else (0.22 if mid else 0.12)
|
||||
var thick := 0.025 if major else (0.018 if mid else 0.01)
|
||||
tbox.size = Vector3(len, thick, 0.04)
|
||||
tick.mesh = tbox
|
||||
tick.position = Vector3(-2.6 + len * 0.5, y, 0.06)
|
||||
var tmat := StandardMaterial3D.new()
|
||||
tmat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
if absf(y - SpriteFit.WORLD_PLAYER_HEIGHT) < 0.001:
|
||||
tmat.albedo_color = Color(0.85, 0.15, 0.1)
|
||||
tbox.size = Vector3(0.55, 0.035, 0.05)
|
||||
tick.position.x = -2.6 + 0.275
|
||||
elif major:
|
||||
tmat.albedo_color = Color(0.12, 0.12, 0.14)
|
||||
else:
|
||||
tmat.albedo_color = Color(0.35, 0.35, 0.38)
|
||||
tick.material_override = tmat
|
||||
wall_root.add_child(tick)
|
||||
|
||||
if major or absf(y - SpriteFit.WORLD_PLAYER_HEIGHT) < 0.001:
|
||||
var label := Label3D.new()
|
||||
if absf(y - SpriteFit.WORLD_PLAYER_HEIGHT) < 0.001:
|
||||
label.text = "1.74 m player"
|
||||
label.modulate = Color(0.9, 0.2, 0.15)
|
||||
else:
|
||||
label.text = "%.0f m" % y
|
||||
label.modulate = Color(0.1, 0.1, 0.12)
|
||||
label.font_size = 48
|
||||
label.pixel_size = 0.0045
|
||||
label.position = Vector3(-2.95, y, 0.08)
|
||||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
label.billboard = BaseMaterial3D.BILLBOARD_DISABLED
|
||||
wall_root.add_child(label)
|
||||
|
||||
## Floor grid strip in front of wall
|
||||
var floor_grid := MeshInstance3D.new()
|
||||
var fbox := BoxMesh.new()
|
||||
fbox.size = Vector3(6.0, 0.02, 4.0)
|
||||
floor_grid.mesh = fbox
|
||||
floor_grid.position = Vector3(0.0, 0.01, 0.0)
|
||||
var fmat := StandardMaterial3D.new()
|
||||
fmat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
fmat.albedo_color = Color(0.45, 0.5, 0.48)
|
||||
floor_grid.material_override = fmat
|
||||
add_child(floor_grid)
|
||||
|
||||
|
||||
func _build_player_reference() -> void:
|
||||
var root := Node3D.new()
|
||||
root.name = "PlayerReference"
|
||||
root.position = PLAYER_REF_POS
|
||||
add_child(root)
|
||||
|
||||
var capsule := MeshInstance3D.new()
|
||||
var mesh := CapsuleMesh.new()
|
||||
mesh.radius = 0.22
|
||||
mesh.height = SpriteFit.WORLD_PLAYER_HEIGHT
|
||||
capsule.mesh = mesh
|
||||
capsule.position = Vector3(0.0, SpriteFit.WORLD_PLAYER_HEIGHT * 0.5, 0.0)
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.albedo_color = Color(0.2, 0.45, 0.95, 0.55)
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
capsule.material_override = mat
|
||||
root.add_child(capsule)
|
||||
|
||||
var tag := Label3D.new()
|
||||
tag.text = "Player 1.74 m"
|
||||
tag.font_size = 42
|
||||
tag.pixel_size = 0.004
|
||||
tag.position = Vector3(0.0, SpriteFit.WORLD_PLAYER_HEIGHT + 0.15, 0.0)
|
||||
tag.modulate = Color(0.15, 0.35, 0.9)
|
||||
root.add_child(tag)
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var layer := CanvasLayer.new()
|
||||
layer.name = "LabUI"
|
||||
layer.layer = 80
|
||||
add_child(layer)
|
||||
|
||||
var panel := PanelContainer.new()
|
||||
panel.set_anchors_preset(Control.PRESET_TOP_LEFT)
|
||||
panel.offset_left = 12
|
||||
panel.offset_top = 12
|
||||
panel.offset_right = 360
|
||||
panel.offset_bottom = 520
|
||||
layer.add_child(panel)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 10)
|
||||
margin.add_theme_constant_override("margin_right", 10)
|
||||
margin.add_theme_constant_override("margin_top", 8)
|
||||
margin.add_theme_constant_override("margin_bottom", 8)
|
||||
panel.add_child(margin)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 6)
|
||||
margin.add_child(vbox)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "Scale Lab"
|
||||
title.add_theme_font_size_override("font_size", 20)
|
||||
vbox.add_child(title)
|
||||
|
||||
var hint := Label.new()
|
||||
hint.text = "Wall ticks = meters. Red = player 1.74 m.\nSave → data/model_scales.json"
|
||||
hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
vbox.add_child(hint)
|
||||
|
||||
_list = ItemList.new()
|
||||
_list.custom_minimum_size = Vector2(0, 220)
|
||||
_list.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
_list.item_selected.connect(_on_list_selected)
|
||||
vbox.add_child(_list)
|
||||
|
||||
vbox.add_child(_make_spin_row("fit_height (m)", 0.01, 20.0, 0.01, true))
|
||||
vbox.add_child(_make_spin_row("fit_max_width (m)", 0.0, 30.0, 0.05, false))
|
||||
|
||||
var buttons := HBoxContainer.new()
|
||||
buttons.add_theme_constant_override("separation", 6)
|
||||
vbox.add_child(buttons)
|
||||
|
||||
var save_btn := Button.new()
|
||||
save_btn.text = "Save"
|
||||
save_btn.pressed.connect(_on_save)
|
||||
buttons.add_child(save_btn)
|
||||
|
||||
var reset_btn := Button.new()
|
||||
reset_btn.text = "Reset defaults"
|
||||
reset_btn.pressed.connect(_on_reset)
|
||||
buttons.add_child(reset_btn)
|
||||
|
||||
var reload_btn := Button.new()
|
||||
reload_btn.text = "Reload JSON"
|
||||
reload_btn.pressed.connect(_on_reload)
|
||||
buttons.add_child(reload_btn)
|
||||
|
||||
_status_label = Label.new()
|
||||
_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_status_label.text = "Ready."
|
||||
vbox.add_child(_status_label)
|
||||
|
||||
var cam_hint := Label.new()
|
||||
cam_hint.text = "Orbit: RMB drag · Zoom: wheel · WASD move cam"
|
||||
cam_hint.modulate = Color(0.7, 0.7, 0.75)
|
||||
vbox.add_child(cam_hint)
|
||||
|
||||
|
||||
func _make_spin_row(label_text: String, min_v: float, max_v: float, step: float, is_height: bool) -> Control:
|
||||
var row := VBoxContainer.new()
|
||||
var lab := Label.new()
|
||||
lab.text = label_text
|
||||
row.add_child(lab)
|
||||
var spin := SpinBox.new()
|
||||
spin.min_value = min_v
|
||||
spin.max_value = max_v
|
||||
spin.step = step
|
||||
spin.allow_greater = true
|
||||
spin.allow_lesser = false
|
||||
if is_height:
|
||||
spin.value_changed.connect(_on_height_changed)
|
||||
_height_spin = spin
|
||||
else:
|
||||
spin.value_changed.connect(_on_width_changed)
|
||||
_width_spin = spin
|
||||
row.add_child(spin)
|
||||
return row
|
||||
|
||||
|
||||
func _load_working_from_catalog() -> void:
|
||||
SpriteFit.reload_scale_catalog()
|
||||
_working.clear()
|
||||
## Seed from defaults, then overlay file.
|
||||
var defaults: Dictionary = SpriteFit.default_scale_catalog().get("scales", {})
|
||||
for key in defaults.keys():
|
||||
_working[str(key)] = (defaults[key] as Dictionary).duplicate(true)
|
||||
if FileAccess.file_exists(SpriteFit.SCALE_CATALOG_PATH):
|
||||
var file := FileAccess.open(SpriteFit.SCALE_CATALOG_PATH, FileAccess.READ)
|
||||
if file:
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
if typeof(parsed) == TYPE_DICTIONARY:
|
||||
var scales: Variant = (parsed as Dictionary).get("scales", {})
|
||||
if typeof(scales) == TYPE_DICTIONARY:
|
||||
for key in (scales as Dictionary).keys():
|
||||
var entry: Variant = scales[key]
|
||||
if typeof(entry) == TYPE_DICTIONARY:
|
||||
_working[str(key)] = (entry as Dictionary).duplicate(true)
|
||||
|
||||
|
||||
func _scan_models() -> void:
|
||||
_models.clear()
|
||||
for category in ["animals", "houses", "props"]:
|
||||
var cat_dir := "%s/%s" % [MODELS_ROOT, category]
|
||||
var dir := DirAccess.open(cat_dir)
|
||||
if dir == null:
|
||||
continue
|
||||
dir.list_dir_begin()
|
||||
var folder := dir.get_next()
|
||||
while folder != "":
|
||||
if dir.current_is_dir() and not folder.begins_with("."):
|
||||
var sub := "%s/%s" % [cat_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]
|
||||
var key := "%s/%s" % [category, folder]
|
||||
_models.append({
|
||||
"key": key,
|
||||
"path": path,
|
||||
"label": "%s (%s)" % [key, fname],
|
||||
})
|
||||
## Ensure working entry exists.
|
||||
if not _working.has(key):
|
||||
_working[key] = {
|
||||
"fit_height": SpriteFit.fit_height_for(path),
|
||||
"fit_max_width": SpriteFit.fit_max_width_for(path),
|
||||
}
|
||||
break
|
||||
fname = subdir.get_next()
|
||||
folder = dir.get_next()
|
||||
_models.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return str(a["key"]) < str(b["key"]))
|
||||
|
||||
|
||||
func _populate_list() -> void:
|
||||
_list.clear()
|
||||
for m in _models:
|
||||
_list.add_item(str(m["label"]))
|
||||
|
||||
|
||||
func _on_list_selected(index: int) -> void:
|
||||
_select_index(index)
|
||||
|
||||
|
||||
func _select_index(index: int) -> void:
|
||||
if index < 0 or index >= _models.size():
|
||||
return
|
||||
_selected = index
|
||||
var m: Dictionary = _models[index]
|
||||
var key: String = m["key"]
|
||||
var path: String = m["path"]
|
||||
var entry: Dictionary = _working.get(key, {
|
||||
"fit_height": SpriteFit.fit_height_for(path),
|
||||
"fit_max_width": SpriteFit.fit_max_width_for(path),
|
||||
})
|
||||
_updating_ui = true
|
||||
_height_spin.value = float(entry.get("fit_height", 1.0))
|
||||
_width_spin.value = float(entry.get("fit_max_width", 1.0))
|
||||
_updating_ui = false
|
||||
_spawn_preview(path, float(_height_spin.value), float(_width_spin.value))
|
||||
_status_label.text = "Selected %s" % key
|
||||
|
||||
|
||||
func _spawn_preview(path: String, fit_h: float, fit_w: float) -> void:
|
||||
if is_instance_valid(_preview):
|
||||
_preview.queue_free()
|
||||
_preview = null
|
||||
var packed: PackedScene = load(MODEL_PROP_SCENE)
|
||||
if packed == null:
|
||||
return
|
||||
_preview = packed.instantiate() as ModelProp
|
||||
if _preview == null:
|
||||
return
|
||||
_preview.name = "PreviewProp"
|
||||
_preview.use_scale_catalog = false
|
||||
_preview.add_collision = false
|
||||
_preview.model_path = path
|
||||
_preview.fit_height = fit_h
|
||||
_preview.fit_max_width = fit_w
|
||||
_preview.ground_align = true
|
||||
_preview.can_interact = false
|
||||
add_child(_preview)
|
||||
_preview.global_position = PREVIEW_POS
|
||||
|
||||
|
||||
func _on_height_changed(v: float) -> void:
|
||||
if _updating_ui or _selected < 0:
|
||||
return
|
||||
_commit_working(v, float(_width_spin.value))
|
||||
|
||||
|
||||
func _on_width_changed(v: float) -> void:
|
||||
if _updating_ui or _selected < 0:
|
||||
return
|
||||
_commit_working(float(_height_spin.value), v)
|
||||
|
||||
|
||||
func _commit_working(fit_h: float, fit_w: float) -> void:
|
||||
var key: String = _models[_selected]["key"]
|
||||
var entry: Dictionary = _working.get(key, {}).duplicate(true)
|
||||
entry["fit_height"] = fit_h
|
||||
entry["fit_max_width"] = fit_w
|
||||
_working[key] = entry
|
||||
if is_instance_valid(_preview):
|
||||
_preview.set_fit_params(fit_h, fit_w, true)
|
||||
|
||||
|
||||
func _on_save() -> void:
|
||||
var doc := {
|
||||
"_meta": {
|
||||
"player_height_m": SpriteFit.WORLD_PLAYER_HEIGHT,
|
||||
"notes": "Edited by scenes/dev/scale_lab.tscn. Keys = category/folder under assets/3DModelle/.",
|
||||
},
|
||||
"scales": _working.duplicate(true),
|
||||
}
|
||||
var err := SpriteFit.save_scale_catalog(doc)
|
||||
if err == OK:
|
||||
_status_label.text = "Saved %s (%d entries)." % [SpriteFit.SCALE_CATALOG_PATH, _working.size()]
|
||||
else:
|
||||
_status_label.text = "Save FAILED (err %s). Run from editor so res:// is writable." % err
|
||||
|
||||
|
||||
func _on_reset() -> void:
|
||||
var defaults: Dictionary = SpriteFit.default_scale_catalog().get("scales", {})
|
||||
_working.clear()
|
||||
for key in defaults.keys():
|
||||
_working[str(key)] = (defaults[key] as Dictionary).duplicate(true)
|
||||
## Keep any extra scanned models with current SpriteFit fallbacks.
|
||||
for m in _models:
|
||||
var key: String = m["key"]
|
||||
if not _working.has(key):
|
||||
_working[key] = {
|
||||
"fit_height": SpriteFit.fit_height_for(str(m["path"])),
|
||||
"fit_max_width": SpriteFit.fit_max_width_for(str(m["path"])),
|
||||
}
|
||||
if _selected >= 0:
|
||||
_select_index(_selected)
|
||||
_status_label.text = "Reset to built-in defaults (not saved yet)."
|
||||
|
||||
|
||||
func _on_reload() -> void:
|
||||
_load_working_from_catalog()
|
||||
if _selected >= 0:
|
||||
_select_index(_selected)
|
||||
_status_label.text = "Reloaded from JSON."
|
||||
|
||||
|
||||
## Simple free camera (WASD + RMB look + wheel).
|
||||
var _yaw: float = 0.35
|
||||
var _pitch: float = -0.25
|
||||
var _cam_dist: float = 5.5
|
||||
var _cam_focus := Vector3(0.0, 1.0, 0.0)
|
||||
var _rmb_down: bool = false
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton:
|
||||
var mb := event as InputEventMouseButton
|
||||
if mb.button_index == MOUSE_BUTTON_RIGHT:
|
||||
_rmb_down = mb.pressed
|
||||
elif mb.pressed and mb.button_index == MOUSE_BUTTON_WHEEL_UP:
|
||||
_cam_dist = maxf(1.5, _cam_dist * 0.9)
|
||||
_update_camera()
|
||||
elif mb.pressed and mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||
_cam_dist = minf(18.0, _cam_dist * 1.1)
|
||||
_update_camera()
|
||||
elif event is InputEventMouseMotion and _rmb_down:
|
||||
var mm := event as InputEventMouseMotion
|
||||
_yaw -= mm.relative.x * 0.005
|
||||
_pitch = clampf(_pitch - mm.relative.y * 0.005, -1.2, 0.6)
|
||||
_update_camera()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
var move := Vector3.ZERO
|
||||
if Input.is_key_pressed(KEY_W):
|
||||
move.z -= 1.0
|
||||
if Input.is_key_pressed(KEY_S):
|
||||
move.z += 1.0
|
||||
if Input.is_key_pressed(KEY_A):
|
||||
move.x -= 1.0
|
||||
if Input.is_key_pressed(KEY_D):
|
||||
move.x += 1.0
|
||||
if Input.is_key_pressed(KEY_Q):
|
||||
move.y -= 1.0
|
||||
if Input.is_key_pressed(KEY_E):
|
||||
move.y += 1.0
|
||||
if move != Vector3.ZERO:
|
||||
var basis := Basis.from_euler(Vector3(_pitch, _yaw, 0.0))
|
||||
_cam_focus += basis * move.normalized() * 3.0 * delta
|
||||
_update_camera()
|
||||
|
||||
|
||||
func _update_camera() -> void:
|
||||
var cam: Camera3D = $Camera3D
|
||||
if cam == null:
|
||||
return
|
||||
var offset := Vector3(
|
||||
sin(_yaw) * cos(_pitch),
|
||||
sin(_pitch),
|
||||
cos(_yaw) * cos(_pitch)
|
||||
) * _cam_dist
|
||||
## Orbit behind focus (wall at -Z).
|
||||
cam.global_position = _cam_focus + Vector3(offset.x, -offset.y + 1.2, offset.z)
|
||||
cam.look_at(_cam_focus, Vector3.UP)
|
||||
|
||||
|
||||
func _enter_tree() -> void:
|
||||
call_deferred("_update_camera")
|
||||
|
|
@ -11,11 +11,17 @@ func _ready() -> void:
|
|||
label.text = Locale.tr_key("dialog_catscene")
|
||||
skip_btn.text = "Skip Katzin / Catscene"
|
||||
cont_btn.text = Locale.tr_key("menu_continue")
|
||||
AudioDirector.play_dialog_line("dialog_catscene")
|
||||
|
||||
func _on_continue_pressed() -> void:
|
||||
GameState.goto_scene("farm_path")
|
||||
|
||||
func _on_skip_pressed() -> void:
|
||||
label.text = Locale.tr_key("dialog_catscene_skip")
|
||||
await get_tree().create_timer(2.0).timeout
|
||||
AudioDirector.play_dialog_line("dialog_catscene_skip")
|
||||
var tree := get_tree()
|
||||
if tree == null:
|
||||
tree = Engine.get_main_loop() as SceneTree
|
||||
if tree:
|
||||
await tree.create_timer(maxf(2.0, AudioDirector.last_voice_length()), true).timeout
|
||||
GameState.reset_to_intro()
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ extends Control
|
|||
@onready var btn_no: Button = $NoButton
|
||||
@onready var btn_yes: Button = $YesButton
|
||||
@onready var hint: Label = $Hint
|
||||
@onready var skull: ColorRect = $Skull
|
||||
@onready var skull: TextureRect = $Skull
|
||||
@onready var bg: ColorRect = $Bg
|
||||
|
||||
var _blink: float = 0.0
|
||||
|
|
@ -26,6 +26,7 @@ func _ready() -> void:
|
|||
_refresh()
|
||||
Locale.locale_changed.connect(_refresh)
|
||||
skull.visible = false
|
||||
SpriteFit.apply_texture_rect_budget(skull, SpriteFit.UI_POPUP_PX)
|
||||
|
||||
func _refresh() -> void:
|
||||
title.text = Locale.tr_key("death_title")
|
||||
|
|
@ -40,11 +41,13 @@ func _process(delta: float) -> void:
|
|||
var idx := int(_blink) % _colors.size()
|
||||
bg.color = _colors[idx]
|
||||
_skull_timer += delta
|
||||
if _skull_timer >= 10.0:
|
||||
_skull_timer = 0.0
|
||||
if skull.visible:
|
||||
if _skull_timer >= 1.0:
|
||||
skull.visible = false
|
||||
_skull_timer = 0.0
|
||||
elif _skull_timer >= 10.0:
|
||||
skull.visible = true
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
skull.visible = false
|
||||
_skull_timer = 0.0
|
||||
|
||||
func _on_revive_pressed() -> void:
|
||||
GameState.revive_continue()
|
||||
|
|
|
|||
|
|
@ -1,34 +1,63 @@
|
|||
extends Node3D
|
||||
## Dresslermeister: Augen öffnen, Mähdrescher hijacken, Apfel dreschen.
|
||||
## Dresslermeister: Augen auf, Mähdrescher hijacken, Apfel dreschen (DESIGN.md §10).
|
||||
|
||||
var _hijacked: int = 0
|
||||
var _eyes_open: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.current_scene_id = "dressler_boss"
|
||||
GameState.heal_full()
|
||||
var dressler := get_node_or_null("Dressler")
|
||||
if dressler:
|
||||
dressler.set("ai_enabled", false) # sleeps until eyes open
|
||||
dressler.set("max_hp", 18)
|
||||
dressler.set("hp", 18)
|
||||
dressler.set("billboard_height", SpriteFit.WORLD_BOSS_HEIGHT)
|
||||
dressler.set("death_cause_key", "death_mower")
|
||||
if dressler.has_signal("defeated"):
|
||||
dressler.defeated.connect(_on_dressler_defeated)
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_dressler"))
|
||||
var dressler := get_node_or_null("Dressler")
|
||||
if dressler and dressler.has_signal("defeated"):
|
||||
dressler.defeated.connect(_on_dressler_defeated)
|
||||
# Convert mower hazards into hijackables
|
||||
for name in ["Mower1", "Mower2"]:
|
||||
ui.show_dialog("Ein riesiger Apfel auf dem Feld…", 2.5)
|
||||
for name in ["Mower1", "Mower2", "Mower3"]:
|
||||
var m := get_node_or_null(name)
|
||||
if m:
|
||||
_make_hijackable(m)
|
||||
|
||||
func _make_hijackable(mower: Node) -> void:
|
||||
mower.set_meta("hijacked", false)
|
||||
if mower.has_signal("body_entered"):
|
||||
# Keep death if not hijacked — wrap
|
||||
pass
|
||||
# Add interactable proxy as child Area isn't enough; use nearby attack
|
||||
mower.add_to_group("hijackable_mower")
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
func _physics_process(delta: float) -> void:
|
||||
_try_open_eyes()
|
||||
if Input.is_action_just_pressed("interact") or Input.is_action_just_pressed("attack"):
|
||||
_try_hijack()
|
||||
# Moving mowers slowly toward player if not hijacked
|
||||
var player := get_tree().get_first_node_in_group("player")
|
||||
if player is Node3D:
|
||||
for mower in get_tree().get_nodes_in_group("hijackable_mower"):
|
||||
if mower is Node3D and not bool(mower.get_meta("hijacked", false)):
|
||||
var to_p: Vector3 = (player as Node3D).global_position - mower.global_position
|
||||
to_p.y = 0.0
|
||||
if to_p.length() > 0.2:
|
||||
mower.global_position += to_p.normalized() * 1.6 * delta
|
||||
|
||||
func _try_open_eyes() -> void:
|
||||
if _eyes_open:
|
||||
return
|
||||
var player := get_tree().get_first_node_in_group("player")
|
||||
var dressler := get_node_or_null("Dressler")
|
||||
if player is Node3D and dressler is Node3D:
|
||||
if player.global_position.distance_to(dressler.global_position) < 6.0:
|
||||
_eyes_open = true
|
||||
dressler.set("ai_enabled", true)
|
||||
dressler.set("move_speed", 1.2)
|
||||
dressler.set("attack_damage", 1)
|
||||
dressler.set("shout_key", "dialog_dressler")
|
||||
dressler.set("shout_interval", 6.0)
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog_key("dialog_dressler", 3.0)
|
||||
|
||||
func _make_hijackable(mower: Node) -> void:
|
||||
mower.set_meta("hijacked", false)
|
||||
mower.add_to_group("hijackable_mower")
|
||||
|
||||
func _try_hijack() -> void:
|
||||
var player := get_tree().get_first_node_in_group("player")
|
||||
|
|
@ -40,18 +69,14 @@ func _try_hijack() -> void:
|
|||
continue
|
||||
mower.set_meta("hijacked", true)
|
||||
_hijacked += 1
|
||||
# Disable kill: remove from monitoring / free hazard script effect
|
||||
if mower.has_method("set"):
|
||||
mower.set("monitoring", false)
|
||||
if mower is Area3D:
|
||||
(mower as Area3D).monitoring = false
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog("Fahrer rausgeworfen! Dresche den Apfel! (%d/2)" % _hijacked)
|
||||
# Bonus damage to dressler for each hijack
|
||||
ui.show_dialog("Fahrer rausgeworfen! Dresche den Apfel! (%d)" % _hijacked)
|
||||
var dressler := get_node_or_null("Dressler")
|
||||
if dressler and dressler.has_method("take_hit"):
|
||||
dressler.take_hit(3)
|
||||
dressler.take_hit(4)
|
||||
return
|
||||
|
||||
func _on_dressler_defeated() -> void:
|
||||
|
|
@ -59,5 +84,5 @@ func _on_dressler_defeated() -> void:
|
|||
Inventory.add_item("dressler_apple", 1)
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_dressler_drop"), 4.0)
|
||||
ui.show_dialog_key("dialog_dressler_drop", 4.0)
|
||||
GameState.set_flag("dressler_defeated", true)
|
||||
|
|
|
|||
|
|
@ -6,16 +6,33 @@ func _ready() -> void:
|
|||
GameState.heal_full()
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_end_angry"), 4.0)
|
||||
ui.show_dialog_key("dialog_end_angry", 4.0)
|
||||
var house := get_node_or_null("HouseSpot")
|
||||
if house and house.has_signal("interacted"):
|
||||
house.interacted.connect(_on_house)
|
||||
var dentist := get_node_or_null("DentistHeist")
|
||||
if dentist and dentist.has_signal("interacted"):
|
||||
dentist.interacted.connect(_on_heist)
|
||||
# Plot marker stays small until built — don't show full house twice
|
||||
_setup_plot_marker()
|
||||
if GameState.house_built or GameState.get_flag("house_built", false):
|
||||
_show_house()
|
||||
|
||||
func _setup_plot_marker() -> void:
|
||||
var spot := get_node_or_null("HouseSpot")
|
||||
if spot == null:
|
||||
return
|
||||
if GameState.house_built or GameState.get_flag("house_built", false):
|
||||
return
|
||||
# Before build: small plot sign, not a second full-size house
|
||||
spot.set("billboard_height", 1.0)
|
||||
spot.set("billboard_max_width", 1.5)
|
||||
var bill = spot.get_node_or_null("BillboardSprite")
|
||||
if bill:
|
||||
bill.set("max_width", 1.5)
|
||||
if bill.has_method("set_target_height"):
|
||||
bill.set_target_height(1.0)
|
||||
|
||||
func _on_house(_n: Node = null) -> void:
|
||||
GameState.house_built = true
|
||||
GameState.set_flag("house_built", true)
|
||||
|
|
@ -25,21 +42,27 @@ func _on_house(_n: Node = null) -> void:
|
|||
ui.show_dialog(Locale.tr_key("build_house"))
|
||||
|
||||
func _show_house() -> void:
|
||||
var house := get_node_or_null("BuiltHouse")
|
||||
if house:
|
||||
house.visible = true
|
||||
## One house only: grow HouseSpot to full size (no duplicate BuiltHouse).
|
||||
var spot := get_node_or_null("HouseSpot")
|
||||
if spot == null:
|
||||
return
|
||||
var scene: PackedScene = load("res://scenes/prefabs/npc.tscn")
|
||||
if scene == null:
|
||||
spot.set("billboard_height", SpriteFit.WORLD_HOUSE_HEIGHT)
|
||||
spot.set("billboard_max_width", SpriteFit.WORLD_HOUSE_MAX_WIDTH)
|
||||
spot.set("texture_path", "res://assets/sprites/placeholders/house.png")
|
||||
_fit_house_billboard(spot)
|
||||
# Remove legacy duplicate if any
|
||||
var dup := get_node_or_null("BuiltHouse")
|
||||
if dup:
|
||||
dup.queue_free()
|
||||
|
||||
func _fit_house_billboard(node: Node) -> void:
|
||||
var bill = node.get_node_or_null("BillboardSprite")
|
||||
if bill == null:
|
||||
return
|
||||
var h: Node3D = scene.instantiate()
|
||||
h.name = "BuiltHouse"
|
||||
add_child(h)
|
||||
h.global_position = Vector3(3, 0.1, -2)
|
||||
h.set("texture_path", "res://assets/sprites/placeholders/house.png")
|
||||
h.set("dialog_key", "build_house")
|
||||
var bill = h.get_node_or_null("BillboardSprite")
|
||||
if bill and bill.has_method("set_texture"):
|
||||
bill.set("max_width", SpriteFit.WORLD_HOUSE_MAX_WIDTH)
|
||||
if bill.has_method("set_target_height"):
|
||||
bill.set_target_height(SpriteFit.WORLD_HOUSE_HEIGHT)
|
||||
if bill.has_method("set_texture"):
|
||||
var tex: Texture2D = load("res://assets/sprites/placeholders/house.png")
|
||||
if tex:
|
||||
bill.set_texture(tex)
|
||||
|
|
@ -49,4 +72,4 @@ func _on_heist(_n: Node = null) -> void:
|
|||
Achievements.unlock("tooth_millionaire")
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_toothionaire"), 4.0)
|
||||
ui.show_dialog_key("dialog_toothionaire", 4.0)
|
||||
|
|
|
|||
|
|
@ -15,18 +15,29 @@ func _ready() -> void:
|
|||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
if mood == "detour":
|
||||
ui.show_dialog(Locale.tr_key("dialog_farmer_detour"), 4.0)
|
||||
ui.show_dialog_key("dialog_farmer_detour", 4.0)
|
||||
else:
|
||||
ui.show_dialog(Locale.tr_key("dialog_farmer_direct"), 3.5)
|
||||
ui.show_dialog_key("dialog_farmer_direct", 3.5)
|
||||
await get_tree().create_timer(1.2).timeout
|
||||
ui.show_dialog(Locale.tr_key("dialog_farmer_quest"), 4.0)
|
||||
ui.show_dialog_key("dialog_farmer_quest", 4.0)
|
||||
var urhahn := get_node_or_null("Urhahn")
|
||||
if urhahn and urhahn.has_signal("defeated"):
|
||||
urhahn.defeated.connect(_on_urhahn_down)
|
||||
if urhahn:
|
||||
urhahn.set("ai_enabled", true)
|
||||
urhahn.set("invulnerable", true)
|
||||
urhahn.set("instakill", true)
|
||||
urhahn.set("move_speed", 4.2)
|
||||
urhahn.set("attack_cooldown", 0.85)
|
||||
urhahn.set("death_cause_key", "death_rooster")
|
||||
urhahn.set("shout_key", "dialog_urhahn")
|
||||
urhahn.set("shout_interval", 5.0)
|
||||
urhahn.set("billboard_height", SpriteFit.WORLD_BOSS_HEIGHT)
|
||||
if urhahn.has_signal("defeated"):
|
||||
urhahn.defeated.connect(_on_urhahn_down)
|
||||
var hazard := get_node_or_null("RoosterHazard")
|
||||
if hazard and hazard.has_signal("body_entered"):
|
||||
# Extra: shock before death for flavor when contacting hazard
|
||||
if not hazard.body_entered.is_connected(_on_rooster_touch):
|
||||
if hazard:
|
||||
hazard.set("damage", 99)
|
||||
hazard.set("death_cause_key", "death_rooster")
|
||||
if hazard.has_signal("body_entered") and not hazard.body_entered.is_connected(_on_rooster_touch):
|
||||
hazard.body_entered.connect(_on_rooster_touch)
|
||||
var exit := get_node_or_null("Exit")
|
||||
if exit:
|
||||
|
|
@ -63,7 +74,7 @@ func _check_quests() -> void:
|
|||
GameState.set_flag("farm_quests_done", true)
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_paywall"), 4.0)
|
||||
ui.show_dialog_key("dialog_paywall", 4.0)
|
||||
Shop.purchase("continue_joke")
|
||||
GameState.set_flag("paywall_cleared", true)
|
||||
|
||||
|
|
|
|||
|
|
@ -43,10 +43,10 @@ func _on_right_body(body: Node3D) -> void:
|
|||
GameState.set_flag("mage_angered", true)
|
||||
GameState.set_flag("planet_destroyed", true)
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_mage_angry"), 4.0)
|
||||
ui.show_dialog_key("dialog_mage_angry", 4.0)
|
||||
await get_tree().create_timer(2.0).timeout
|
||||
GameState.goto_scene("moon")
|
||||
else:
|
||||
GameState.set_flag("called_black_mage", true)
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_mage_push"))
|
||||
ui.show_dialog_key("dialog_mage_push")
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
extends Node3D
|
||||
## Glitschkönig: Reveal, Quirxels, Split-Phasen, Flucht des letzten Pixels.
|
||||
## Glitschkönig: Reveal, Quirxel-Schüsse, Split, Flucht (DESIGN.md §12).
|
||||
|
||||
enum Phase { FULL, SPLIT, FLEE, DONE }
|
||||
|
||||
var _phase: Phase = Phase.FULL
|
||||
var _revealed: bool = false
|
||||
var _splits_left: int = 2
|
||||
var _splits_done: int = 0
|
||||
const SPLITS_MAX := 2
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.current_scene_id = "final_boss"
|
||||
|
|
@ -15,13 +16,22 @@ func _ready() -> void:
|
|||
_do_reveal()
|
||||
var boss := get_node_or_null("Hongkong")
|
||||
if boss:
|
||||
boss.set("max_hp", 12)
|
||||
boss.set("max_hp", 16)
|
||||
boss.set("hp", 16)
|
||||
boss.set("ai_enabled", true)
|
||||
boss.set("move_speed", 1.8)
|
||||
boss.set("attack_damage", 1)
|
||||
boss.set("attack_cooldown", 1.4)
|
||||
boss.set("death_cause_key", "death_default")
|
||||
boss.set("billboard_height", SpriteFit.WORLD_BOSS_HEIGHT)
|
||||
boss.set("shout_key", "dialog_hongkong_boss")
|
||||
boss.set("shout_interval", 8.0)
|
||||
if boss.has_signal("hit"):
|
||||
boss.hit.connect(_on_boss_hit)
|
||||
if boss.has_signal("defeated"):
|
||||
boss.defeated.connect(_on_boss_defeated)
|
||||
var t := Timer.new()
|
||||
t.wait_time = 2.8
|
||||
t.wait_time = 2.2
|
||||
t.autostart = true
|
||||
t.timeout.connect(_spawn_quirxel)
|
||||
add_child(t)
|
||||
|
|
@ -32,51 +42,65 @@ func _do_reveal() -> void:
|
|||
Inventory.add_item("wartchen", 1)
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_reveal"), 5.5)
|
||||
ui.show_dialog_key("dialog_reveal", 5.5)
|
||||
await get_tree().create_timer(2.0).timeout
|
||||
ui.show_dialog_key("dialog_boss_unclever", 3.5)
|
||||
|
||||
func _on_boss_hit(remaining: int) -> void:
|
||||
if _phase == Phase.FULL and remaining <= 6 and _splits_left > 0:
|
||||
var boss := get_node_or_null("Hongkong")
|
||||
if remaining <= 10 and _splits_done < SPLITS_MAX and _phase != Phase.FLEE:
|
||||
_phase = Phase.SPLIT
|
||||
_splits_left -= 1
|
||||
_splits_done += 1
|
||||
_spawn_split_clone()
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog("Er teilt sich wie Minecraft-Schleim!", 2.5)
|
||||
elif remaining <= 2 and _phase != Phase.FLEE and _phase != Phase.DONE:
|
||||
if remaining <= 3 and _phase != Phase.DONE:
|
||||
_phase = Phase.FLEE
|
||||
var boss := get_node_or_null("Hongkong")
|
||||
if boss is Node3D:
|
||||
boss.global_position += Vector3(randf_range(-4, 4), 0, -3)
|
||||
if boss:
|
||||
boss.set("flee_below_hp", 99)
|
||||
boss.set("move_speed", 5.5)
|
||||
boss.set("ai_enabled", true)
|
||||
|
||||
func _spawn_split_clone() -> void:
|
||||
var scene: PackedScene = load("res://scenes/prefabs/enemy.tscn")
|
||||
if scene == null:
|
||||
return
|
||||
var c: Node3D = scene.instantiate()
|
||||
c.name = "GlitschSplit"
|
||||
c.name = "GlitschSplit_%d" % _splits_done
|
||||
add_child(c)
|
||||
c.global_position = Vector3(randf_range(-3, 3), 0.1, -2)
|
||||
c.set("max_hp", 4)
|
||||
c.set("texture_path", "res://assets/sprites/placeholders/hongkong.png")
|
||||
c.global_position = Vector3(randf_range(-4, 4), 0.1, -2)
|
||||
c.set("max_hp", 5)
|
||||
c.set("texture_path", "res://assets/sprites/placeholders/glitschkoenig.png")
|
||||
c.set("ai_enabled", true)
|
||||
c.set("move_speed", 3.2)
|
||||
c.set("attack_damage", 1)
|
||||
c.set("billboard_height", 1.2)
|
||||
c.set("death_cause_key", "death_quirxel")
|
||||
var bill = c.get_node_or_null("BillboardSprite")
|
||||
if bill and bill.has_method("set_texture"):
|
||||
var tex: Texture2D = load("res://assets/sprites/placeholders/hongkong.png")
|
||||
var tex: Texture2D = load("res://assets/sprites/placeholders/glitschkoenig.png")
|
||||
if tex:
|
||||
bill.set_texture(tex)
|
||||
|
||||
func _spawn_quirxel() -> void:
|
||||
if _phase == Phase.DONE or GameState.get_flag("hongkong_defeated", false):
|
||||
return
|
||||
var scene: PackedScene = load("res://scenes/prefabs/hazard.tscn")
|
||||
var scene: PackedScene = load("res://scenes/prefabs/quirxel_projectile.tscn")
|
||||
if scene == null:
|
||||
return
|
||||
var h: Node3D = scene.instantiate()
|
||||
add_child(h)
|
||||
h.global_position = Vector3(randf_range(-6, 6), 0.1, randf_range(-6, 0))
|
||||
h.set("death_cause_key", "death_quirxel")
|
||||
await get_tree().create_timer(5.0).timeout
|
||||
if is_instance_valid(h):
|
||||
h.queue_free()
|
||||
var player := get_tree().get_first_node_in_group("player")
|
||||
var boss := get_node_or_null("Hongkong")
|
||||
var origin := Vector3(0, 0.8, -2)
|
||||
if boss is Node3D:
|
||||
origin = (boss as Node3D).global_position + Vector3(0, 0.9, 0)
|
||||
var q: Node3D = scene.instantiate()
|
||||
add_child(q)
|
||||
q.global_position = origin + Vector3(randf_range(-1.5, 1.5), 0, randf_range(-1, 1))
|
||||
var dir := Vector3(0, 0, 1)
|
||||
if player is Node3D:
|
||||
dir = ((player as Node3D).global_position + Vector3(0, 0.8, 0) - q.global_position).normalized()
|
||||
q.set("velocity", dir * 7.5)
|
||||
|
||||
func _on_boss_defeated() -> void:
|
||||
_phase = Phase.DONE
|
||||
|
|
@ -84,8 +108,8 @@ func _on_boss_defeated() -> void:
|
|||
GameState.set_flag("hongkong_defeated", true)
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_hongkong_boss"), 3.0)
|
||||
ui.show_dialog_key("dialog_hongkong_boss", 3.0)
|
||||
await get_tree().create_timer(2.5).timeout
|
||||
ui.show_dialog(Locale.tr_key("dialog_bear_end"), 5.0)
|
||||
ui.show_dialog_key("dialog_bear_end", 5.0)
|
||||
await get_tree().create_timer(3.5).timeout
|
||||
GameState.goto_scene("end_screen")
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
extends Control
|
||||
## Intro: Tisch, Konsole, Sprecher, Explosion.
|
||||
## Intro: Tisch, Konsole, Sprecher, Explosion — Platzhalter unter assets/sprites/placeholders/.
|
||||
|
||||
const TEX_CONSOLE := preload("res://assets/sprites/placeholders/console.png")
|
||||
const TEX_CONSOLE_BOOM := preload("res://assets/sprites/placeholders/console_exploded.png")
|
||||
|
||||
@onready var label: Label = $Label
|
||||
@onready var console: TextureRect = $Console
|
||||
@onready var boom_flash: TextureRect = $BoomFlash
|
||||
@onready var speaker: TextureRect = $Speaker
|
||||
|
||||
var _step: int = 0
|
||||
|
||||
|
|
@ -9,22 +15,57 @@ func _ready() -> void:
|
|||
GameState.current_scene_id = "intro_cutscene"
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
AudioDirector.stop_music()
|
||||
# Decorative full-screen nodes must not eat clicks meant to advance.
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
for child_name in ["Bg", "Desk", "Console", "Speaker", "BoomFlash", "Label", "Hint"]:
|
||||
var n := get_node_or_null(child_name) as Control
|
||||
if n:
|
||||
n.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
console.texture = TEX_CONSOLE
|
||||
boom_flash.visible = false
|
||||
_show_step()
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("skip_cutscene") or event.is_action_pressed("interact") or event.is_action_pressed("attack"):
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
_advance()
|
||||
accept_event()
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("skip_cutscene") \
|
||||
or event.is_action_pressed("dialog_advance") \
|
||||
or event.is_action_pressed("ui_accept") \
|
||||
or event.is_action_pressed("interact") \
|
||||
or event.is_action_pressed("attack"):
|
||||
_advance()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
func _show_step() -> void:
|
||||
match _step:
|
||||
0:
|
||||
# Sprecher + intakte Konsole auf dem Tisch
|
||||
speaker.visible = true
|
||||
boom_flash.visible = false
|
||||
console.texture = TEX_CONSOLE
|
||||
console.modulate = Color.WHITE
|
||||
label.text = Locale.tr_key("dialog_intro_1")
|
||||
AudioDirector.play_dialog_line("dialog_intro_1")
|
||||
1:
|
||||
# Explosion
|
||||
AudioDirector.play_explosion()
|
||||
speaker.visible = false
|
||||
boom_flash.visible = true
|
||||
console.texture = TEX_CONSOLE_BOOM
|
||||
console.modulate = Color(1.2, 0.7, 0.4, 1)
|
||||
label.text = "BOOM"
|
||||
2:
|
||||
# Nachspiel: Trümmer — same confirm keys as VN (Enter/C) + intro keys
|
||||
boom_flash.visible = false
|
||||
console.texture = TEX_CONSOLE_BOOM
|
||||
console.modulate = Color(0.7, 0.55, 0.45, 1)
|
||||
label.text = Locale.tr_key("dialog_intro_2")
|
||||
AudioDirector.play_dialog_line("dialog_intro_2")
|
||||
_:
|
||||
AudioDirector.stop_voice()
|
||||
GameState.goto_scene("village")
|
||||
|
||||
func _advance() -> void:
|
||||
|
|
|
|||
|
|
@ -1,53 +1,5 @@
|
|||
extends Node3D
|
||||
## Massage: Schlange massieren → Bear-Save; oder Secret-Milch.
|
||||
|
||||
@onready var choice_ui: CanvasLayer = $ChoiceUI
|
||||
## Legacy scene — Masseur-Kuh lives in the village now.
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.current_scene_id = "massage_studio"
|
||||
GameState.heal_full()
|
||||
choice_ui.visible = false
|
||||
var cow := get_node_or_null("CowMasseur")
|
||||
if cow and cow.has_signal("interacted"):
|
||||
cow.interacted.connect(_on_cow)
|
||||
if cow:
|
||||
cow.require_item_id = ""
|
||||
cow.achievement_id = ""
|
||||
cow.quest_flag = ""
|
||||
cow.consume_required = false
|
||||
cow.give_item_id = ""
|
||||
|
||||
func _on_cow(_npc: Node = null) -> void:
|
||||
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")
|
||||
|
||||
func _close_choices() -> void:
|
||||
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"):
|
||||
ui0.show_dialog(Locale.tr_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 ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_cow_massage") + "\n" + Locale.tr_key("dialog_bear_saved"), 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"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_cow_drink"), 4.0)
|
||||
GameState.goto_scene("village")
|
||||
|
|
|
|||
|
|
@ -1,129 +1,5 @@
|
|||
extends Node3D
|
||||
## Wiese: Eisbär retten (Schlangen holen → Massage ODER hier abgeben), Glitzos.
|
||||
|
||||
var _glitzo_timer: float = 0.0
|
||||
var _bear_saved: bool = false
|
||||
var _attack_cd: float = 0.0
|
||||
var _first_glitzo_done: bool = false
|
||||
## Legacy scene — meadow is now a zone in village overworld.
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.current_scene_id = "meadow"
|
||||
GameState.heal_full()
|
||||
_bear_saved = bool(GameState.get_flag("bear_saved", false))
|
||||
_first_glitzo_done = Achievements.is_unlocked("first_glitzo")
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
if _bear_saved:
|
||||
ui.show_dialog(Locale.tr_key("dialog_bear_saved"))
|
||||
else:
|
||||
ui.show_dialog(Locale.tr_key("dialog_bear_help"))
|
||||
var bear := get_node_or_null("Bear")
|
||||
if bear and bear.has_signal("interacted"):
|
||||
bear.interacted.connect(_on_bear)
|
||||
var snake := get_node_or_null("SnakePickup")
|
||||
if snake and snake.has_signal("interacted"):
|
||||
snake.interacted.connect(_on_snake_pickup)
|
||||
# Disable mower kill if snakes already bagged? Keep hazard until massage done.
|
||||
if GameState.get_flag("snake_massaged", false):
|
||||
_disable_mower()
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_glitzo_timer += delta
|
||||
if _glitzo_timer >= 3.5:
|
||||
_glitzo_timer = 0.0
|
||||
_spawn_glitzo()
|
||||
_tick_glitzo_attacks(delta)
|
||||
_cull_glitzos_off_meadow()
|
||||
|
||||
func _tick_glitzo_attacks(delta: float) -> void:
|
||||
_attack_cd = max(0.0, _attack_cd - delta)
|
||||
if _attack_cd > 0.0:
|
||||
return
|
||||
var player := get_tree().get_first_node_in_group("player")
|
||||
if player == null:
|
||||
return
|
||||
for child in get_children():
|
||||
if not child.is_in_group("enemy"):
|
||||
continue
|
||||
if not str(child.get("texture_path")).ends_with("glitzo.png") and not child.name.begins_with("Glitzo"):
|
||||
continue
|
||||
if child is Node3D and player.global_position.distance_to(child.global_position) < 1.6:
|
||||
_attack_cd = 1.15
|
||||
GameState.register_glitzo_hit()
|
||||
return
|
||||
|
||||
func _cull_glitzos_off_meadow() -> void:
|
||||
## DESIGN: leave the meadow → they shed pixels and die.
|
||||
for child in get_children():
|
||||
if child is Node3D and (child.name.begins_with("Glitzo") or str(child.get("texture_path")).ends_with("glitzo.png")):
|
||||
var p: Vector3 = child.global_position
|
||||
if abs(p.x) > 18.0 or abs(p.z) > 18.0:
|
||||
child.queue_free()
|
||||
|
||||
func _spawn_glitzo() -> void:
|
||||
var scene: PackedScene = load("res://scenes/prefabs/enemy.tscn")
|
||||
if scene == null:
|
||||
return
|
||||
var g: Node3D = scene.instantiate()
|
||||
g.name = "GlitzoSpawn_%d" % Time.get_ticks_msec()
|
||||
g.set("max_hp", 4)
|
||||
g.set("texture_path", "res://assets/sprites/placeholders/glitzo.png")
|
||||
if not _first_glitzo_done:
|
||||
g.set("achievement_on_defeat", "first_glitzo")
|
||||
add_child(g)
|
||||
g.global_position = Vector3(randf_range(-8, 8), 0.1, randf_range(-6, 2))
|
||||
var bill: Node = g.get_node_or_null("BillboardSprite")
|
||||
if bill and bill.has_method("set_texture"):
|
||||
var tex: Texture2D = load("res://assets/sprites/placeholders/glitzo.png")
|
||||
if tex:
|
||||
bill.set_texture(tex)
|
||||
if g.has_signal("defeated"):
|
||||
g.defeated.connect(_on_glitzo_killed)
|
||||
|
||||
func _on_glitzo_killed() -> void:
|
||||
_first_glitzo_done = true
|
||||
|
||||
func _on_bear(_npc: Node = null) -> void:
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if _bear_saved:
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_bear_saved"))
|
||||
return
|
||||
# Need snakes rescued via inventory + preferably massaged; tooth if massaged or handing snakes in after pickup
|
||||
if Inventory.has_item("snake") and GameState.get_flag("snake_massaged", false):
|
||||
_save_bear()
|
||||
return
|
||||
if Inventory.has_item("snake"):
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_villager_snake"))
|
||||
return
|
||||
# First talk: point at snakes / give hint, auto-pickup one for convenience
|
||||
Inventory.add_item("snake", 1)
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_snake_pain") + "\n" + Locale.tr_key("dialog_bear_help"))
|
||||
|
||||
func _on_snake_pickup(_npc: Node = null) -> void:
|
||||
if not Inventory.has_item("snake"):
|
||||
Inventory.add_item("snake", 1)
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_snake_pain"))
|
||||
|
||||
func _save_bear() -> void:
|
||||
_bear_saved = true
|
||||
GameState.set_flag("bear_saved", true)
|
||||
if not Inventory.has_item("tooth"):
|
||||
Inventory.add_item("tooth", 1)
|
||||
_disable_mower()
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_bear_saved"), 4.5)
|
||||
|
||||
func _disable_mower() -> void:
|
||||
var mower := get_node_or_null("MowerHazard")
|
||||
if mower:
|
||||
mower.queue_free()
|
||||
|
||||
func notify_snake_massaged() -> void:
|
||||
if not _bear_saved:
|
||||
_save_bear()
|
||||
GameState.goto_scene("village")
|
||||
|
|
|
|||
141
scripts/levels/meadow_zone.gd
Normal file
141
scripts/levels/meadow_zone.gd
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
extends Node3D
|
||||
## Wiese-Zone innerhalb der Dorf-Hauptwelt (kein eigener Scene-Teleport).
|
||||
## Glitzos leben nur in meadow_aabb; außerhalb → Pixel-Sprühnebel.
|
||||
|
||||
@export var meadow_aabb: AABB = AABB(Vector3(20.0, -1.0, -18.0), Vector3(36.0, 8.0, 36.0))
|
||||
|
||||
var _glitzo_timer: float = 0.0
|
||||
var _bear_saved: bool = false
|
||||
var _first_glitzo_done: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
_bear_saved = bool(GameState.get_flag("bear_saved", false))
|
||||
_first_glitzo_done = Achievements.is_unlocked("first_glitzo")
|
||||
var bear := get_node_or_null("Bear")
|
||||
if bear and bear.has_signal("interacted"):
|
||||
bear.interacted.connect(_on_bear)
|
||||
var snake := get_node_or_null("SnakePickup")
|
||||
if snake and snake.has_signal("interacted"):
|
||||
snake.interacted.connect(_on_snake_pickup)
|
||||
if Inventory.has_item("snake") or GameState.get_flag("snake_massaged", false):
|
||||
if snake:
|
||||
snake.queue_free()
|
||||
if GameState.get_flag("snake_massaged", false):
|
||||
_disable_mower()
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
## Only tick while player is near the meadow (cheap).
|
||||
var player := get_tree().get_first_node_in_group("player")
|
||||
if player is Node3D:
|
||||
var pp: Vector3 = (player as Node3D).global_position
|
||||
if not _near_meadow(pp):
|
||||
_cull_glitzos_off_meadow()
|
||||
return
|
||||
_glitzo_timer += delta
|
||||
if _glitzo_timer >= 3.2:
|
||||
_glitzo_timer = 0.0
|
||||
_spawn_glitzo()
|
||||
_cull_glitzos_off_meadow()
|
||||
|
||||
func _near_meadow(p: Vector3) -> bool:
|
||||
var expanded := meadow_aabb.grow(8.0)
|
||||
return expanded.has_point(Vector3(p.x, 0.0, p.z))
|
||||
|
||||
func _cull_glitzos_off_meadow() -> void:
|
||||
for child in get_children():
|
||||
if child is Node3D and (child.name.begins_with("Glitzo") or str(child.get("texture_path")).ends_with("glitzo.png")):
|
||||
var p: Vector3 = child.global_position
|
||||
if not meadow_aabb.has_point(Vector3(p.x, 0.0, p.z)):
|
||||
_pixel_spray(p)
|
||||
child.queue_free()
|
||||
|
||||
func _pixel_spray(at: Vector3) -> void:
|
||||
var scene: PackedScene = load("res://scenes/prefabs/quirxel_projectile.tscn")
|
||||
if scene == null:
|
||||
return
|
||||
for i in range(5):
|
||||
var q: Node3D = scene.instantiate()
|
||||
get_parent().add_child(q)
|
||||
q.global_position = at + Vector3(0, 0.4, 0)
|
||||
var ang := randf() * TAU
|
||||
q.set("velocity", Vector3(cos(ang), 0.5, sin(ang)) * randf_range(3.0, 6.0))
|
||||
q.set("lifetime", 0.7)
|
||||
q.set("hp", 99)
|
||||
|
||||
func _spawn_glitzo() -> void:
|
||||
var scene: PackedScene = load("res://scenes/prefabs/enemy.tscn")
|
||||
if scene == null:
|
||||
return
|
||||
var g: Node3D = scene.instantiate()
|
||||
g.name = "GlitzoSpawn_%d" % Time.get_ticks_msec()
|
||||
g.set("max_hp", 4)
|
||||
g.set("texture_path", "res://assets/sprites/placeholders/glitzo.png")
|
||||
g.set("ai_enabled", true)
|
||||
g.set("glitzo_mode", true)
|
||||
g.set("move_speed", 2.6)
|
||||
g.set("attack_cooldown", 1.15)
|
||||
g.set("attack_range", 1.1)
|
||||
g.set("death_cause_key", "death_glitzo")
|
||||
g.set("billboard_height", 1.0)
|
||||
g.set("aggro_range", 22.0)
|
||||
if not _first_glitzo_done:
|
||||
g.set("achievement_on_defeat", "first_glitzo")
|
||||
add_child(g)
|
||||
## Spawn inside meadow AABB (local to world coords used by export).
|
||||
var min_p := meadow_aabb.position
|
||||
var max_p := meadow_aabb.position + meadow_aabb.size
|
||||
g.global_position = Vector3(
|
||||
randf_range(min_p.x + 4.0, max_p.x - 4.0),
|
||||
0.1,
|
||||
randf_range(min_p.z + 4.0, max_p.z - 4.0)
|
||||
)
|
||||
var bill: Node = g.get_node_or_null("BillboardSprite")
|
||||
if bill and bill.has_method("set_texture"):
|
||||
var tex: Texture2D = load("res://assets/sprites/placeholders/glitzo.png")
|
||||
if tex:
|
||||
bill.set_texture(tex)
|
||||
if g.has_signal("defeated"):
|
||||
g.defeated.connect(_on_glitzo_killed)
|
||||
|
||||
func _on_glitzo_killed() -> void:
|
||||
_first_glitzo_done = true
|
||||
|
||||
func _on_bear(_npc: Node = null) -> void:
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if _bear_saved:
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog_key("dialog_bear_saved")
|
||||
return
|
||||
if Inventory.has_item("snake") and GameState.get_flag("snake_massaged", false):
|
||||
_save_bear()
|
||||
return
|
||||
if Inventory.has_item("snake"):
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog_key("dialog_villager_snake")
|
||||
return
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog_key("dialog_bear_help")
|
||||
|
||||
func _on_snake_pickup(_npc: Node = null) -> void:
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog_key("dialog_snake_picked")
|
||||
|
||||
func _save_bear() -> void:
|
||||
_bear_saved = true
|
||||
GameState.set_flag("bear_saved", true)
|
||||
if not Inventory.has_item("tooth"):
|
||||
Inventory.add_item("tooth", 1)
|
||||
_disable_mower()
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog_key("dialog_bear_saved", 4.5)
|
||||
|
||||
func _disable_mower() -> void:
|
||||
var mower := get_node_or_null("MowerHazard")
|
||||
if mower:
|
||||
mower.queue_free()
|
||||
|
||||
func notify_snake_massaged() -> void:
|
||||
if not _bear_saved:
|
||||
_save_bear()
|
||||
|
|
@ -40,7 +40,7 @@ func _on_shades(_n: Node = null) -> void:
|
|||
Shop.purchase("sunglasses")
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_sunglasses_free"))
|
||||
ui.show_dialog_key("dialog_sunglasses_free")
|
||||
|
||||
func _start_bowser_fight(_n: Node = null) -> void:
|
||||
if GameState.get_flag("bowser_defeated", false):
|
||||
|
|
@ -51,7 +51,7 @@ func _start_bowser_fight(_n: Node = null) -> void:
|
|||
_hits = 0
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_bowser"))
|
||||
ui.show_dialog_key("dialog_bowser")
|
||||
_refresh_hammers()
|
||||
|
||||
func _refresh_hammers() -> void:
|
||||
|
|
|
|||
|
|
@ -1,20 +1,5 @@
|
|||
extends Node3D
|
||||
## Schuhmacher Reif. Sieg → Tonne Tiramisu + Fett-Debuff.
|
||||
## Legacy scene — shoe boss is now local in village overworld.
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.current_scene_id = "shoe_boss"
|
||||
GameState.heal_full()
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_reif"))
|
||||
var reif := get_node_or_null("Reif")
|
||||
if reif and reif.has_signal("defeated"):
|
||||
reif.defeated.connect(_on_reif_defeated)
|
||||
|
||||
func _on_reif_defeated() -> void:
|
||||
Inventory.add_item("tiramisu", 1000)
|
||||
GameState.apply_fat(600.0)
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("show_dialog"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_tiramisu"), 5.0)
|
||||
GameState.set_flag("reif_defeated", true)
|
||||
GameState.goto_scene("village")
|
||||
|
|
|
|||
|
|
@ -15,17 +15,38 @@ func _ready() -> void:
|
|||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
AudioDirector.burger_cham_enabled = true
|
||||
AudioDirector.play_title_music()
|
||||
# Lyrics belong in the sung track — not on-screen (DESIGN.md §1).
|
||||
if music_hint:
|
||||
music_hint.visible = false
|
||||
_refresh()
|
||||
Locale.locale_changed.connect(_refresh)
|
||||
_spawn_rain()
|
||||
_spawn_disturbed()
|
||||
lang_panel.visible = Settings.needs_language_picker()
|
||||
start_btn.visible = not lang_panel.visible
|
||||
|
||||
func _spawn_disturbed() -> void:
|
||||
## Bottom-left disturbed human (DESIGN.md §1)
|
||||
var s := Sprite2D.new()
|
||||
s.name = "DisturbedHuman"
|
||||
s.texture = load("res://assets/sprites/placeholders/disturbed.png")
|
||||
s.position = Vector2(90, 640)
|
||||
s.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||||
SpriteFit.apply_sprite2d(s, 96.0)
|
||||
add_child(s)
|
||||
# Hide ColorRect placeholder if present
|
||||
var old := get_node_or_null("Disturbed")
|
||||
if old:
|
||||
old.visible = false
|
||||
|
||||
func _refresh() -> void:
|
||||
if not is_inside_tree():
|
||||
return
|
||||
subtitle.text = Locale.tr_key("title_pay2win")
|
||||
bubble.text = Locale.tr_key("title_bubble")
|
||||
start_btn.text = Locale.tr_key("menu_start")
|
||||
music_hint.text = Locale.tr_key("title_music_hint")
|
||||
if music_hint:
|
||||
music_hint.visible = false
|
||||
$LangPanel/Margin/VBox/Title.text = Locale.tr_key("lang_pick_title")
|
||||
$LangPanel/Margin/VBox/BtnDE.text = Locale.tr_key("lang_de")
|
||||
$LangPanel/Margin/VBox/BtnEN.text = Locale.tr_key("lang_en")
|
||||
|
|
@ -41,7 +62,8 @@ func _spawn_rain() -> void:
|
|||
var s := Sprite2D.new()
|
||||
s.texture = load(paths[i % paths.size()])
|
||||
s.position = Vector2(randf() * 1280.0, randf() * -720.0)
|
||||
s.scale = Vector2(3, 3)
|
||||
# Fit longest side to ~48px — works for 16×16 and 2k burger art alike.
|
||||
SpriteFit.apply_sprite2d(s, SpriteFit.UI_RAIN_PX)
|
||||
s.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||||
rain.add_child(s)
|
||||
_rain_sprites.append(s)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,83 @@
|
|||
@tool
|
||||
extends Node3D
|
||||
## Kaputtes AC-Dorf: fliehende NPCs, Tisch-Knopf → Reif.
|
||||
## 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"):
|
||||
ui.show_dialog(Locale.tr_key("dialog_villager_run"))
|
||||
# Strip wrong early toothionaire / snake gift
|
||||
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 = ""
|
||||
|
|
@ -20,11 +88,559 @@ func _ready() -> void:
|
|||
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.is_in_group("interactable") and n.name != "TableButton" and n is Node3D:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,19 +1,22 @@
|
|||
@tool
|
||||
extends CharacterBody3D
|
||||
class_name PlayerController
|
||||
## Third-person controller: camera follows player, movement is camera-relative.
|
||||
## Billboard sprites face the camera (Mario-tree style).
|
||||
## Third-person controller + focus highlight / attack feedback.
|
||||
## @tool: only applies billboard look in the editor (no input/camera capture).
|
||||
|
||||
@export var move_speed: float = 6.0
|
||||
@export var attack_range: float = 1.8
|
||||
@export var attack_range: float = 2.75 ## Longer than enemy melee so you can poke before they hit
|
||||
@export var interact_range: float = 2.8
|
||||
@export var attack_damage: int = 1
|
||||
@export var mouse_sensitivity: float = 0.0025
|
||||
@export var min_pitch_deg: float = -35.0
|
||||
@export var max_pitch_deg: float = 55.0
|
||||
@export var spring_length: float = 5.5
|
||||
@export var camera_height: float = 1.4
|
||||
@export var spring_length: float = 6.0
|
||||
@export var camera_height: float = 1.62
|
||||
|
||||
signal interacted
|
||||
signal attacked
|
||||
signal focus_changed(target: Node, prompt: String)
|
||||
|
||||
@onready var _billboard: BillboardSprite = $BillboardSprite
|
||||
@onready var _yaw_pivot: Node3D = $CameraYaw
|
||||
|
|
@ -22,9 +25,19 @@ signal attacked
|
|||
|
||||
var _locked: bool = false
|
||||
var _pitch: float = deg_to_rad(-18.0)
|
||||
var _focus: Node = null
|
||||
var _attack_focus: Node = null
|
||||
var _melee_vfx: MeleeVfx
|
||||
|
||||
func _ready() -> void:
|
||||
_apply_editor_billboard()
|
||||
if Engine.is_editor_hint():
|
||||
set_physics_process(false)
|
||||
set_process(false)
|
||||
set_process_unhandled_input(false)
|
||||
return
|
||||
add_to_group("player")
|
||||
camera_height = SpriteFit.WORLD_CAMERA_EYE
|
||||
if _spring:
|
||||
_spring.spring_length = spring_length
|
||||
_spring.collision_mask = 1
|
||||
|
|
@ -33,17 +46,43 @@ func _ready() -> void:
|
|||
_camera.current = true
|
||||
_camera.fov = 70.0
|
||||
_apply_pitch()
|
||||
_set_mouse_captured(true)
|
||||
if _billboard and _billboard.texture == null:
|
||||
# Scene load may already have opened a blocking VN (pause + visible mouse).
|
||||
# Capturing here would steal the cursor from Weiter / dialog UI.
|
||||
var ui0 := get_node_or_null("/root/UIRoot")
|
||||
if ui0 and ui0.has_method("is_dialog_open") and ui0.is_dialog_open():
|
||||
_set_mouse_captured(false)
|
||||
else:
|
||||
_set_mouse_captured(true)
|
||||
_apply_editor_billboard()
|
||||
_melee_vfx = MeleeVfx.new()
|
||||
_melee_vfx.name = "MeleeVfx"
|
||||
add_child(_melee_vfx)
|
||||
_melee_vfx.set_ranges(attack_range, interact_range)
|
||||
|
||||
|
||||
func _apply_editor_billboard() -> void:
|
||||
if _billboard == null:
|
||||
_billboard = get_node_or_null("BillboardSprite") as BillboardSprite
|
||||
if _billboard == null:
|
||||
return
|
||||
_billboard.max_width = SpriteFit.WORLD_MAX_WIDTH
|
||||
_billboard.set_target_height(SpriteFit.WORLD_PLAYER_HEIGHT)
|
||||
if _billboard.texture == null:
|
||||
var tex: Texture2D = load("res://assets/sprites/placeholders/player.png")
|
||||
if tex:
|
||||
_billboard.set_texture(tex)
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if Engine.is_editor_hint():
|
||||
return
|
||||
_clear_focus_highlights()
|
||||
_set_mouse_captured(false)
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if _locked or get_tree().paused:
|
||||
if Engine.is_editor_hint() or _locked or get_tree().paused:
|
||||
return
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("is_dialog_open") and ui.is_dialog_open():
|
||||
return
|
||||
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
||||
var motion := event as InputEventMouseMotion
|
||||
|
|
@ -60,6 +99,12 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||
func _physics_process(_delta: float) -> void:
|
||||
if _locked or get_tree().paused:
|
||||
velocity = Vector3.ZERO
|
||||
_update_focus()
|
||||
return
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("is_dialog_open") and ui.is_dialog_open():
|
||||
velocity = Vector3.ZERO
|
||||
_update_focus()
|
||||
return
|
||||
|
||||
var input := Vector2(
|
||||
|
|
@ -83,10 +128,13 @@ func _physics_process(_delta: float) -> void:
|
|||
speed *= 0.55
|
||||
if GameState.is_shocked():
|
||||
velocity = Vector3.ZERO
|
||||
_update_focus()
|
||||
return
|
||||
velocity = dir * speed
|
||||
move_and_slide()
|
||||
|
||||
_update_focus()
|
||||
|
||||
if Input.is_action_just_pressed("interact"):
|
||||
_try_interact()
|
||||
if Input.is_action_just_pressed("attack"):
|
||||
|
|
@ -100,7 +148,6 @@ func lock_input(locked: bool) -> void:
|
|||
_set_mouse_captured(true)
|
||||
|
||||
func set_gameplay_focused(focused: bool) -> void:
|
||||
## Called by pause UI: release mouse in menus, recapture in gameplay.
|
||||
if focused and not _locked:
|
||||
_set_mouse_captured(true)
|
||||
else:
|
||||
|
|
@ -117,17 +164,137 @@ func _set_mouse_captured(captured: bool) -> void:
|
|||
else:
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
|
||||
func _interact_distance_to(body: Node3D) -> float:
|
||||
if body.has_method("get_interact_distance"):
|
||||
return float(body.call("get_interact_distance", global_position))
|
||||
return global_position.distance_to(body.global_position)
|
||||
|
||||
func _nearest_in_group(group: String, radius: float) -> Node:
|
||||
var best: Node = null
|
||||
var best_d := radius
|
||||
for body in get_tree().get_nodes_in_group(group):
|
||||
if body == self or not (body is Node3D):
|
||||
continue
|
||||
if not is_instance_valid(body):
|
||||
continue
|
||||
var d: float = _interact_distance_to(body as Node3D)
|
||||
if d <= best_d:
|
||||
best_d = d
|
||||
best = body
|
||||
return best
|
||||
|
||||
func _update_focus() -> void:
|
||||
if get_tree().paused or _locked or GameState.current_scene_id in ["title_screen", "death_screen", "intro_cutscene"]:
|
||||
_set_focus(null, null)
|
||||
return
|
||||
var interact := _nearest_in_group("interactable", interact_range)
|
||||
if interact != null and interact.is_in_group("enemy"):
|
||||
var talk := _nearest_talk_target(interact_range)
|
||||
if talk != null:
|
||||
interact = talk
|
||||
var attack := _nearest_in_group("enemy", attack_range)
|
||||
_set_focus(interact, attack)
|
||||
|
||||
func _nearest_talk_target(radius: float) -> Node:
|
||||
var best: Node = null
|
||||
var best_d := radius
|
||||
for body in get_tree().get_nodes_in_group("interactable"):
|
||||
if body == self or not (body is Node3D):
|
||||
continue
|
||||
if body.is_in_group("enemy"):
|
||||
continue
|
||||
var d: float = _interact_distance_to(body as Node3D)
|
||||
if d <= best_d:
|
||||
best_d = d
|
||||
best = body
|
||||
return best
|
||||
|
||||
func _set_focus(interact: Node, attack: Node) -> void:
|
||||
if interact == _focus and attack == _attack_focus:
|
||||
_emit_prompt()
|
||||
return
|
||||
_clear_focus_highlights()
|
||||
_focus = interact
|
||||
_attack_focus = attack
|
||||
if _focus != null and _focus.has_method("set_interaction_highlight"):
|
||||
_focus.set_interaction_highlight(true, Color(1.4, 1.35, 0.4))
|
||||
elif _focus != null:
|
||||
_highlight_node(_focus, true, Color(1.4, 1.35, 0.4))
|
||||
if _attack_focus != null and _attack_focus != _focus:
|
||||
if _attack_focus.has_method("set_interaction_highlight"):
|
||||
_attack_focus.set_interaction_highlight(true, Color(1.5, 0.45, 0.35))
|
||||
else:
|
||||
_highlight_node(_attack_focus, true, Color(1.5, 0.45, 0.35))
|
||||
_emit_prompt()
|
||||
|
||||
func _emit_prompt() -> void:
|
||||
## Interact stays as text prompt; attack/range is visual (rings + swing), not text.
|
||||
var prompt := ""
|
||||
if _focus != null:
|
||||
if _focus.has_method("get_interact_prompt"):
|
||||
prompt = str(_focus.get_interact_prompt())
|
||||
else:
|
||||
prompt = Locale.tr_key("prompt_use")
|
||||
var exit_hint := _near_exit_hint()
|
||||
if exit_hint != "" and prompt == "":
|
||||
prompt = exit_hint
|
||||
elif exit_hint != "" and prompt != "":
|
||||
prompt = "%s %s" % [prompt, exit_hint]
|
||||
if _melee_vfx:
|
||||
_melee_vfx.show_attack_range(_attack_focus != null)
|
||||
_melee_vfx.show_interact_range(_focus != null)
|
||||
focus_changed.emit(_focus, prompt)
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("set_interact_prompt"):
|
||||
ui.set_interact_prompt(prompt)
|
||||
|
||||
func _near_exit_hint() -> String:
|
||||
for body in get_tree().get_nodes_in_group("exit_trigger"):
|
||||
if body is Node3D and global_position.distance_to((body as Node3D).global_position) <= 3.5:
|
||||
return Locale.tr_key("prompt_exit")
|
||||
return ""
|
||||
|
||||
func _clear_focus_highlights() -> void:
|
||||
if _focus != null and is_instance_valid(_focus):
|
||||
if _focus.has_method("set_interaction_highlight"):
|
||||
_focus.set_interaction_highlight(false)
|
||||
else:
|
||||
_highlight_node(_focus, false)
|
||||
if _attack_focus != null and is_instance_valid(_attack_focus) and _attack_focus != _focus:
|
||||
if _attack_focus.has_method("set_interaction_highlight"):
|
||||
_attack_focus.set_interaction_highlight(false)
|
||||
else:
|
||||
_highlight_node(_attack_focus, false)
|
||||
|
||||
func _highlight_node(node: Node, enabled: bool, color: Color = Color(1.35, 1.25, 0.35)) -> void:
|
||||
var bill = node.get_node_or_null("BillboardSprite")
|
||||
if bill and bill.has_method("set_highlight"):
|
||||
bill.set_highlight(enabled, color)
|
||||
|
||||
func _try_interact() -> void:
|
||||
interacted.emit()
|
||||
for body in get_tree().get_nodes_in_group("interactable"):
|
||||
if body is Node3D and global_position.distance_to(body.global_position) <= 2.2:
|
||||
if body.has_method("on_interact"):
|
||||
body.on_interact(self)
|
||||
return
|
||||
var target := _focus
|
||||
if target == null:
|
||||
target = _nearest_talk_target(interact_range)
|
||||
if target == null:
|
||||
target = _nearest_in_group("interactable", interact_range)
|
||||
if target != null and target.has_method("on_interact"):
|
||||
_flash_feedback(target, Color(1.6, 1.6, 0.6))
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("pulse_interact"):
|
||||
ui.pulse_interact()
|
||||
target.on_interact(self)
|
||||
|
||||
func _try_attack() -> void:
|
||||
attacked.emit()
|
||||
AudioDirector.play_hit()
|
||||
if _melee_vfx:
|
||||
_melee_vfx.play_swing()
|
||||
if _billboard and _billboard.has_method("flash"):
|
||||
_billboard.flash(Color(2.2, 2.0, 1.4), 0.1)
|
||||
var ui := get_node_or_null("/root/UIRoot")
|
||||
if ui and ui.has_method("pulse_attack"):
|
||||
ui.pulse_attack()
|
||||
var dmg := attack_damage
|
||||
if Inventory.has_item("tooth"):
|
||||
dmg = max(dmg, 2)
|
||||
|
|
@ -135,7 +302,31 @@ func _try_attack() -> void:
|
|||
dmg = max(dmg, 99)
|
||||
if Inventory.has_item("wartchen"):
|
||||
dmg = max(dmg, 3)
|
||||
var hit_any := false
|
||||
var targets: Array = []
|
||||
if _attack_focus != null:
|
||||
targets.append(_attack_focus)
|
||||
for body in get_tree().get_nodes_in_group("enemy"):
|
||||
if body in targets:
|
||||
continue
|
||||
if body is Node3D and global_position.distance_to(body.global_position) <= attack_range:
|
||||
targets.append(body)
|
||||
for body in targets:
|
||||
if body is Node3D and global_position.distance_to(body.global_position) <= attack_range:
|
||||
if body.has_method("take_hit"):
|
||||
body.take_hit(dmg)
|
||||
_flash_feedback(body, Color(2.0, 0.5, 0.4))
|
||||
if _melee_vfx:
|
||||
_melee_vfx.burst_at((body as Node3D).global_position, true)
|
||||
hit_any = true
|
||||
if not hit_any:
|
||||
# Miss puff at swing tip in front of player
|
||||
var tip := global_position + (-_yaw_pivot.global_transform.basis.z) * attack_range
|
||||
tip.y = global_position.y
|
||||
if _melee_vfx:
|
||||
_melee_vfx.burst_at(tip, false)
|
||||
|
||||
func _flash_feedback(node: Node, color: Color) -> void:
|
||||
var bill = node.get_node_or_null("BillboardSprite")
|
||||
if bill and bill.has_method("flash"):
|
||||
bill.flash(color, 0.15)
|
||||
|
|
|
|||
|
|
@ -421,6 +421,10 @@ def main() -> None:
|
|||
write_png(sprites / "burger.png", 16, 16, solid(180, 120, 40))
|
||||
write_png(sprites / "disturbed.png", 16, 16, face(solid(180, 180, 180)))
|
||||
write_png(sprites / "shoe.png", 16, 16, solid(90, 50, 20))
|
||||
write_png(sprites / "reif.png", 16, 16, face(solid(120, 80, 40)))
|
||||
write_png(sprites / "mark.png", 16, 16, face(solid(100, 70, 35)))
|
||||
write_png(sprites / "stick.png", 16, 16, solid(90, 55, 20))
|
||||
write_png(sprites / "tiramisu.png", 16, 16, solid(90, 55, 30))
|
||||
write_png(sprites / "glitzo.png", 16, 16, checker((255, 0, 255, 255), (0, 255, 255, 255), 2))
|
||||
write_png(sprites / "bear.png", 16, 16, solid(240, 240, 255))
|
||||
write_png(sprites / "snake.png", 16, 16, solid(40, 180, 60))
|
||||
|
|
@ -435,6 +439,35 @@ def main() -> None:
|
|||
write_png(sprites / "house.png", 16, 16, solid(140, 90, 50))
|
||||
write_png(sprites / "skull.png", 16, 16, solid(240, 240, 240))
|
||||
write_png(sprites / "quirxel.png", 8, 8, solid(255, 255, 0))
|
||||
# DESIGN inventory / props (stubs — do not clobber large user art in repo by hand)
|
||||
for name, rgb in {
|
||||
"tooth.png": (250, 250, 245),
|
||||
"milk.png": (245, 245, 250),
|
||||
"banana.png": (240, 210, 40),
|
||||
"wartchen.png": (220, 80, 140),
|
||||
"sunglasses.png": (20, 20, 20),
|
||||
"death_ticket.png": (180, 160, 80),
|
||||
"moon_sword.png": (180, 200, 255),
|
||||
"chicken.png": (240, 220, 180),
|
||||
"pig.png": (240, 160, 180),
|
||||
"lava_chicken.png": (220, 60, 20),
|
||||
"wedding_egg.png": (255, 240, 200),
|
||||
"dressler_apple.png": (180, 20, 20),
|
||||
"burger_cham.png": (180, 100, 40),
|
||||
"snake_mower.png": (40, 180, 60),
|
||||
"mower.png": (100, 100, 110),
|
||||
"cheese_seller.png": (240, 210, 80),
|
||||
"glitschkoenig.png": (255, 0, 255),
|
||||
"button.png": (200, 40, 40),
|
||||
"table.png": (120, 80, 40),
|
||||
"dentist.png": (220, 220, 230),
|
||||
"shorts_stim.png": (80, 200, 120),
|
||||
"hammer.png": (140, 100, 60),
|
||||
"whiff.png": (120, 200, 255),
|
||||
"arrow_left.png": (255, 220, 40),
|
||||
"moon_researcher.png": (100, 100, 140),
|
||||
}.items():
|
||||
write_png(sprites / name, 16, 16, solid(*rgb))
|
||||
|
||||
write_tone_wav(audio / "music_title.wav", 220.0, 1.2, 0.45)
|
||||
write_tone_wav(audio / "sfx_explosion.wav", 60.0, 0.5, 0.5)
|
||||
|
|
@ -489,7 +522,7 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.1, -10)
|
|||
[node name="Reif" parent="." instance=ExtResource("5")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.1, -2)
|
||||
max_hp = 5
|
||||
texture_path = "res://assets/sprites/placeholders/shoe.png"
|
||||
texture_path = "res://assets/sprites/placeholders/reif.png"
|
||||
defeat_flag = "reif_defeated"
|
||||
achievement_on_defeat = "dressler_apples"
|
||||
|
||||
|
|
|
|||
228
scripts/tools/generate_voice_dialogs.py
Normal file
228
scripts/tools/generate_voice_dialogs.py
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Generate high-quality Piper TTS for dialog_* / lang_voice_* from locales.
|
||||
|
||||
Uses large/high Piper models from data/voice_cast.json.
|
||||
Until real voice takes exist, run --watch to auto-rebuild when scripts change.
|
||||
|
||||
Usage:
|
||||
python3 scripts/tools/generate_voice_dialogs.py --force
|
||||
python3 scripts/tools/generate_voice_dialogs.py --watch
|
||||
python3 scripts/tools/generate_voice_dialogs.py --lang de --force
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CAST_PATH = ROOT / "data" / "voice_cast.json"
|
||||
LOCALES = ROOT / "data" / "locales"
|
||||
OUT_ROOT = ROOT / "assets" / "audio" / "voice"
|
||||
MANIFEST = OUT_ROOT / "_manifest.json"
|
||||
PIPER = os.path.expanduser("~/.local/bin/piper")
|
||||
DEFAULT_VOICES = Path(os.path.expanduser("~/.local/share/piper/voices"))
|
||||
|
||||
STAGE_DIR = re.compile(r"\*[^*]+\*")
|
||||
EMOJI_ETC = re.compile(r"[⬜]+")
|
||||
|
||||
|
||||
def clean_tts(text: str) -> str:
|
||||
text = STAGE_DIR.sub(" ", text)
|
||||
text = EMOJI_ETC.sub(" ", text)
|
||||
text = text.replace("…", "...")
|
||||
text = text.replace("—", " - ")
|
||||
text = text.replace("–", " - ")
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
return text
|
||||
|
||||
|
||||
def load_cast() -> dict:
|
||||
return json.loads(CAST_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def voices_dir(cast: dict) -> Path:
|
||||
return Path(os.path.expanduser(cast.get("voices_dir", str(DEFAULT_VOICES))))
|
||||
|
||||
|
||||
def resolve_model(cast: dict, model_key: str) -> Path:
|
||||
models = cast["models"]
|
||||
if model_key not in models:
|
||||
raise KeyError(f"unknown model key {model_key}")
|
||||
path = voices_dir(cast) / models[model_key]
|
||||
return path
|
||||
|
||||
|
||||
def npc_params(cast: dict, npc_id: str, lang: str) -> dict:
|
||||
npc = cast["npcs"][npc_id]
|
||||
# New format: per-lang dict; legacy: flat speaker ints
|
||||
entry = npc.get(lang, npc)
|
||||
if isinstance(entry, dict) and ("model" in entry or "speaker" in entry):
|
||||
params = dict(entry)
|
||||
else:
|
||||
defaults = cast.get("lang_defaults", {})
|
||||
params = {
|
||||
"model": defaults.get(lang, "de_high"),
|
||||
"speaker": int(entry) if not isinstance(entry, dict) else 0,
|
||||
"length_scale": float(npc.get("length_scale", 1.0)),
|
||||
}
|
||||
params.setdefault("speaker", 0)
|
||||
params.setdefault("length_scale", 1.0)
|
||||
params.setdefault("noise_scale", 0.667)
|
||||
params.setdefault("noise_w", 0.8)
|
||||
params.setdefault("sentence_silence", 0.35)
|
||||
return params
|
||||
|
||||
|
||||
def fingerprint(text: str, params: dict, model_path: Path) -> str:
|
||||
payload = {
|
||||
"text": text,
|
||||
"model": model_path.name,
|
||||
"speaker": int(params.get("speaker", 0)),
|
||||
"length_scale": float(params.get("length_scale", 1.0)),
|
||||
"noise_scale": float(params.get("noise_scale", 0.667)),
|
||||
"noise_w": float(params.get("noise_w", 0.8)),
|
||||
"sentence_silence": float(params.get("sentence_silence", 0.35)),
|
||||
}
|
||||
raw = json.dumps(payload, sort_keys=True, ensure_ascii=False)
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def synthesize(model: Path, text: str, out_wav: Path, params: dict) -> None:
|
||||
out_wav.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = out_wav.with_suffix(".tmp.wav")
|
||||
cmd = [
|
||||
PIPER,
|
||||
"-m", str(model),
|
||||
"-s", str(int(params.get("speaker", 0))),
|
||||
"--length_scale", str(float(params.get("length_scale", 1.0))),
|
||||
"--noise_scale", str(float(params.get("noise_scale", 0.667))),
|
||||
"--noise_w", str(float(params.get("noise_w", 0.8))),
|
||||
"--sentence_silence", str(float(params.get("sentence_silence", 0.35))),
|
||||
"-f", str(tmp),
|
||||
]
|
||||
proc = subprocess.run(cmd, input=text.encode("utf-8"), capture_output=True)
|
||||
if proc.returncode != 0 or not tmp.exists() or tmp.stat().st_size < 44:
|
||||
err = proc.stderr.decode("utf-8", errors="replace")
|
||||
if tmp.exists():
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise RuntimeError(f"piper failed for {out_wav.name}: {err[-500:]}")
|
||||
tmp.replace(out_wav)
|
||||
|
||||
|
||||
def load_manifest() -> dict:
|
||||
if MANIFEST.exists():
|
||||
try:
|
||||
return json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def save_manifest(data: dict) -> None:
|
||||
OUT_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
MANIFEST.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def generate(force: bool = False, langs: list[str] | None = None) -> tuple[int, int]:
|
||||
if not Path(PIPER).exists():
|
||||
raise SystemExit(f"piper not found at {PIPER}")
|
||||
|
||||
cast = load_cast()
|
||||
lines = cast["lines"]
|
||||
all_langs = langs or ["de", "en", "by"]
|
||||
locale_data = {}
|
||||
for lang in all_langs:
|
||||
locale_data[lang] = json.loads((LOCALES / f"{lang}.json").read_text(encoding="utf-8"))
|
||||
|
||||
manifest = {} if force else load_manifest()
|
||||
done = 0
|
||||
skipped = 0
|
||||
|
||||
for lang in all_langs:
|
||||
print(f"== {lang}", flush=True)
|
||||
for key, npc_id in lines.items():
|
||||
text_raw = locale_data[lang].get(key)
|
||||
if not text_raw:
|
||||
print(f" skip missing {key}")
|
||||
skipped += 1
|
||||
continue
|
||||
tts = clean_tts(str(text_raw))
|
||||
if not tts:
|
||||
skipped += 1
|
||||
continue
|
||||
params = npc_params(cast, npc_id, lang)
|
||||
model_path = resolve_model(cast, str(params["model"]))
|
||||
if not model_path.exists():
|
||||
raise SystemExit(f"MISSING model {model_path}")
|
||||
fp = fingerprint(tts, params, model_path)
|
||||
out = OUT_ROOT / lang / f"{key}.wav"
|
||||
mkey = f"{lang}/{key}.wav"
|
||||
if (
|
||||
not force
|
||||
and out.exists()
|
||||
and out.stat().st_size > 1000
|
||||
and manifest.get(mkey) == fp
|
||||
):
|
||||
skipped += 1
|
||||
continue
|
||||
print(f" {key} npc={npc_id} model={model_path.name} spk={params.get('speaker')}")
|
||||
synthesize(model_path, tts, out, params)
|
||||
manifest[mkey] = fp
|
||||
done += 1
|
||||
|
||||
save_manifest(manifest)
|
||||
print(f"generated={done} skipped={skipped} out={OUT_ROOT}", flush=True)
|
||||
return done, skipped
|
||||
|
||||
|
||||
def watch_sources_mtime() -> float:
|
||||
mtimes = [CAST_PATH.stat().st_mtime]
|
||||
for p in LOCALES.glob("*.json"):
|
||||
mtimes.append(p.stat().st_mtime)
|
||||
return max(mtimes)
|
||||
|
||||
|
||||
def watch_loop(interval: float = 2.0) -> int:
|
||||
print(f"watching {LOCALES} + {CAST_PATH} (interval={interval}s) — Ctrl+C to stop", flush=True)
|
||||
print("placeholder TTS only; replace WAVs later with real takes (same paths).", flush=True)
|
||||
last = 0.0
|
||||
# initial sync
|
||||
generate(force=False)
|
||||
last = watch_sources_mtime()
|
||||
try:
|
||||
while True:
|
||||
time.sleep(interval)
|
||||
now = watch_sources_mtime()
|
||||
if now > last:
|
||||
print("\n[watch] locale/cast changed — regenerating…")
|
||||
generate(force=False)
|
||||
last = watch_sources_mtime()
|
||||
except KeyboardInterrupt:
|
||||
print("\n[watch] stopped")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--force", action="store_true", help="Rebuild all WAVs")
|
||||
ap.add_argument("--lang", action="append", help="Only these langs (de/en/by)")
|
||||
ap.add_argument("--watch", action="store_true", help="Auto-regenerate when locales/cast change")
|
||||
ap.add_argument("--interval", type=float, default=2.0, help="Watch poll seconds")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.watch:
|
||||
return watch_loop(args.interval)
|
||||
|
||||
generate(force=args.force, langs=args.lang)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
312
scripts/tools/organize_3d_models.py
Normal file
312
scripts/tools/organize_3d_models.py
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Auto-organize GLBs dropped into assets/3DModelle/.
|
||||
|
||||
Drop/rename freely in the folder root or `_inbox/`. After files sit unchanged
|
||||
for `--stable-secs` (default 45), they get classified into props/houses/animals,
|
||||
related textures move along, and `.import` source paths are fixed.
|
||||
|
||||
Usage:
|
||||
python3 scripts/tools/organize_3d_models.py --watch
|
||||
python3 scripts/tools/organize_3d_models.py --force --import
|
||||
python3 scripts/tools/organize_3d_models.py --dry-run
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MODELS = ROOT / "assets" / "3DModelle"
|
||||
INBOX = MODELS / "_inbox"
|
||||
|
||||
ANIMAL_WORDS = (
|
||||
"cat", "chimp", "chimpanzee", "ape", "gorilla", "giraffe", "snail",
|
||||
"spider", "cow", "pig", "bear", "dog", "chicken", "rooster", "snake",
|
||||
"horse", "sheep", "goat", "duck", "fish", "bird", "monkey",
|
||||
)
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def classify(glb_filename: str) -> tuple[Path, str]:
|
||||
"""Return (dest_dir relative to MODELS, target_filename)."""
|
||||
stem = Path(glb_filename).stem
|
||||
low = stem.lower()
|
||||
|
||||
if "massour" in low or "masseur" in low:
|
||||
return Path("houses/massour"), "massourhaus.glb"
|
||||
|
||||
if "broken" in low and ("house" in low or "haus" in low or low == "brokenhouse"):
|
||||
return Path("houses/broken"), "BrokenHouse.glb"
|
||||
|
||||
if "house" in low or "haus" in low:
|
||||
# house_b / house-c / houseB
|
||||
m = re.search(r"house[_\-]?([a-z0-9]{1,4})$", low)
|
||||
if m and m.group(1) not in ("glb", "pbr"):
|
||||
folder = f"house_{m.group(1)}"
|
||||
return Path("houses") / folder, f"{folder}.glb"
|
||||
if re.fullmatch(r"house", low) or low.startswith("house_texture"):
|
||||
return Path("houses/house"), "house.glb"
|
||||
# unknown house-like → own folder from cleaned stem
|
||||
clean = re.sub(r"[^a-z0-9_]+", "_", low).strip("_")
|
||||
return Path("houses") / clean, f"{clean}.glb"
|
||||
|
||||
for key, folder, fname in (
|
||||
("button", "button", "Button.glb"),
|
||||
("knopf", "button", "Button.glb"),
|
||||
("table", "table", "table.glb"),
|
||||
("tisch", "table", "table.glb"),
|
||||
("door", "door", "door.glb"),
|
||||
("stuhl", "chair", "chair.glb"),
|
||||
("chair", "chair", "chair.glb"),
|
||||
):
|
||||
if key in low:
|
||||
return Path("props") / folder, fname
|
||||
|
||||
for w in ANIMAL_WORDS:
|
||||
if w in low:
|
||||
folder = {
|
||||
"chimpanzee": "chimp",
|
||||
"ape": "chimp",
|
||||
"monkey": "chimp",
|
||||
}.get(w, w)
|
||||
# prefer explicit chimp/gorilla in name
|
||||
if "gorilla" in low:
|
||||
folder = "gorilla"
|
||||
elif "chimp" in low or "chimpanzee" in low:
|
||||
folder = "chimp"
|
||||
# cow/ → Masseur only (village skips this folder)
|
||||
return Path("animals") / folder, f"{folder}.glb"
|
||||
|
||||
# vendor-long name → last alpha token
|
||||
clean = stem
|
||||
if len(stem) > 24 and ("-" in stem or "_" in stem):
|
||||
parts = re.split(r"[-_]+", stem)
|
||||
for part in reversed(parts):
|
||||
if part.isalpha() and len(part) >= 3:
|
||||
clean = part.lower()
|
||||
break
|
||||
clean = re.sub(r"[^a-zA-Z0-9_]+", "_", clean).strip("_") or "model"
|
||||
return Path("props") / clean.lower(), f"{clean}.glb"
|
||||
|
||||
|
||||
def related_files(glb: Path) -> list[Path]:
|
||||
parent = glb.parent
|
||||
stem = glb.stem
|
||||
prefixes = {stem, stem.lower()}
|
||||
# base house textures: house_Fence, house_texture…
|
||||
if stem.lower() == "house":
|
||||
prefixes.add("house_")
|
||||
out: list[Path] = []
|
||||
for p in parent.iterdir():
|
||||
if not p.is_file() or p == glb:
|
||||
continue
|
||||
if p.suffix.lower() == ".glb":
|
||||
continue
|
||||
if p.name == glb.name + ".import":
|
||||
out.append(p)
|
||||
continue
|
||||
low = p.name.lower()
|
||||
for pref in prefixes:
|
||||
pl = pref.lower()
|
||||
if low.startswith(pl):
|
||||
# don't steal house_b_* for house.glb
|
||||
if pl == "house_" and low.startswith("house_b"):
|
||||
continue
|
||||
out.append(p)
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def fix_import_paths(folder: Path) -> None:
|
||||
for imp in folder.rglob("*.import"):
|
||||
src = Path(str(imp)[:-7])
|
||||
if not src.exists():
|
||||
continue
|
||||
rel = "res://" + str(src.relative_to(ROOT)).replace("\\", "/")
|
||||
text = imp.read_text(encoding="utf-8")
|
||||
lines: list[str] = []
|
||||
changed = False
|
||||
for line in text.splitlines(True):
|
||||
if line.startswith("source_file="):
|
||||
new = f'source_file="{rel}"\n'
|
||||
changed = changed or line != new
|
||||
lines.append(new)
|
||||
else:
|
||||
lines.append(line)
|
||||
if changed:
|
||||
imp.write_text("".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def move_into(src: Path, dest_dir: Path, dest_name: str | None = None) -> Path:
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = dest_dir / (dest_name or src.name)
|
||||
if dest.exists() and src.resolve() == dest.resolve():
|
||||
return dest
|
||||
if dest.exists():
|
||||
log(f" keep existing {dest.relative_to(MODELS)}")
|
||||
if src.exists() and src.resolve() != dest.resolve():
|
||||
# leave duplicate source — user can delete
|
||||
log(f" (source still at {src.relative_to(MODELS)})")
|
||||
return dest
|
||||
shutil.move(str(src), str(dest))
|
||||
return dest
|
||||
|
||||
|
||||
def organize_glb(glb: Path, dry_run: bool = False) -> Path | None:
|
||||
rel_dir, target_name = classify(glb.name)
|
||||
dest_dir = MODELS / rel_dir
|
||||
log(f"organize {glb.name} → {rel_dir}/{target_name}")
|
||||
if dry_run:
|
||||
return dest_dir / target_name
|
||||
|
||||
extras = related_files(glb)
|
||||
dest_glb = move_into(glb, dest_dir, target_name)
|
||||
# imports / textures that were next to old location
|
||||
for extra in extras:
|
||||
if not extra.exists():
|
||||
continue
|
||||
if extra.name.endswith(".import") and extra.name.startswith(Path(glb.name).stem):
|
||||
# rename import to match new glb name if needed
|
||||
if extra.name == glb.name + ".import" and target_name != glb.name:
|
||||
move_into(extra, dest_dir, target_name + ".import")
|
||||
continue
|
||||
move_into(extra, dest_dir)
|
||||
# stray import at old path
|
||||
old_imp = Path(str(glb) + ".import")
|
||||
if old_imp.exists():
|
||||
move_into(old_imp, dest_dir, target_name + ".import")
|
||||
fix_import_paths(dest_dir)
|
||||
return dest_glb
|
||||
|
||||
|
||||
def root_glbs() -> list[Path]:
|
||||
out: list[Path] = []
|
||||
for base in (MODELS, INBOX):
|
||||
if not base.is_dir():
|
||||
continue
|
||||
for p in base.iterdir():
|
||||
if p.is_file() and p.suffix.lower() == ".glb":
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def bundle_mtime(glb: Path) -> float:
|
||||
mt = glb.stat().st_mtime
|
||||
for p in related_files(glb):
|
||||
try:
|
||||
mt = max(mt, p.stat().st_mtime)
|
||||
except OSError:
|
||||
pass
|
||||
return mt
|
||||
|
||||
|
||||
def organize_orphans(dry_run: bool = False) -> int:
|
||||
"""Loose textures at MODELS root → matching model folder or props/_loose."""
|
||||
n = 0
|
||||
for p in list(MODELS.iterdir()):
|
||||
if not p.is_file():
|
||||
continue
|
||||
if p.suffix.lower() == ".glb" or p.name.endswith(".import") or p.name == "README.md":
|
||||
continue
|
||||
low = p.name.lower()
|
||||
target: Path | None = None
|
||||
for glb in MODELS.rglob("*.glb"):
|
||||
stem = glb.stem.lower()
|
||||
if low.startswith(stem):
|
||||
if stem == "house" and low.startswith("house_b"):
|
||||
continue
|
||||
target = glb.parent
|
||||
break
|
||||
if target is None:
|
||||
target = MODELS / "props" / "_loose"
|
||||
log(f"orphan {p.name} → {target.relative_to(MODELS)}")
|
||||
if not dry_run:
|
||||
move_into(p, target)
|
||||
imp = MODELS / (p.name + ".import")
|
||||
if imp.exists():
|
||||
move_into(imp, target)
|
||||
fix_import_paths(target)
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def organize_stable(stable_secs: float, dry_run: bool = False) -> int:
|
||||
now = time.time()
|
||||
n = 0
|
||||
for glb in root_glbs():
|
||||
age = now - bundle_mtime(glb)
|
||||
if age < stable_secs:
|
||||
log(f"wait {glb.name} ({stable_secs - age:.0f}s left)")
|
||||
continue
|
||||
organize_glb(glb, dry_run=dry_run)
|
||||
n += 1
|
||||
n += organize_orphans(dry_run=dry_run)
|
||||
return n
|
||||
|
||||
|
||||
def run_godot_import() -> None:
|
||||
godot = Path(os.path.expanduser("~/.local/bin/godot"))
|
||||
if not godot.exists():
|
||||
which = shutil.which("godot")
|
||||
godot = Path(which) if which else Path()
|
||||
if not godot.exists():
|
||||
log("godot not found — skip import")
|
||||
return
|
||||
log("godot --import …")
|
||||
subprocess.run(
|
||||
[str(godot), "--headless", "--path", str(ROOT), "--import"],
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def watch_loop(stable_secs: float, interval: float, do_import: bool) -> int:
|
||||
INBOX.mkdir(parents=True, exist_ok=True)
|
||||
log(f"watching {MODELS} + _inbox/ — organize after {stable_secs:.0f}s idle")
|
||||
try:
|
||||
while True:
|
||||
n = organize_stable(stable_secs, dry_run=False)
|
||||
if n > 0 and do_import:
|
||||
run_godot_import()
|
||||
time.sleep(interval)
|
||||
except KeyboardInterrupt:
|
||||
log("stopped")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--watch", action="store_true")
|
||||
ap.add_argument("--stable-secs", type=float, default=45.0)
|
||||
ap.add_argument("--interval", type=float, default=5.0)
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--import", dest="do_import", action="store_true")
|
||||
ap.add_argument("--force", action="store_true", help="Ignore debounce (stable-secs=0)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not MODELS.is_dir():
|
||||
log(f"missing {MODELS}")
|
||||
return 1
|
||||
|
||||
stable = 0.0 if args.force else args.stable_secs
|
||||
if args.watch:
|
||||
# watch always uses configured debounce (force only for one-shot)
|
||||
return watch_loop(args.stable_secs, args.interval, args.do_import)
|
||||
|
||||
n = organize_stable(stable, dry_run=args.dry_run)
|
||||
log(f"organized={n}")
|
||||
if n > 0 and args.do_import and not args.dry_run:
|
||||
run_godot_import()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,9 +1,16 @@
|
|||
extends CanvasLayer
|
||||
## Global UI: pause, inventory, shop, settings, toast, dialog, burger-cham.
|
||||
## Global UI: pause, inventory, shop, settings, toast, VN-dialog, burger-cham.
|
||||
|
||||
@onready var pause_panel: PanelContainer = $PausePanel
|
||||
@onready var dialog_panel: PanelContainer = $DialogPanel
|
||||
@onready var dialog_label: Label = $DialogPanel/Margin/DialogLabel
|
||||
@onready var dialog_layer: Control = $DialogLayer
|
||||
@onready var dialog_dim: ColorRect = $DialogLayer/Dim
|
||||
@onready var dialog_panel: PanelContainer = $DialogLayer/DialogPanel
|
||||
@onready var dialog_label: Label = $DialogLayer/DialogPanel/Margin/VBox/DialogLabel
|
||||
@onready var speaker_label: Label = $DialogLayer/DialogPanel/Margin/VBox/SpeakerLabel
|
||||
@onready var continue_hint: Label = $DialogLayer/DialogPanel/Margin/VBox/Footer/ContinueHint
|
||||
@onready var advance_btn: Button = $DialogLayer/DialogPanel/Margin/VBox/Footer/AdvanceButton
|
||||
@onready var bark_panel: PanelContainer = $BarkPanel
|
||||
@onready var bark_label: Label = $BarkPanel/BarkLabel
|
||||
@onready var toast_label: Label = $ToastLabel
|
||||
@onready var burger_overlay: ColorRect = $BurgerOverlay
|
||||
@onready var burger_label: Label = $BurgerOverlay/BurgerLabel
|
||||
|
|
@ -12,20 +19,50 @@ extends CanvasLayer
|
|||
@onready var sfx_slider: HSlider = $PausePanel/Margin/VBox/SfxSlider
|
||||
@onready var music_slider: HSlider = $PausePanel/Margin/VBox/MusicSlider
|
||||
@onready var lang_option: OptionButton = $PausePanel/Margin/VBox/LangOption
|
||||
@onready var hp_label: Label = $HUD/HpLabel
|
||||
@onready var hp_hearts: HBoxContainer = $HUD/HpHearts
|
||||
@onready var status_label: Label = $HUD/StatusLabel
|
||||
@onready var fat_stim: ColorRect = $FatStim
|
||||
@onready var fat_label: Label = $FatStim/FatLabel
|
||||
@onready var interact_prompt: Label = $HUD/InteractPrompt
|
||||
@onready var attack_flash: ColorRect = $HUD/AttackFlash
|
||||
|
||||
const TYPEWRITER_CPS := 42.0
|
||||
const PANEL_SLIDE := 36.0
|
||||
|
||||
var _dialog_timer: float = 0.0
|
||||
var _fat_blink: float = 0.0
|
||||
var _toast_brief_timer: float = 0.0
|
||||
var _heart_full: Texture2D
|
||||
var _heart_empty: Texture2D
|
||||
|
||||
## VN dialog state
|
||||
var _dialog_open: bool = false
|
||||
var _dialog_blocking: bool = false
|
||||
var _dialog_paused_game: bool = false
|
||||
var _dialog_full_text: String = ""
|
||||
var _dialog_visible_chars: float = 0.0
|
||||
var _dialog_typing: bool = false
|
||||
var _dialog_queue: Array[Dictionary] = []
|
||||
var _dialog_panel_rest_top: float = -210.0
|
||||
var _bark_timer: float = 0.0
|
||||
var _continue_blink: float = 0.0
|
||||
var _voice_cast: Dictionary = {}
|
||||
var _open_tween: Tween
|
||||
|
||||
func _ready() -> void:
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
layer = 100
|
||||
_heart_full = load("res://assets/sprites/placeholders/heart.png")
|
||||
_heart_empty = load("res://assets/sprites/placeholders/heart_empty.png")
|
||||
_load_voice_cast()
|
||||
_style_dialog_panel()
|
||||
pause_panel.visible = false
|
||||
dialog_panel.visible = false
|
||||
dialog_layer.visible = false
|
||||
bark_panel.visible = false
|
||||
burger_overlay.visible = false
|
||||
toast_label.visible = false
|
||||
_dialog_panel_rest_top = dialog_panel.offset_top
|
||||
if advance_btn:
|
||||
advance_btn.pressed.connect(_on_dialog_advance_pressed)
|
||||
_setup_lang_option()
|
||||
_refresh_texts()
|
||||
sfx_slider.value = Settings.sfx_volume
|
||||
|
|
@ -42,14 +79,126 @@ func _ready() -> void:
|
|||
_on_hp(GameState.hp, GameState.max_hp)
|
||||
_refresh_inventory()
|
||||
_refresh_points(Shop.dressler_points)
|
||||
if interact_prompt:
|
||||
interact_prompt.visible = false
|
||||
if attack_flash:
|
||||
attack_flash.visible = false
|
||||
|
||||
func _load_voice_cast() -> void:
|
||||
var f := FileAccess.open("res://data/voice_cast.json", FileAccess.READ)
|
||||
if f == null:
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(f.get_as_text())
|
||||
if typeof(parsed) == TYPE_DICTIONARY:
|
||||
_voice_cast = parsed
|
||||
|
||||
func _style_dialog_panel() -> void:
|
||||
var box := StyleBoxFlat.new()
|
||||
box.bg_color = Color(0.07, 0.08, 0.12, 0.94)
|
||||
box.border_color = Color(0.85, 0.72, 0.4, 0.85)
|
||||
box.set_border_width_all(2)
|
||||
box.set_corner_radius_all(10)
|
||||
box.content_margin_left = 4
|
||||
box.content_margin_right = 4
|
||||
box.content_margin_top = 4
|
||||
box.content_margin_bottom = 4
|
||||
dialog_panel.add_theme_stylebox_override("panel", box)
|
||||
var bark_box := box.duplicate() as StyleBoxFlat
|
||||
bark_box.bg_color = Color(0.1, 0.08, 0.06, 0.88)
|
||||
bark_panel.add_theme_stylebox_override("panel", bark_box)
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _dialog_timer > 0.0:
|
||||
_dialog_timer -= delta
|
||||
if _dialog_timer <= 0.0:
|
||||
dialog_panel.visible = false
|
||||
if _dialog_open and _dialog_typing:
|
||||
_dialog_visible_chars += TYPEWRITER_CPS * delta
|
||||
var shown := mini(int(_dialog_visible_chars), _dialog_full_text.length())
|
||||
dialog_label.text = _dialog_full_text.substr(0, shown)
|
||||
if shown >= _dialog_full_text.length():
|
||||
_dialog_typing = false
|
||||
_dialog_visible_chars = float(_dialog_full_text.length())
|
||||
if _dialog_open and not _dialog_typing:
|
||||
_continue_blink += delta
|
||||
var blink_a: float = 0.45 + 0.4 * abs(sin(_continue_blink * 4.0))
|
||||
continue_hint.modulate.a = blink_a
|
||||
advance_btn.modulate.a = 0.85 + 0.15 * abs(sin(_continue_blink * 4.0))
|
||||
if _bark_timer > 0.0:
|
||||
_bark_timer -= delta
|
||||
if _bark_timer <= 0.0:
|
||||
bark_panel.visible = false
|
||||
if _toast_brief_timer > 0.0:
|
||||
_toast_brief_timer -= delta
|
||||
_update_fat_stim(delta)
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if not _dialog_open or not _dialog_blocking:
|
||||
return
|
||||
# Advance first: Space is both ui_accept and interact — don't let interact steal it.
|
||||
if event.is_action_pressed("dialog_advance") or event.is_action_pressed("ui_accept"):
|
||||
_advance_dialog()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
# Gameplay-only keys must NOT dismiss / advance dialog (E / attack click)
|
||||
if event.is_action_pressed("interact") or event.is_action_pressed("attack"):
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
|
||||
func set_interact_prompt(text: String) -> void:
|
||||
if interact_prompt == null:
|
||||
return
|
||||
if _dialog_open or text == "" or GameState.current_scene_id in ["title_screen", "death_screen", "intro_cutscene", "catscene"]:
|
||||
interact_prompt.visible = false
|
||||
return
|
||||
interact_prompt.text = text
|
||||
interact_prompt.visible = true
|
||||
|
||||
func _wait(sec: float) -> void:
|
||||
## Safe timer: works when paused; never call create_timer on a null tree.
|
||||
var tree := get_tree()
|
||||
if tree == null:
|
||||
tree = Engine.get_main_loop() as SceneTree
|
||||
if tree == null:
|
||||
return
|
||||
await tree.create_timer(sec, true).timeout
|
||||
|
||||
func force_close_dialog() -> void:
|
||||
## Called on scene change so pause/dialog state cannot leak to title.
|
||||
_dialog_queue.clear()
|
||||
_dialog_open = false
|
||||
_dialog_typing = false
|
||||
_dialog_blocking = false
|
||||
if dialog_layer:
|
||||
dialog_layer.visible = false
|
||||
if bark_panel:
|
||||
bark_panel.visible = false
|
||||
_bark_timer = 0.0
|
||||
_dialog_paused_game = false
|
||||
_sync_player_focus(true)
|
||||
|
||||
func pulse_attack() -> void:
|
||||
if attack_flash == null:
|
||||
return
|
||||
attack_flash.color = Color(1.0, 0.85, 0.3, 0.18)
|
||||
attack_flash.visible = true
|
||||
await _wait(0.08)
|
||||
if is_instance_valid(attack_flash):
|
||||
attack_flash.visible = false
|
||||
|
||||
func pulse_interact() -> void:
|
||||
if attack_flash == null:
|
||||
return
|
||||
attack_flash.color = Color(0.5, 1.0, 0.7, 0.14)
|
||||
attack_flash.visible = true
|
||||
await _wait(0.1)
|
||||
if is_instance_valid(attack_flash):
|
||||
attack_flash.visible = false
|
||||
|
||||
func show_toast_brief(text: String, duration: float = 0.5) -> void:
|
||||
toast_label.text = text
|
||||
toast_label.visible = true
|
||||
_toast_brief_timer = duration
|
||||
await _wait(duration)
|
||||
if _toast_brief_timer <= 0.0:
|
||||
toast_label.visible = false
|
||||
|
||||
func _update_fat_stim(delta: float) -> void:
|
||||
if fat_stim == null:
|
||||
return
|
||||
|
|
@ -59,8 +208,23 @@ func _update_fat_stim(delta: float) -> void:
|
|||
_fat_blink += delta
|
||||
fat_label.text = Locale.tr_key("dialog_tiramisu")
|
||||
fat_stim.color = Color(0.05, 0.05, 0.08, 0.35 + 0.25 * abs(sin(_fat_blink * 3.0)))
|
||||
var stim := fat_stim.get_node_or_null("StimImage") as TextureRect
|
||||
if stim == null:
|
||||
stim = TextureRect.new()
|
||||
stim.name = "StimImage"
|
||||
fat_stim.add_child(stim)
|
||||
stim.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
|
||||
stim.offset_left = -80.0
|
||||
stim.offset_top = -200.0
|
||||
stim.offset_right = 80.0
|
||||
stim.offset_bottom = -40.0
|
||||
stim.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
stim.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
stim.texture = load("res://assets/sprites/placeholders/shorts_stim.png")
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if _dialog_open and _dialog_blocking:
|
||||
return
|
||||
if event.is_action_pressed("pause_menu"):
|
||||
toggle_pause()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
|
@ -68,6 +232,8 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||
func toggle_pause() -> void:
|
||||
if GameState.current_scene_id in ["title_screen", "death_screen", "intro_cutscene", "catscene"]:
|
||||
return
|
||||
if _dialog_open and _dialog_blocking:
|
||||
return
|
||||
pause_panel.visible = not pause_panel.visible
|
||||
get_tree().paused = pause_panel.visible
|
||||
_sync_player_focus(not pause_panel.visible)
|
||||
|
|
@ -80,10 +246,134 @@ func _sync_player_focus(focused: bool) -> void:
|
|||
if node.has_method("set_gameplay_focused"):
|
||||
node.set_gameplay_focused(focused)
|
||||
|
||||
func show_dialog(text: String, duration: float = 3.5) -> void:
|
||||
dialog_label.text = text
|
||||
dialog_panel.visible = true
|
||||
_dialog_timer = duration
|
||||
## Blocking VN dialog (pauses world). `duration` ignored for dismiss — user advances with Enter/C.
|
||||
func show_dialog(text: String, _duration: float = 3.5, voice_key: String = "", blocking: bool = true) -> void:
|
||||
if not blocking:
|
||||
_show_bark(text, maxf(_duration, 2.0), voice_key)
|
||||
return
|
||||
_dialog_queue.append({"text": text, "voice_key": voice_key})
|
||||
if not _dialog_open:
|
||||
_present_next_dialog()
|
||||
|
||||
func show_dialog_key(key: String, duration: float = 3.5, blocking: bool = true) -> void:
|
||||
show_dialog(Locale.tr_key(key), duration, key, blocking)
|
||||
|
||||
func is_dialog_open() -> bool:
|
||||
return _dialog_open and _dialog_blocking
|
||||
|
||||
func _show_bark(text: String, duration: float, voice_key: String) -> void:
|
||||
bark_label.text = text
|
||||
bark_panel.visible = true
|
||||
_bark_timer = duration
|
||||
if voice_key != "":
|
||||
AudioDirector.play_dialog_line(voice_key)
|
||||
|
||||
func _present_next_dialog() -> void:
|
||||
if _dialog_queue.is_empty():
|
||||
_close_dialog()
|
||||
return
|
||||
var entry: Dictionary = _dialog_queue.pop_front()
|
||||
var text: String = str(entry.get("text", ""))
|
||||
var voice_key: String = str(entry.get("voice_key", ""))
|
||||
_dialog_full_text = text
|
||||
_dialog_visible_chars = 0.0
|
||||
_dialog_typing = text.length() > 0
|
||||
_dialog_blocking = true
|
||||
_dialog_open = true
|
||||
dialog_label.text = ""
|
||||
speaker_label.text = _speaker_for_key(voice_key)
|
||||
continue_hint.text = Locale.tr_key("dialog_continue")
|
||||
advance_btn.text = Locale.tr_key("dialog_advance_btn")
|
||||
dialog_layer.visible = true
|
||||
if interact_prompt:
|
||||
interact_prompt.visible = false
|
||||
_begin_dialog_pause()
|
||||
# Player _ready may run after this and steal mouse — re-assert after scene settles.
|
||||
call_deferred("_ensure_dialog_input_ready")
|
||||
_play_open_anim()
|
||||
if voice_key != "":
|
||||
AudioDirector.play_dialog_line(voice_key)
|
||||
|
||||
func _speaker_for_key(voice_key: String) -> String:
|
||||
if voice_key == "":
|
||||
return Locale.tr_key("speaker_system")
|
||||
var lines: Variant = _voice_cast.get("lines", {})
|
||||
if typeof(lines) == TYPE_DICTIONARY and lines.has(voice_key):
|
||||
var npc := str(lines[voice_key])
|
||||
var sk := "speaker_%s" % npc
|
||||
var named := Locale.tr_key(sk)
|
||||
if named != sk:
|
||||
return named
|
||||
return npc.capitalize()
|
||||
return Locale.tr_key("speaker_system")
|
||||
|
||||
func _play_open_anim() -> void:
|
||||
if _open_tween and _open_tween.is_running():
|
||||
_open_tween.kill()
|
||||
dialog_dim.color.a = 0.0
|
||||
dialog_panel.modulate.a = 0.0
|
||||
dialog_panel.offset_top = _dialog_panel_rest_top + PANEL_SLIDE
|
||||
dialog_panel.offset_bottom = -28.0 + PANEL_SLIDE
|
||||
_open_tween = create_tween()
|
||||
_open_tween.set_parallel(true)
|
||||
_open_tween.tween_property(dialog_dim, "color:a", 0.55, 0.28).set_ease(Tween.EASE_OUT)
|
||||
_open_tween.tween_property(dialog_panel, "modulate:a", 1.0, 0.32).set_ease(Tween.EASE_OUT)
|
||||
_open_tween.tween_property(dialog_panel, "offset_top", _dialog_panel_rest_top, 0.35).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
|
||||
_open_tween.tween_property(dialog_panel, "offset_bottom", -28.0, 0.35).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
|
||||
|
||||
func _advance_dialog() -> void:
|
||||
if not _dialog_open:
|
||||
return
|
||||
if _dialog_typing:
|
||||
# First confirm: finish typewriter
|
||||
_dialog_typing = false
|
||||
_dialog_visible_chars = float(_dialog_full_text.length())
|
||||
dialog_label.text = _dialog_full_text
|
||||
return
|
||||
# Second confirm: next line or close
|
||||
if not _dialog_queue.is_empty():
|
||||
_present_next_dialog()
|
||||
else:
|
||||
_close_dialog()
|
||||
|
||||
func _on_dialog_advance_pressed() -> void:
|
||||
_advance_dialog()
|
||||
|
||||
func _begin_dialog_pause() -> void:
|
||||
if pause_panel.visible:
|
||||
_dialog_paused_game = false
|
||||
return
|
||||
if not get_tree().paused:
|
||||
get_tree().paused = true
|
||||
_dialog_paused_game = true
|
||||
else:
|
||||
_dialog_paused_game = true
|
||||
_sync_player_focus(false)
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
|
||||
func _ensure_dialog_input_ready() -> void:
|
||||
if not _dialog_open or not _dialog_blocking:
|
||||
return
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
_sync_player_focus(false)
|
||||
if advance_btn:
|
||||
advance_btn.disabled = false
|
||||
advance_btn.grab_focus()
|
||||
|
||||
func _end_dialog_pause() -> void:
|
||||
if _dialog_paused_game and not pause_panel.visible:
|
||||
get_tree().paused = false
|
||||
_sync_player_focus(true)
|
||||
_dialog_paused_game = false
|
||||
|
||||
func _close_dialog() -> void:
|
||||
_dialog_open = false
|
||||
_dialog_blocking = false
|
||||
_dialog_typing = false
|
||||
_dialog_queue.clear()
|
||||
dialog_layer.visible = false
|
||||
AudioDirector.stop_voice()
|
||||
_end_dialog_pause()
|
||||
|
||||
func _setup_lang_option() -> void:
|
||||
lang_option.clear()
|
||||
|
|
@ -103,14 +393,14 @@ func _refresh_texts() -> void:
|
|||
$PausePanel/Margin/VBox/ResumeButton.text = Locale.tr_key("menu_resume")
|
||||
$PausePanel/Margin/VBox/ShopTitle.text = Locale.tr_key("menu_shop")
|
||||
$PausePanel/Margin/VBox/BuyPointButton.text = Locale.tr_key("menu_buy_point")
|
||||
$PausePanel/Margin/VBox/BuySunglassesButton.text = Locale.tr_key("shop_sunglasses")
|
||||
$PausePanel/Margin/VBox/BuyTicketButton.text = Locale.tr_key("shop_ticket")
|
||||
$PausePanel/Margin/VBox/BuyMilkButton.text = Locale.tr_key("shop_continue_joke")
|
||||
$PausePanel/Margin/VBox/BuyTiramisuButton.text = Locale.tr_key("shop_next_level")
|
||||
$PausePanel/Margin/VBox/SfxLabel.text = Locale.tr_key("menu_sfx")
|
||||
$PausePanel/Margin/VBox/MusicLabel.text = Locale.tr_key("menu_music")
|
||||
$PausePanel/Margin/VBox/LangLabel.text = Locale.tr_key("menu_language")
|
||||
$PausePanel/Margin/VBox/InventoryLabel.text = Locale.tr_key("menu_inventory")
|
||||
if continue_hint:
|
||||
continue_hint.text = Locale.tr_key("dialog_continue")
|
||||
if advance_btn:
|
||||
advance_btn.text = Locale.tr_key("dialog_advance_btn")
|
||||
_refresh_points(Shop.dressler_points)
|
||||
|
||||
func _refresh_points(points: int) -> void:
|
||||
|
|
@ -118,34 +408,81 @@ func _refresh_points(points: int) -> void:
|
|||
|
||||
func _refresh_inventory() -> void:
|
||||
inventory_list.clear()
|
||||
inventory_list.fixed_icon_size = Vector2i(int(SpriteFit.UI_ICON_PX), int(SpriteFit.UI_ICON_PX))
|
||||
var items := Inventory.get_all()
|
||||
for id in items.keys():
|
||||
var count: int = int(items[id])
|
||||
var item_name: String = Locale.tr_key("item_" + str(id))
|
||||
var idx := inventory_list.item_count
|
||||
inventory_list.add_item("%s x%d" % [item_name, count])
|
||||
inventory_list.set_item_metadata(inventory_list.item_count - 1, id)
|
||||
inventory_list.set_item_metadata(idx, id)
|
||||
var icon_path := Inventory.get_icon_path(str(id))
|
||||
if icon_path != "":
|
||||
var tex: Texture2D = load(icon_path)
|
||||
if tex:
|
||||
inventory_list.set_item_icon(idx, tex)
|
||||
|
||||
func _on_hp(hp: int, max_hp: int) -> void:
|
||||
_rebuild_hearts(hp, max_hp)
|
||||
var extra := ""
|
||||
if GameState.is_fat():
|
||||
extra += " | " + Locale.tr_key("status_fat", [str(int(GameState.fat_time_left))])
|
||||
extra += Locale.tr_key("status_fat", [str(int(GameState.fat_time_left))])
|
||||
if GameState.is_shocked():
|
||||
extra += " | " + Locale.tr_key("status_shock")
|
||||
if extra != "":
|
||||
extra += " · "
|
||||
extra += Locale.tr_key("status_shock")
|
||||
if GameState.is_glitched_slow():
|
||||
extra += " | " + Locale.tr_key("status_glitch")
|
||||
hp_label.text = "HP %d/%d%s" % [hp, max_hp, extra]
|
||||
if extra != "":
|
||||
extra += " · "
|
||||
extra += Locale.tr_key("status_glitch")
|
||||
if status_label:
|
||||
status_label.text = extra
|
||||
status_label.visible = extra != ""
|
||||
|
||||
func _rebuild_hearts(hp: int, max_hp: int) -> void:
|
||||
if hp_hearts == null:
|
||||
return
|
||||
while hp_hearts.get_child_count() > 0:
|
||||
var c := hp_hearts.get_child(0)
|
||||
hp_hearts.remove_child(c)
|
||||
c.queue_free()
|
||||
var n := maxi(max_hp, 1)
|
||||
for i in range(n):
|
||||
var tex_rect := TextureRect.new()
|
||||
tex_rect.custom_minimum_size = Vector2(28, 28)
|
||||
tex_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
tex_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
tex_rect.texture = _heart_full if i < hp else _heart_empty
|
||||
tex_rect.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||||
hp_hearts.add_child(tex_rect)
|
||||
|
||||
func _on_achievement(_id: String, title: String) -> void:
|
||||
toast_label.text = Locale.tr_key("toast_achievement", [title])
|
||||
toast_label.visible = true
|
||||
await get_tree().create_timer(3.0).timeout
|
||||
toast_label.visible = false
|
||||
await _wait(3.0)
|
||||
if is_instance_valid(toast_label):
|
||||
toast_label.visible = false
|
||||
|
||||
func _on_burger_cham() -> void:
|
||||
burger_label.text = Locale.tr_key("burger_cham")
|
||||
burger_overlay.visible = true
|
||||
await get_tree().create_timer(2.5).timeout
|
||||
burger_overlay.visible = false
|
||||
var img := burger_overlay.get_node_or_null("BurgerImage") as TextureRect
|
||||
if img == null:
|
||||
img = TextureRect.new()
|
||||
img.name = "BurgerImage"
|
||||
burger_overlay.add_child(img)
|
||||
img.set_anchors_preset(Control.PRESET_CENTER)
|
||||
img.offset_left = -120.0
|
||||
img.offset_top = -200.0
|
||||
img.offset_right = 120.0
|
||||
img.offset_bottom = -40.0
|
||||
img.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
img.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
img.texture = load("res://assets/sprites/placeholders/burger_cham.png")
|
||||
SpriteFit.apply_texture_rect_budget(img, 240.0)
|
||||
await _wait(2.5)
|
||||
if is_instance_valid(burger_overlay):
|
||||
burger_overlay.visible = false
|
||||
|
||||
func _on_resume_pressed() -> void:
|
||||
pause_panel.visible = false
|
||||
|
|
@ -155,18 +492,6 @@ func _on_resume_pressed() -> void:
|
|||
func _on_buy_point() -> void:
|
||||
Shop.purchase("buy_point")
|
||||
|
||||
func _on_buy_sunglasses() -> void:
|
||||
Shop.purchase("sunglasses")
|
||||
|
||||
func _on_buy_ticket() -> void:
|
||||
Shop.purchase("death_ticket")
|
||||
|
||||
func _on_buy_milk() -> void:
|
||||
Shop.purchase("continue_joke")
|
||||
|
||||
func _on_buy_tiramisu() -> void:
|
||||
Shop.purchase("next_level")
|
||||
|
||||
func _on_sfx_changed(value: float) -> void:
|
||||
Settings.set_sfx_volume(value)
|
||||
|
||||
|
|
|
|||
265
scripts/util/sprite_fit.gd
Normal file
265
scripts/util/sprite_fit.gd
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
extends Object
|
||||
class_name SpriteFit
|
||||
## Resolution-independent sprite / world sizing.
|
||||
## Canonical adult = player 1.74 m; other world sizes are realistic relative to that.
|
||||
## Runtime GLB sizes: prefer data/model_scales.json (scale lab SSOT), else built-in tables.
|
||||
|
||||
const SCALE_CATALOG_PATH := "res://data/model_scales.json"
|
||||
const MODELS_ROOT := "res://assets/3DModelle/"
|
||||
|
||||
## --- World (meters) ---
|
||||
const WORLD_PLAYER_HEIGHT := 1.74
|
||||
const WORLD_CHARACTER_HEIGHT := 1.74 ## NPCs ≈ adult
|
||||
const WORLD_BOSS_HEIGHT := 2.15 ## slightly taller than player
|
||||
const WORLD_PROP_HEIGHT := 0.85
|
||||
const WORLD_PICKUP_HEIGHT := 0.55
|
||||
const WORLD_HOUSE_HEIGHT := 8.5 ## village houses ~5× player (1.74 m)
|
||||
const WORLD_HOUSE_MAX_WIDTH := 12.0
|
||||
const WORLD_MAX_WIDTH := 2.2
|
||||
const WORLD_TABLE_HEIGHT := 0.75
|
||||
const WORLD_BUTTON_HEIGHT := 0.05
|
||||
const WORLD_COW_HEIGHT := 1.50 ## withers ≈ adult cow
|
||||
const WORLD_DOOR_HEIGHT := 2.05 ## indoor floating door
|
||||
const WORLD_CAMERA_EYE := 1.62 ## ~eye level for 1.74 m
|
||||
|
||||
## Screen (2D Sprite2D / UI), pixels on the longest side
|
||||
const UI_RAIN_PX := 48.0
|
||||
const UI_ICON_PX := 40.0
|
||||
const UI_POPUP_PX := 192.0
|
||||
|
||||
## Village critters (standing height in meters), keyed by animals/<folder>/
|
||||
## Fallback when catalog missing a key.
|
||||
const ANIMAL_HEIGHTS := {
|
||||
"cat": 0.28,
|
||||
"chimp": 0.85, ## adult common chimp ~ standing
|
||||
"dog": 0.62,
|
||||
"giraffe": 3.40,
|
||||
"snail": 0.16,
|
||||
"spider": 0.40,
|
||||
"cow": 1.50,
|
||||
"gorilla": 1.55,
|
||||
}
|
||||
|
||||
## Horizontal cap after height-fit (keeps odd AABBs from exploding sideways/up).
|
||||
const ANIMAL_MAX_WIDTH := {
|
||||
"cat": 0.55,
|
||||
"chimp": 0.80,
|
||||
"dog": 1.10,
|
||||
"giraffe": 2.80,
|
||||
"snail": 0.35,
|
||||
"spider": 0.70,
|
||||
"cow": 2.40,
|
||||
"gorilla": 1.40,
|
||||
}
|
||||
|
||||
static var _catalog_scales: Dictionary = {}
|
||||
static var _catalog_loaded: bool = false
|
||||
|
||||
|
||||
static func reload_scale_catalog() -> void:
|
||||
_catalog_loaded = false
|
||||
_catalog_scales.clear()
|
||||
ensure_scale_catalog()
|
||||
|
||||
|
||||
static func ensure_scale_catalog() -> void:
|
||||
if _catalog_loaded:
|
||||
return
|
||||
_catalog_loaded = true
|
||||
_catalog_scales.clear()
|
||||
if not FileAccess.file_exists(SCALE_CATALOG_PATH):
|
||||
return
|
||||
var file := FileAccess.open(SCALE_CATALOG_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("SpriteFit: cannot read %s" % SCALE_CATALOG_PATH)
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
push_warning("SpriteFit: invalid JSON in %s" % SCALE_CATALOG_PATH)
|
||||
return
|
||||
var scales: Variant = (parsed as Dictionary).get("scales", {})
|
||||
if typeof(scales) != TYPE_DICTIONARY:
|
||||
return
|
||||
for key in (scales as Dictionary).keys():
|
||||
var entry: Variant = scales[key]
|
||||
if typeof(entry) != TYPE_DICTIONARY:
|
||||
continue
|
||||
_catalog_scales[str(key)] = (entry as Dictionary).duplicate(true)
|
||||
|
||||
|
||||
static func default_scale_catalog() -> Dictionary:
|
||||
## Built-in defaults matching ANIMAL_* / WORLD_* (for lab Reset).
|
||||
var scales := {
|
||||
"animals/cat": {"fit_height": 0.28, "fit_max_width": 0.55, "notes": "standing cat"},
|
||||
"animals/chimp": {"fit_height": 0.85, "fit_max_width": 0.80, "notes": "adult common chimp standing"},
|
||||
"animals/cow": {"fit_height": WORLD_COW_HEIGHT, "fit_max_width": 2.4, "notes": "withers ≈ adult cow"},
|
||||
"animals/dog": {"fit_height": 0.62, "fit_max_width": 1.10},
|
||||
"animals/giraffe": {"fit_height": 3.40, "fit_max_width": 2.80},
|
||||
"animals/snail": {"fit_height": 0.16, "fit_max_width": 0.35},
|
||||
"animals/spider": {"fit_height": 0.40, "fit_max_width": 0.70},
|
||||
"houses/broken": {"fit_height": WORLD_HOUSE_HEIGHT, "fit_max_width": WORLD_HOUSE_MAX_WIDTH},
|
||||
"houses/house": {"fit_height": WORLD_HOUSE_HEIGHT, "fit_max_width": WORLD_HOUSE_MAX_WIDTH},
|
||||
"houses/house_b": {"fit_height": WORLD_HOUSE_HEIGHT, "fit_max_width": WORLD_HOUSE_MAX_WIDTH},
|
||||
"houses/house_c": {"fit_height": WORLD_HOUSE_HEIGHT, "fit_max_width": WORLD_HOUSE_MAX_WIDTH},
|
||||
"houses/massour": {"fit_height": WORLD_HOUSE_HEIGHT, "fit_max_width": WORLD_HOUSE_MAX_WIDTH, "notes": "masseur house"},
|
||||
"props/button": {"fit_height": WORLD_BUTTON_HEIGHT, "fit_max_width": 0.10},
|
||||
"props/table": {"fit_height": WORLD_TABLE_HEIGHT, "fit_max_width": 1.40},
|
||||
}
|
||||
return {
|
||||
"_meta": {
|
||||
"player_height_m": WORLD_PLAYER_HEIGHT,
|
||||
"notes": "Edited by scenes/dev/scale_lab.tscn. Keys = category/folder under assets/3DModelle/.",
|
||||
},
|
||||
"scales": scales,
|
||||
}
|
||||
|
||||
|
||||
static func save_scale_catalog(doc: Dictionary) -> Error:
|
||||
var text := JSON.stringify(doc, "\t")
|
||||
var abs_path := ProjectSettings.globalize_path(SCALE_CATALOG_PATH)
|
||||
var file := FileAccess.open(abs_path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
## Fallback: try res:// path (editor often allows it).
|
||||
file = FileAccess.open(SCALE_CATALOG_PATH, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("SpriteFit: cannot write %s (err %s)" % [SCALE_CATALOG_PATH, FileAccess.get_open_error()])
|
||||
return FileAccess.get_open_error()
|
||||
file.store_string(text)
|
||||
file.store_string("\n")
|
||||
_catalog_scales.clear()
|
||||
var scales: Variant = doc.get("scales", {})
|
||||
if typeof(scales) == TYPE_DICTIONARY:
|
||||
for key in (scales as Dictionary).keys():
|
||||
var entry: Variant = scales[key]
|
||||
if typeof(entry) == TYPE_DICTIONARY:
|
||||
_catalog_scales[str(key)] = (entry as Dictionary).duplicate(true)
|
||||
_catalog_loaded = true
|
||||
return OK
|
||||
|
||||
|
||||
static func catalog_key_for_path(model_path: String) -> String:
|
||||
## res://assets/3DModelle/animals/chimp/chimp.glb → animals/chimp
|
||||
var p := model_path.replace("\\", "/")
|
||||
var root := MODELS_ROOT
|
||||
if p.begins_with(root):
|
||||
p = p.substr(root.length())
|
||||
var parts := p.split("/")
|
||||
if parts.size() >= 2:
|
||||
return "%s/%s" % [parts[0], parts[1]]
|
||||
return p.get_basename()
|
||||
|
||||
|
||||
static func scale_entry(model_path: String) -> Dictionary:
|
||||
ensure_scale_catalog()
|
||||
var key := catalog_key_for_path(model_path)
|
||||
if _catalog_scales.has(key):
|
||||
return (_catalog_scales[key] as Dictionary).duplicate(true)
|
||||
return {}
|
||||
|
||||
|
||||
static func fit_height_for(model_path: String, fallback: float = -1.0) -> float:
|
||||
var entry := scale_entry(model_path)
|
||||
if entry.has("fit_height"):
|
||||
return float(entry["fit_height"])
|
||||
var folder := _animal_folder(model_path)
|
||||
if folder != "" and ANIMAL_HEIGHTS.has(folder):
|
||||
return float(ANIMAL_HEIGHTS[folder])
|
||||
if _is_house_path(model_path):
|
||||
return WORLD_HOUSE_HEIGHT
|
||||
if fallback >= 0.0:
|
||||
return fallback
|
||||
return WORLD_PROP_HEIGHT
|
||||
|
||||
|
||||
static func fit_max_width_for(model_path: String, fallback: float = -1.0) -> float:
|
||||
var entry := scale_entry(model_path)
|
||||
if entry.has("fit_max_width"):
|
||||
return float(entry["fit_max_width"])
|
||||
var folder := _animal_folder(model_path)
|
||||
if folder != "" and ANIMAL_MAX_WIDTH.has(folder):
|
||||
return float(ANIMAL_MAX_WIDTH[folder])
|
||||
if _is_house_path(model_path):
|
||||
return WORLD_HOUSE_MAX_WIDTH
|
||||
if fallback >= 0.0:
|
||||
return fallback
|
||||
return WORLD_MAX_WIDTH
|
||||
|
||||
|
||||
static func _is_house_path(model_path: String) -> bool:
|
||||
return model_path.replace("\\", "/").find("/houses/") >= 0
|
||||
|
||||
|
||||
static func _animal_folder(model_path: String) -> String:
|
||||
var p := model_path.to_lower().replace("\\", "/")
|
||||
var marker := "/animals/"
|
||||
var i := p.find(marker)
|
||||
if i < 0:
|
||||
return ""
|
||||
var rest := p.substr(i + marker.length())
|
||||
var slash := rest.find("/")
|
||||
if slash <= 0:
|
||||
return ""
|
||||
return rest.substr(0, slash)
|
||||
|
||||
|
||||
static func animal_height(model_path: String, fallback: float = 0.70) -> float:
|
||||
return fit_height_for(model_path, fallback)
|
||||
|
||||
|
||||
static func animal_max_width(model_path: String, fallback: float = 1.2) -> float:
|
||||
return fit_max_width_for(model_path, fallback)
|
||||
|
||||
|
||||
static func texture_dims(tex: Texture2D) -> Vector2:
|
||||
if tex == null:
|
||||
return Vector2.ONE
|
||||
var w := float(tex.get_width())
|
||||
var h := float(tex.get_height())
|
||||
return Vector2(maxf(w, 1.0), maxf(h, 1.0))
|
||||
|
||||
|
||||
## Uniform scale so the texture's longest side equals target_long_side_px.
|
||||
static func scale_to_long_side(tex: Texture2D, target_long_side_px: float) -> float:
|
||||
var dims := texture_dims(tex)
|
||||
var longest := maxf(dims.x, dims.y)
|
||||
if longest <= 0.0 or target_long_side_px <= 0.0:
|
||||
return 1.0
|
||||
return target_long_side_px / longest
|
||||
|
||||
|
||||
static func apply_sprite2d(sprite: Sprite2D, target_long_side_px: float = UI_RAIN_PX) -> void:
|
||||
if sprite == null:
|
||||
return
|
||||
var s := scale_to_long_side(sprite.texture, target_long_side_px)
|
||||
sprite.scale = Vector2(s, s)
|
||||
|
||||
|
||||
## World-space QuadMesh size (width, height) fitted to target height, capped by max_width.
|
||||
static func world_quad_size(
|
||||
tex: Texture2D,
|
||||
target_height: float = WORLD_CHARACTER_HEIGHT,
|
||||
max_width: float = WORLD_MAX_WIDTH
|
||||
) -> Vector2:
|
||||
if tex == null:
|
||||
return Vector2(target_height * 0.5, target_height)
|
||||
var dims := texture_dims(tex)
|
||||
var aspect := dims.x / dims.y
|
||||
var h := target_height
|
||||
var w := h * aspect
|
||||
if w > max_width and max_width > 0.0:
|
||||
w = max_width
|
||||
h = w / maxf(aspect, 0.0001)
|
||||
return Vector2(w, h)
|
||||
|
||||
|
||||
## Keep a TextureRect inside a square budget (aspect preserved via expand/stretch).
|
||||
static func apply_texture_rect_budget(rect: TextureRect, max_side_px: float = UI_POPUP_PX) -> void:
|
||||
if rect == null:
|
||||
return
|
||||
var half := max_side_px * 0.5
|
||||
rect.offset_left = -half
|
||||
rect.offset_top = -half
|
||||
rect.offset_right = half
|
||||
rect.offset_bottom = half
|
||||
rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
159
scripts/vfx/melee_vfx.gd
Normal file
159
scripts/vfx/melee_vfx.gd
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
extends Node3D
|
||||
class_name MeleeVfx
|
||||
## Visible melee swing (fist/tooth/sword) + range rings + hit particles.
|
||||
|
||||
@export var attack_range: float = 2.75
|
||||
@export var interact_range: float = 2.8
|
||||
|
||||
var _swinging: bool = false
|
||||
var _swing_t: float = 0.0
|
||||
var _swing_dur: float = 0.22
|
||||
var _weapon: Node3D
|
||||
var _weapon_bill: BillboardSprite
|
||||
var _atk_ring: MeshInstance3D
|
||||
var _use_ring: MeshInstance3D
|
||||
var _particles: CPUParticles3D
|
||||
|
||||
func _ready() -> void:
|
||||
_build_weapon()
|
||||
_build_rings()
|
||||
_build_particles()
|
||||
visible = true
|
||||
|
||||
func _build_weapon() -> void:
|
||||
_weapon = Node3D.new()
|
||||
_weapon.name = "WeaponSwing"
|
||||
add_child(_weapon)
|
||||
var packed: PackedScene = load("res://scenes/prefabs/billboard_sprite.tscn")
|
||||
if packed:
|
||||
_weapon_bill = packed.instantiate()
|
||||
_weapon.add_child(_weapon_bill)
|
||||
_weapon_bill.position = Vector3(0.85, 0.9, 0.0)
|
||||
_weapon_bill.set_target_height(0.55)
|
||||
_weapon_bill.max_width = 0.7
|
||||
_weapon.visible = false
|
||||
|
||||
func _build_rings() -> void:
|
||||
_atk_ring = _make_ring(Color(1.0, 0.35, 0.25, 0.35), attack_range)
|
||||
_atk_ring.name = "AttackRangeRing"
|
||||
add_child(_atk_ring)
|
||||
_use_ring = _make_ring(Color(1.0, 0.9, 0.25, 0.28), interact_range)
|
||||
_use_ring.name = "InteractRangeRing"
|
||||
add_child(_use_ring)
|
||||
_atk_ring.visible = false
|
||||
_use_ring.visible = false
|
||||
|
||||
func _make_ring(color: Color, radius: float) -> MeshInstance3D:
|
||||
var mi := MeshInstance3D.new()
|
||||
var torus := TorusMesh.new()
|
||||
torus.inner_radius = maxf(0.05, radius - 0.06)
|
||||
torus.outer_radius = radius
|
||||
torus.rings = 12
|
||||
torus.ring_segments = 24
|
||||
mi.mesh = torus
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.albedo_color = color
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
mi.material_override = mat
|
||||
mi.position = Vector3(0, 0.05, 0)
|
||||
mi.rotation_degrees = Vector3(0, 0, 0)
|
||||
return mi
|
||||
|
||||
func _build_particles() -> void:
|
||||
_particles = CPUParticles3D.new()
|
||||
_particles.name = "HitParticles"
|
||||
_particles.emitting = false
|
||||
_particles.one_shot = true
|
||||
_particles.explosiveness = 0.95
|
||||
_particles.amount = 18
|
||||
_particles.lifetime = 0.35
|
||||
_particles.direction = Vector3(0, 1, 0)
|
||||
_particles.spread = 180.0
|
||||
_particles.initial_velocity_min = 2.5
|
||||
_particles.initial_velocity_max = 6.0
|
||||
_particles.gravity = Vector3(0, -8, 0)
|
||||
_particles.scale_amount_min = 0.08
|
||||
_particles.scale_amount_max = 0.18
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.albedo_color = Color(1.0, 0.85, 0.3)
|
||||
mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
_particles.mesh = SphereMesh.new()
|
||||
(_particles.mesh as SphereMesh).radius = 0.08
|
||||
(_particles.mesh as SphereMesh).height = 0.16
|
||||
_particles.material_override = mat
|
||||
add_child(_particles)
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _swinging:
|
||||
_swing_t += delta
|
||||
var u := clampf(_swing_t / _swing_dur, 0.0, 1.0)
|
||||
# Arc from -70° to +70° around player up-axis
|
||||
var ang := deg_to_rad(lerp(-70.0, 70.0, u))
|
||||
_weapon.rotation = Vector3(0, ang, 0)
|
||||
_weapon.position = Vector3(0, 0.15 + 0.1 * sin(u * PI), 0)
|
||||
if u >= 1.0:
|
||||
_swinging = false
|
||||
_weapon.visible = false
|
||||
|
||||
func set_ranges(attack_r: float, interact_r: float) -> void:
|
||||
attack_range = attack_r
|
||||
interact_range = interact_r
|
||||
_resize_ring(_atk_ring, attack_r)
|
||||
_resize_ring(_use_ring, interact_r)
|
||||
|
||||
func _resize_ring(mi: MeshInstance3D, radius: float) -> void:
|
||||
if mi == null:
|
||||
return
|
||||
var torus := TorusMesh.new()
|
||||
torus.inner_radius = maxf(0.05, radius - 0.07)
|
||||
torus.outer_radius = radius
|
||||
torus.rings = 12
|
||||
torus.ring_segments = 24
|
||||
mi.mesh = torus
|
||||
|
||||
func show_attack_range(on: bool) -> void:
|
||||
if _atk_ring:
|
||||
_atk_ring.visible = on
|
||||
|
||||
func show_interact_range(on: bool) -> void:
|
||||
if _use_ring:
|
||||
_use_ring.visible = on
|
||||
|
||||
func play_swing() -> void:
|
||||
_refresh_weapon_texture()
|
||||
_swinging = true
|
||||
_swing_t = 0.0
|
||||
_weapon.visible = true
|
||||
_weapon.rotation = Vector3(0, deg_to_rad(-70), 0)
|
||||
|
||||
func burst_at(world_pos: Vector3, hit: bool) -> void:
|
||||
if _particles == null:
|
||||
return
|
||||
_particles.global_position = world_pos + Vector3(0, 0.9, 0)
|
||||
var mat := _particles.material_override as StandardMaterial3D
|
||||
if mat:
|
||||
mat.albedo_color = Color(1.0, 0.35, 0.25) if hit else Color(0.7, 0.7, 0.75)
|
||||
_particles.restart()
|
||||
_particles.emitting = true
|
||||
|
||||
func _refresh_weapon_texture() -> void:
|
||||
if _weapon_bill == null:
|
||||
return
|
||||
var path := "res://assets/sprites/placeholders/fist.png"
|
||||
var h := 0.55
|
||||
if Inventory.has_item("moon_sword"):
|
||||
path = "res://assets/sprites/placeholders/moon_sword.png"
|
||||
h = 0.9
|
||||
elif Inventory.has_item("wartchen"):
|
||||
path = "res://assets/sprites/placeholders/wartchen.png"
|
||||
h = 0.65
|
||||
elif Inventory.has_item("tooth"):
|
||||
path = "res://assets/sprites/placeholders/tooth.png"
|
||||
h = 0.6
|
||||
_weapon_bill.set_target_height(h)
|
||||
var tex: Texture2D = load(path)
|
||||
if tex:
|
||||
_weapon_bill.set_texture(tex)
|
||||
Loading…
Add table
Add a link
Reference in a new issue