parent
ddc1522174
commit
fab47fb1e0
11 changed files with 468 additions and 38 deletions
@ -0,0 +1,51 @@ |
||||
[gd_scene load_steps=7 format=3 uid="uid://dif4t1y3c07ax"] |
||||
|
||||
[ext_resource type="Script" path="res://level/scripts/practice_dummy.gd" id="1_dummy"] |
||||
[ext_resource type="Script" uid="uid://bj3uepduxvgju" path="res://level/scripts/hurt_box.gd" id="2_hurtbox"] |
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_dummy"] |
||||
albedo_color = Color(0.8, 0.6, 0.4, 1) |
||||
metallic = 0.2 |
||||
roughness = 0.8 |
||||
|
||||
[sub_resource type="CapsuleMesh" id="CapsuleMesh_dummy"] |
||||
material = SubResource("StandardMaterial3D_dummy") |
||||
|
||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"] |
||||
|
||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_hurtbox"] |
||||
radius = 0.6 |
||||
height = 2.2 |
||||
|
||||
[node name="PracticeDummy" type="CharacterBody3D"] |
||||
transform = Transform3D(1.5, 0, 0, 0, 1.5, 0, 0, 0, 1.5, 0, 0, 0) |
||||
collision_mask = 2 |
||||
script = ExtResource("1_dummy") |
||||
detection_range = 0.0 |
||||
is_aggressive = false |
||||
respawn_delay = 5.0 |
||||
|
||||
[node name="Mesh" type="MeshInstance3D" parent="."] |
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) |
||||
mesh = SubResource("CapsuleMesh_dummy") |
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="."] |
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) |
||||
shape = SubResource("CapsuleShape3D_body") |
||||
|
||||
[node name="HurtBox" type="Area3D" parent="." node_paths=PackedStringArray("owner_entity")] |
||||
collision_layer = 16 |
||||
collision_mask = 0 |
||||
script = ExtResource("2_hurtbox") |
||||
owner_entity = NodePath("..") |
||||
|
||||
[node name="HurtBoxShape" type="CollisionShape3D" parent="HurtBox"] |
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) |
||||
shape = SubResource("CapsuleShape3D_hurtbox") |
||||
|
||||
[node name="HealthLabel" type="Label3D" parent="."] |
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.5, 0) |
||||
billboard = 1 |
||||
text = "HP: 100/100" |
||||
font_size = 24 |
||||
outline_size = 8 |
||||
@ -0,0 +1,108 @@ |
||||
extends BaseUnit |
||||
class_name BaseEnemy |
||||
|
||||
## Base class for all enemies in the game |
||||
## Provides common enemy functionality like AI, pathfinding, and targeting |
||||
|
||||
signal target_changed(new_target: Node) |
||||
|
||||
## Current target (usually a player) |
||||
var current_target: Node = null |
||||
## Enemy detection/aggro range |
||||
@export var detection_range: float = 10.0 |
||||
## Whether this enemy is aggressive (will attack players) |
||||
@export var is_aggressive: bool = true |
||||
|
||||
func _ready(): |
||||
super._ready() |
||||
|
||||
# Enemies should respawn by default |
||||
can_respawn = true |
||||
|
||||
# Connect to health signals for AI reactions |
||||
health_changed.connect(_on_enemy_health_changed) |
||||
died.connect(_on_enemy_died) |
||||
respawned.connect(_on_enemy_respawned) |
||||
|
||||
func _physics_process(delta): |
||||
# Only server handles enemy AI |
||||
if not multiplayer.is_server(): |
||||
return |
||||
|
||||
if is_dead: |
||||
return |
||||
|
||||
# Update target if needed |
||||
_update_target() |
||||
|
||||
## Find and update current target |
||||
func _update_target(): |
||||
# Subclasses can override this to implement custom targeting logic |
||||
pass |
||||
|
||||
## Get all players in range |
||||
func get_players_in_range(range_dist: float) -> Array[Node]: |
||||
var players_in_range: Array[Node] = [] |
||||
|
||||
# Find the players container |
||||
var level = get_tree().get_current_scene() |
||||
if not level or not level.has_node("PlayersContainer"): |
||||
return players_in_range |
||||
|
||||
var players_container = level.get_node("PlayersContainer") |
||||
|
||||
for player in players_container.get_children(): |
||||
if player is Character and not player.is_dead: |
||||
var distance = global_position.distance_to(player.global_position) |
||||
if distance <= range_dist: |
||||
players_in_range.append(player) |
||||
|
||||
return players_in_range |
||||
|
||||
## Get nearest player |
||||
func get_nearest_player() -> Node: |
||||
var players = get_players_in_range(detection_range) |
||||
|
||||
if players.is_empty(): |
||||
return null |
||||
|
||||
var nearest_player = null |
||||
var nearest_distance = INF |
||||
|
||||
for player in players: |
||||
var distance = global_position.distance_to(player.global_position) |
||||
if distance < nearest_distance: |
||||
nearest_distance = distance |
||||
nearest_player = player |
||||
|
||||
return nearest_player |
||||
|
||||
## Health changed callback |
||||
func _on_enemy_health_changed(old_health: float, new_health: float): |
||||
# Subclasses can override to react to damage |
||||
pass |
||||
|
||||
## Death callback |
||||
func _on_enemy_died(killer_id: int): |
||||
print("[Enemy ", name, "] died. Killer ID: ", killer_id) |
||||
|
||||
# Subclasses can override for death effects |
||||
pass |
||||
|
||||
## Respawn callback |
||||
func _on_enemy_respawned(): |
||||
print("[Enemy ", name, "] respawned at ", global_position) |
||||
|
||||
# Clear target on respawn |
||||
current_target = null |
||||
target_changed.emit(null) |
||||
|
||||
# Subclasses can override for respawn effects |
||||
pass |
||||
|
||||
## Override hurt animation |
||||
@rpc("any_peer", "call_local", "reliable") |
||||
func _play_hurt_animation(): |
||||
# Flash red or play hurt animation |
||||
# Subclasses should implement this with their specific animations |
||||
pass |
||||
@ -0,0 +1 @@ |
||||
uid://base_enemy_script |
||||
@ -0,0 +1,112 @@ |
||||
extends BaseEnemy |
||||
class_name PracticeDummy |
||||
|
||||
## A stationary practice dummy for testing combat |
||||
## Cannot move or attack - just takes damage and shows health |
||||
|
||||
## Visual mesh reference |
||||
@onready var _mesh: MeshInstance3D = null |
||||
## Health label above dummy |
||||
@onready var _health_label: Label3D = null |
||||
## Original material for hit flash effect |
||||
var _original_material: Material = null |
||||
## Hit flash effect |
||||
var _hit_flash_timer: float = 0.0 |
||||
const HIT_FLASH_DURATION: float = 0.2 |
||||
|
||||
func _ready(): |
||||
super._ready() |
||||
|
||||
# Practice dummy should not be aggressive |
||||
is_aggressive = false |
||||
|
||||
# Auto-find mesh and health label |
||||
_mesh = get_node_or_null("Mesh") |
||||
_health_label = get_node_or_null("HealthLabel") |
||||
|
||||
# Store original material for flash effect |
||||
if _mesh: |
||||
_original_material = _mesh.get_surface_override_material(0) |
||||
if not _original_material and _mesh.mesh: |
||||
_original_material = _mesh.mesh.surface_get_material(0) |
||||
|
||||
# Update initial health display |
||||
_update_health_display() |
||||
|
||||
# Connect health change to update display |
||||
health_changed.connect(_on_health_display_changed) |
||||
|
||||
func _process(delta): |
||||
# Handle hit flash timer |
||||
if _hit_flash_timer > 0: |
||||
_hit_flash_timer -= delta |
||||
if _hit_flash_timer <= 0: |
||||
_reset_material() |
||||
|
||||
func _physics_process(delta): |
||||
# Don't call super._physics_process since we don't move |
||||
# Just apply gravity |
||||
if not is_on_floor(): |
||||
velocity.y -= ProjectSettings.get_setting("physics/3d/default_gravity") * delta |
||||
move_and_slide() |
||||
|
||||
## Update health display |
||||
func _update_health_display(): |
||||
if _health_label: |
||||
_health_label.text = "HP: %d/%d" % [int(current_health), int(max_health)] |
||||
|
||||
func _on_health_display_changed(_old_health: float, _new_health: float): |
||||
_update_health_display() |
||||
|
||||
## Override hurt animation to flash red |
||||
@rpc("any_peer", "call_local", "reliable") |
||||
func _play_hurt_animation(): |
||||
if _mesh: |
||||
_flash_red() |
||||
|
||||
## Flash the dummy red when hit |
||||
func _flash_red(): |
||||
if not _mesh: |
||||
return |
||||
|
||||
_hit_flash_timer = HIT_FLASH_DURATION |
||||
|
||||
# Create red material |
||||
var red_material = StandardMaterial3D.new() |
||||
red_material.albedo_color = Color(1.5, 0.3, 0.3) # Bright red |
||||
|
||||
# Copy properties from original if it exists |
||||
if _original_material and _original_material is StandardMaterial3D: |
||||
var orig = _original_material as StandardMaterial3D |
||||
red_material.metallic = orig.metallic |
||||
red_material.roughness = orig.roughness |
||||
red_material.albedo_texture = orig.albedo_texture |
||||
|
||||
_mesh.set_surface_override_material(0, red_material) |
||||
|
||||
## Reset to original material |
||||
func _reset_material(): |
||||
if _mesh and _original_material: |
||||
_mesh.set_surface_override_material(0, _original_material.duplicate()) |
||||
|
||||
## Override death to just hide the mesh |
||||
func _on_enemy_died(killer_id: int): |
||||
super._on_enemy_died(killer_id) |
||||
|
||||
# Hide mesh when dead |
||||
if _mesh: |
||||
_mesh.visible = false |
||||
|
||||
print("[PracticeDummy] Killed by player ", killer_id, ". Respawning in ", respawn_delay, " seconds...") |
||||
|
||||
## Override respawn to show mesh again |
||||
func _on_enemy_respawned(): |
||||
super._on_enemy_respawned() |
||||
|
||||
# Show mesh when respawned |
||||
if _mesh: |
||||
_mesh.visible = true |
||||
_reset_material() |
||||
|
||||
_update_health_display() |
||||
print("[PracticeDummy] Respawned!") |
||||
@ -0,0 +1 @@ |
||||
uid://practice_dummy_script |
||||
Loading…
Reference in new issue