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>
154 lines
4.9 KiB
GDScript
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)
|