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

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

154 lines
4.9 KiB
GDScript

@tool
extends Node3D
class_name HittableEntity
## 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":
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")
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()
call_deferred("queue_free")
func on_interact(_player: Node) -> void:
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)