eNEMIES!!!!!!!!!

Enemys and enemy spawner

- Press N to spawn wave
- Press H to togglt hitboxs
This commit is contained in:
Twirpytherobot
2025-11-29 20:39:30 +00:00
parent b606563f4d
commit 689181ac30
10 changed files with 699 additions and 79 deletions
+285
View File
@@ -0,0 +1,285 @@
extends BaseEnemy
class_name BasicEnemy
## A basic melee enemy that chases and attacks players
## Server-authoritative AI with multiplayer support
## Movement
@export var move_speed: float = 3.0
@export var chase_range: float = 15.0
@export var attack_range: float = 2.5
## Combat
@export var attack_damage: float = 15.0
@export var attack_knockback: float = 10.0
@export var attack_cooldown: float = 1.5
@export_category("Attack Timing")
@export var attack_startup: float = 0.3 # Wind-up before hit
@export var attack_active: float = 0.4 # Hit window duration
## References
var _hitbox: HitBox = null
var _hurtbox: HurtBox = null
var _mesh: MeshInstance3D = null
## AI State
var _attack_timer: float = 0.0
var _is_attacking: bool = false
var _original_material: Material = null
var _hit_flash_timer: float = 0.0
const HIT_FLASH_DURATION: float = 0.2
func _enter_tree():
# Enemies are always server-authoritative
set_multiplayer_authority(1)
func _ready():
super._ready()
# Find mesh, hitbox, and hurtbox
_mesh = get_node_or_null("Mesh")
_hitbox = get_node_or_null("HitBox")
_hurtbox = get_node_or_null("HurtBox")
# Store original material for hit flash
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)
# Setup hitbox
if _hitbox:
_hitbox.owner_entity = self
_hitbox.set_stats(attack_damage, attack_knockback)
_hitbox.hit_landed.connect(_on_hitbox_hit)
# Setup hurtbox
if _hurtbox:
_hurtbox.owner_entity = self
func _process(delta):
# Countdown timers
if _attack_timer > 0:
_attack_timer -= delta
# Handle hit flash
if _hit_flash_timer > 0:
_hit_flash_timer -= delta
if _hit_flash_timer <= 0:
_reset_material()
func _physics_process(delta):
super._physics_process(delta)
# Only server runs AI
if not multiplayer.is_server():
return
if is_dead:
return
# Apply gravity
if not is_on_floor():
velocity.y -= ProjectSettings.get_setting("physics/3d/default_gravity") * delta
# AI behavior
if current_target and is_instance_valid(current_target):
_ai_combat(delta)
else:
# Stop moving if no target
velocity.x = 0
velocity.z = 0
move_and_slide()
## Override to find nearest player without range limit
func get_nearest_player() -> Node:
# Find all players in a very large range (essentially unlimited)
var players = get_players_in_range(1000.0) # 1000m range - basically unlimited
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
## Update target - called by BaseEnemy
func _update_target():
if not is_aggressive:
return
# Always find and target the nearest player (no range limit)
var nearest = get_nearest_player()
if nearest:
# Update target if it changed
if current_target != nearest:
current_target = nearest
target_changed.emit(nearest)
else:
# No players exist
if current_target != null:
current_target = null
target_changed.emit(null)
## Combat AI behavior
func _ai_combat(delta):
if not current_target or not is_instance_valid(current_target):
return
var target_pos = current_target.global_position
var direction = (target_pos - global_position).normalized()
var distance = global_position.distance_to(target_pos)
# Face the target
if direction.length() > 0.01:
var look_dir = Vector3(direction.x, 0, direction.z)
if look_dir.length() > 0.01:
look_at(global_position + look_dir, Vector3.UP)
# If in attack range, attack
if distance <= attack_range:
# Stop moving when attacking
velocity.x = 0
velocity.z = 0
# Try to attack
if _attack_timer <= 0 and not _is_attacking:
_perform_attack()
else:
# Always chase the target (no range limit)
velocity.x = direction.x * move_speed
velocity.z = direction.z * move_speed
## Perform melee attack
func _perform_attack():
if _is_attacking or not multiplayer.is_server():
return
# Calculate total attack duration
var total_duration = attack_startup + attack_active
var cooldown = max(attack_cooldown, total_duration)
_attack_timer = cooldown
_is_attacking = true
# Play attack animation on all clients
rpc("_sync_attack_animation")
# Activate hitbox
_activate_hitbox()
## Activate hitbox for attack
func _activate_hitbox():
if not _hitbox or not multiplayer.is_server():
_is_attacking = false
return
# STARTUP PHASE - Wait before activating
if attack_startup > 0:
await get_tree().create_timer(attack_startup).timeout
if not _hitbox or not is_instance_valid(_hitbox) or is_dead:
_is_attacking = false
return
# ACTIVE PHASE - Hitbox on
_hitbox.activate()
await get_tree().create_timer(attack_active).timeout
# RECOVERY PHASE - Hitbox off
if _hitbox and is_instance_valid(_hitbox):
_hitbox.deactivate()
_is_attacking = false
## Called when hitbox hits something
func _on_hitbox_hit(target: Node, damage_amount: float, knockback_amount: float, attacker_pos: Vector3):
if not target or not multiplayer.is_server():
return
# Flash target's hurtbox
if target is Node:
var hurtbox = target.find_child("HurtBox", true, false)
if hurtbox and hurtbox.has_method("flash_hit"):
hurtbox.flash_hit()
# Server applies damage directly
if target is BaseUnit:
target.take_damage(damage_amount, 1, knockback_amount, global_position)
## Play attack animation (synced to all clients)
@rpc("any_peer", "call_local", "reliable")
func _sync_attack_animation():
# Visual feedback for attack
# Could play animation here if you have an AnimationPlayer
pass
## Override hurt animation to flash red
@rpc("any_peer", "call_local", "reliable")
func _play_hurt_animation():
if _mesh:
_flash_red()
## Flash 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)
# Copy properties from original if 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 material
func _reset_material():
if _mesh and _original_material:
_mesh.set_surface_override_material(0, _original_material.duplicate())
## Death callback
func _on_enemy_died(killer_id: int):
super._on_enemy_died(killer_id)
# Hide when dead
if _mesh:
_mesh.visible = false
# Deactivate hitbox
if _hitbox:
_hitbox.deactivate()
print("[BasicEnemy ", name, "] killed by ", killer_id)
## Respawn callback
func _on_enemy_respawned():
super._on_enemy_respawned()
# Show mesh
if _mesh:
_mesh.visible = true
_reset_material()
# Reset state
_attack_timer = 0.0
_is_attacking = false
print("[BasicEnemy ", name, "] respawned")
+1
View File
@@ -0,0 +1 @@
uid://cd87rsuiqhdav
+251
View File
@@ -0,0 +1,251 @@
extends Node3D
class_name EnemySpawner
## Spawns enemies in waves around the arena edge
## Server-authoritative spawning with multiplayer sync
signal wave_started(wave_number: int)
signal wave_completed(wave_number: int)
signal enemy_spawned(enemy: Node)
signal all_enemies_defeated()
## Spawn configuration
@export_category("Spawn Settings")
@export var spawn_radius: float = 20.0 ## Distance from spawner center to spawn enemies
@export var spawn_height: float = 0.5 ## Height above ground to spawn
@export var enemies_per_wave: int = 3 ## How many enemies spawn per wave
@export var auto_start_next_wave: bool = false ## Auto-start next wave when all defeated
@export var wave_delay: float = 5.0 ## Delay before next wave auto-starts
@export_category("Enemy Pool")
@export var enemy_scenes: Array[PackedScene] = [] ## List of enemy scenes to spawn from
## Wave tracking
var current_wave: int = 0
var active_enemies: Array[Node] = []
var _wave_delay_timer: float = 0.0
var _waiting_for_next_wave: bool = false
var _enemy_id_counter: int = 0
## Debug visualization
@export var show_spawn_radius: bool = false ## Show spawn radius circle in game
var _debug_circle: MeshInstance3D = null
func _ready():
print("[EnemySpawner] Ready. Server: ", multiplayer.is_server())
_create_debug_circle()
## Start a new wave
func start_wave():
if not multiplayer.is_server():
return
if not is_inside_tree():
push_error("[EnemySpawner] Cannot start wave - spawner not in scene tree!")
return
if enemy_scenes.is_empty():
push_error("[EnemySpawner] No enemy scenes configured!")
return
current_wave += 1
print("[EnemySpawner] Starting wave ", current_wave)
wave_started.emit(current_wave)
# Spawn enemies
for i in range(enemies_per_wave):
_spawn_enemy(i, enemies_per_wave)
## Spawn a single enemy at a position around the circle
func _spawn_enemy(index: int, total: int):
if not multiplayer.is_server():
return
if not is_inside_tree():
push_error("[EnemySpawner] Cannot spawn enemy - spawner not in scene tree!")
return
if enemy_scenes.is_empty():
return
# Pick a random enemy scene from the pool
var enemy_scene = enemy_scenes[randi() % enemy_scenes.size()]
# Calculate spawn position in a circle
var angle = (TAU / total) * index # Evenly distribute around circle
var offset = Vector3(
cos(angle) * spawn_radius,
spawn_height,
sin(angle) * spawn_radius
)
var spawn_pos = global_position + offset
# Generate unique name for multiplayer sync
_enemy_id_counter += 1
var enemy_name = "Enemy_" + str(current_wave) + "_" + str(_enemy_id_counter)
# Spawn on all clients via RPC (call_local will spawn on server too)
var enemy_scene_path = enemy_scene.resource_path
rpc("_spawn_enemy_on_client", enemy_name, spawn_pos, enemy_scene_path)
## RPC to spawn enemy on all clients (including server via call_local)
@rpc("any_peer", "call_local", "reliable")
func _spawn_enemy_on_client(enemy_name: String, spawn_pos: Vector3, scene_path: String):
# Load the enemy scene
var enemy_scene = load(scene_path)
if not enemy_scene:
push_error("[EnemySpawner] Failed to load enemy scene: ", scene_path)
return
# Instantiate enemy
var enemy = enemy_scene.instantiate()
enemy.name = enemy_name
enemy.position = spawn_pos
# Find enemies container
var level = get_tree().get_current_scene()
var enemies_container = null
if level and level.has_node("EnemiesContainer"):
enemies_container = level.get_node("EnemiesContainer")
else:
push_error("[EnemySpawner] EnemiesContainer not found!")
return
# Add to scene
enemies_container.add_child(enemy, true)
# Only server tracks active enemies and connects signals
if multiplayer.is_server():
active_enemies.append(enemy)
# Connect death signal if enemy is BaseEnemy
if enemy is BaseEnemy:
enemy.died.connect(_on_enemy_died.bind(enemy))
enemy_spawned.emit(enemy)
print("[EnemySpawner] Spawned ", enemy.name, " at ", enemy.global_position, " on peer ", multiplayer.get_unique_id())
## Called when an enemy dies
func _on_enemy_died(killer_id: int, enemy: Node):
if not multiplayer.is_server():
return
print("[EnemySpawner] Enemy ", enemy.name, " defeated by ", killer_id)
# Will be cleaned up in _update_active_enemies
## Update list of active enemies and check if wave complete
func _update_active_enemies():
if not multiplayer.is_server():
return
# Remove dead/invalid enemies from active list
var alive_count = 0
for enemy in active_enemies:
if is_instance_valid(enemy) and enemy is BaseEnemy and not enemy.is_dead:
alive_count += 1
# Check if wave is complete
if active_enemies.size() > 0 and alive_count == 0:
_on_wave_completed()
## Called when all enemies in wave are defeated
func _on_wave_completed():
if not multiplayer.is_server():
return
print("[EnemySpawner] Wave ", current_wave, " completed!")
wave_completed.emit(current_wave)
# Clean up dead enemies after a delay
await get_tree().create_timer(2.0).timeout
_cleanup_dead_enemies()
all_enemies_defeated.emit()
# Auto-start next wave if enabled
if auto_start_next_wave:
_waiting_for_next_wave = true
_wave_delay_timer = wave_delay
print("[EnemySpawner] Next wave starts in ", wave_delay, " seconds")
## Remove dead enemies from the scene
func _cleanup_dead_enemies():
var cleaned = 0
for enemy in active_enemies:
if is_instance_valid(enemy) and enemy is BaseEnemy and enemy.is_dead:
enemy.queue_free()
cleaned += 1
active_enemies.clear()
print("[EnemySpawner] Cleaned up ", cleaned, " defeated enemies")
## Manual wave control
func start_next_wave():
if not multiplayer.is_server():
return
start_wave()
func stop_waves():
_waiting_for_next_wave = false
## Get current wave info
func get_alive_enemy_count() -> int:
var count = 0
for enemy in active_enemies:
if is_instance_valid(enemy) and enemy is BaseEnemy and not enemy.is_dead:
count += 1
return count
func is_wave_active() -> bool:
return get_alive_enemy_count() > 0
## Create debug visualization circle
func _create_debug_circle():
# Create a circle mesh to show spawn radius
_debug_circle = MeshInstance3D.new()
_debug_circle.name = "DebugSpawnCircle"
# Create circle mesh using ImmediateMesh
var immediate_mesh = ImmediateMesh.new()
var material = StandardMaterial3D.new()
material.albedo_color = Color(1, 0.5, 0, 0.6) # Orange
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.cull_mode = BaseMaterial3D.CULL_DISABLED
# Draw circle
immediate_mesh.surface_begin(Mesh.PRIMITIVE_LINE_STRIP)
var segments = 64
for i in range(segments + 1):
var angle = (TAU / segments) * i
var x = cos(angle) * spawn_radius
var z = sin(angle) * spawn_radius
immediate_mesh.surface_add_vertex(Vector3(x, spawn_height, z))
immediate_mesh.surface_end()
_debug_circle.mesh = immediate_mesh
_debug_circle.material_override = material
_debug_circle.visible = show_spawn_radius
add_child(_debug_circle)
## Update debug visualization (useful when changing spawn_radius in editor)
func _process(_delta):
# Update circle visibility
if _debug_circle:
_debug_circle.visible = show_spawn_radius
# Server spawning logic
if not multiplayer.is_server():
return
# Handle wave delay timer
if _waiting_for_next_wave:
_wave_delay_timer -= _delta
if _wave_delay_timer <= 0:
_waiting_for_next_wave = false
start_wave()
# Check if all enemies defeated
_update_active_enemies()
+1
View File
@@ -0,0 +1 @@
uid://dim3dvik1fd27
+8
View File
@@ -14,6 +14,9 @@ signal hit_landed(target: Node, damage: float, knockback: float, attacker_pos: V
## Owner entity (used to prevent self-damage and identify attacker)
@export var owner_entity: Node = null
## Global debug visibility toggle (static-like via class name access)
static var debug_visible: bool = true
## Whether hitbox is currently active (only deals damage when active)
var is_active: bool = false
## Tracks entities hit this attack (prevents multi-hit)
@@ -61,10 +64,15 @@ func _create_debug_visualization(collision_shape: CollisionShape3D):
if mesh:
_debug_mesh.mesh = mesh
_debug_mesh.material_override = _debug_material
_debug_mesh.visible = HitBox.debug_visible
# Don't set transform - it inherits from parent CollisionShape3D
collision_shape.add_child(_debug_mesh)
func _physics_process(_delta):
# Update debug visibility
if _debug_mesh:
_debug_mesh.visible = HitBox.debug_visible
if not is_active:
return
+8
View File
@@ -5,6 +5,9 @@ class_name HurtBox
## Attach to any entity that can be damaged (players, enemies, destructibles)
## NOTE: This is a passive detection zone - HitBox handles the collision detection
## Global debug visibility toggle (static-like via class name access)
static var debug_visible: bool = true
## The entity that owns this hurtbox (should be a BaseUnit or similar)
@export var owner_entity: Node = null
## Debug mesh for visualization
@@ -35,6 +38,10 @@ func _ready():
_create_debug_visualization()
func _process(delta):
# Update debug visibility
if _debug_mesh:
_debug_mesh.visible = HurtBox.debug_visible
# Handle hit flash timer
if _hit_flash_timer > 0.0:
_hit_flash_timer -= delta
@@ -75,6 +82,7 @@ func _create_debug_visualization():
if mesh:
_debug_mesh.mesh = mesh
_debug_mesh.material_override = _debug_material
_debug_mesh.visible = HurtBox.debug_visible
# Don't set transform - it inherits from parent CollisionShape3D
child.add_child(_debug_mesh)
break
+30 -2
View File
@@ -6,6 +6,8 @@ extends Node3D
@onready var players_container: Node3D = $PlayersContainer
@onready var weapons_container: Node3D = $WeaponsContainer
@onready var enemies_container: Node3D = $EnemiesContainer
@onready var enemy_spawner: EnemySpawner = $EnemySpawner
@onready var player_spawn_points: Node3D = $PlayerSpawnPoints
@onready var menu: Control = $Menu
@onready var main_menu: VBoxContainer = $Menu/MainContainer/MainMenu
@export var player_scene: PackedScene
@@ -17,6 +19,8 @@ var _weapon_spawn_counter: int = 0
var _active_weapons: Dictionary = {} # weapon_id -> WorldWeapon reference
# Track if we've already initialized to prevent double-spawning
var _multiplayer_initialized: bool = false
# Track next spawn point for round-robin spawning (server-side only)
var _next_spawn_index: int = 0
# multiplayer chat
@onready var message: LineEdit = $MultiplayerChat/VBoxContainer/HBoxContainer/Message
@@ -342,8 +346,22 @@ func _spawn_player_local(id: int, spawn_pos: Vector3):
# rpc("sync_player_position", id, player.position)
func get_spawn_point() -> Vector3:
var spawn_point = Vector2.from_angle(randf() * 2 * PI) * 10 # spawn radius
return Vector3(spawn_point.x, 0, spawn_point.y)
# Use PlayerSpawnPoint container if available
if player_spawn_points and player_spawn_points.get_child_count() > 0:
var spawn_children = player_spawn_points.get_children()
# Use round-robin to distribute players across spawn points
var spawn_index = _next_spawn_index % spawn_children.size()
_next_spawn_index = (spawn_index + 1) % spawn_children.size()
var spawn_node = spawn_children[spawn_index]
print("[Level] Using spawn point ", spawn_index, " at ", spawn_node.global_position)
return spawn_node.global_position
else:
# Fallback to random circle spawn if no spawn points defined
print("[Level] Warning: No PlayerSpawnPoint container found, using fallback random spawn")
var spawn_point = Vector2.from_angle(randf() * 2 * PI) * 10 # spawn radius
return Vector3(spawn_point.x, 0, spawn_point.y)
func _remove_player(id):
if not multiplayer.is_server() or not players_container.has_node(str(id)):
@@ -388,6 +406,16 @@ func _input(event):
toggle_chat()
elif event is InputEventKey and event.keycode == KEY_ENTER:
_on_send_pressed()
elif event is InputEventKey and event.keycode == KEY_N and event.pressed:
# Start next wave (server only, press N)
if multiplayer.is_server() and enemy_spawner:
enemy_spawner.start_next_wave()
print("[Level] Starting wave via N key")
elif event is InputEventKey and event.keycode == KEY_H and event.pressed:
# Toggle hitbox/hurtbox debug visualization
HitBox.debug_visible = not HitBox.debug_visible
HurtBox.debug_visible = not HurtBox.debug_visible
print("[Level] Hitbox/Hurtbox debug visibility: ", HitBox.debug_visible)
func _on_send_pressed() -> void:
var trimmed_message = message.text.strip_edges()