Made more enemies, easier to kill, more slashy. You get passive health regen, health orbs. Little key bindings tooltip.

This commit is contained in:
2026-06-30 22:57:11 +01:00
parent 7fa2efabaf
commit 97ebbb1618
17 changed files with 404 additions and 6 deletions
+29
View File
@@ -11,15 +11,29 @@ signal respawned()
@export var max_health: float = 100.0
@export var can_respawn: bool = true
@export var respawn_delay: float = 3.0
@export var health_regen: float = 0.0 ## HP restored per second by passive regen (server-applied). 0 = disabled.
@export var regen_delay: float = 4.0 ## Seconds to wait after taking damage before regen resumes.
const REGEN_TICK := 0.5 # Seconds between regen applications
var current_health: float = 100.0
var is_dead: bool = false
var _respawn_point: Vector3 = Vector3.ZERO
var _last_damage_time: float = -1000.0
func _ready():
current_health = max_health
_respawn_point = global_position
# Server drives a passive regen tick for any unit with health_regen > 0
if multiplayer.is_server() and health_regen > 0.0:
var regen_timer := Timer.new()
regen_timer.name = "RegenTimer"
regen_timer.wait_time = REGEN_TICK
regen_timer.autostart = true
add_child(regen_timer)
regen_timer.timeout.connect(_on_regen_tick)
func _enter_tree():
set_multiplayer_authority(str(name).to_int())
@@ -34,6 +48,9 @@ func take_damage(amount: float, attacker_id: int = -1, knockback: float = 0.0, a
if is_dead:
return
# Record when we were last hit so passive regen can pause briefly
_last_damage_time = Time.get_ticks_msec() / 1000.0
# Apply blocking reduction if applicable (duck typing - check if method exists)
var final_damage = amount
var final_knockback = knockback
@@ -68,6 +85,18 @@ func take_damage(amount: float, attacker_id: int = -1, knockback: float = 0.0, a
if current_health <= 0:
_die(attacker_id)
## Server-side passive regeneration tick (driven by RegenTimer)
func _on_regen_tick():
if not multiplayer.is_server():
return
if is_dead or health_regen <= 0.0 or current_health >= max_health:
return
# Hold off regen for a short window after taking damage
var now := Time.get_ticks_msec() / 1000.0
if now - _last_damage_time < regen_delay:
return
heal(health_regen * REGEN_TICK)
## Heal the unit
@rpc("any_peer", "reliable")
func heal(amount: float):
+32
View File
@@ -13,6 +13,7 @@ class_name BasicEnemy
@export var attack_damage: float = 15.0
@export var attack_knockback: float = 10.0
@export var attack_cooldown: float = 1.5
@export var health_orb_drop_chance: float = 0.4 ## Chance (0-1) to drop a health orb on death
@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
@@ -36,6 +37,9 @@ func _enter_tree():
func _ready():
super._ready()
# Wave lobsters die for good — no reviving (BaseEnemy defaults this to true)
can_respawn = false
# Find mesh, hitbox, and hurtbox
_mesh = get_node_or_null("Mesh")
_hitbox = get_node_or_null("HitBox")
@@ -255,6 +259,34 @@ func _reset_material():
if _mesh and _original_material:
_mesh.set_surface_override_material(0, _original_material.duplicate())
## Handle death: drop loot, then despawn (server-authoritative, fires once per death)
func _die(killer_id: int):
var already_dead = is_dead
super._die(killer_id)
if not multiplayer.is_server() or already_dead:
return
# Chance to drop a health orb
if randf() < health_orb_drop_chance:
var level = get_tree().get_current_scene()
if level and level.has_method("spawn_health_orb"):
level.spawn_health_orb(global_position + Vector3.UP)
# Non-reviving enemies remove their corpse on every client after a short beat
if not can_respawn:
_schedule_despawn()
## Free this enemy on all peers a couple seconds after death
func _schedule_despawn():
await get_tree().create_timer(2.0).timeout
if is_instance_valid(self):
rpc("_despawn")
@rpc("any_peer", "call_local", "reliable")
func _despawn():
queue_free()
## Death callback
func _on_enemy_died(killer_id: int):
super._on_enemy_died(killer_id)
+26
View File
@@ -17,6 +17,11 @@ signal all_enemies_defeated()
@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("Timed Waves")
@export var auto_waves: bool = true ## Spawn a fresh wave on a repeating timer
@export var wave_interval: float = 15.0 ## Seconds between timed waves
@export var first_wave_delay: float = 3.0 ## Delay before the very first timed wave
@export_category("Enemy Pool")
@export var enemy_scenes: Array[PackedScene] = [] ## List of enemy scenes to spawn from
@@ -26,6 +31,8 @@ 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
var _auto_waves_active: bool = false
var _auto_wave_timer: float = 0.0
## Debug visualization
@export var show_spawn_radius: bool = false ## Show spawn radius circle in game
@@ -35,6 +42,18 @@ func _ready():
print("[EnemySpawner] Ready. Server: ", multiplayer.is_server())
_create_debug_circle()
## Begin spawning waves automatically on a timer (server only)
func start_auto_waves():
if not multiplayer.is_server() or not auto_waves:
return
_auto_waves_active = true
_auto_wave_timer = first_wave_delay
print("[EnemySpawner] Auto waves enabled - first wave in ", first_wave_delay, "s, then every ", wave_interval, "s")
## Stop the timed-wave loop
func stop_auto_waves():
_auto_waves_active = false
## Start a new wave
func start_wave():
if not multiplayer.is_server():
@@ -240,6 +259,13 @@ func _process(_delta):
if multiplayer.multiplayer_peer == null or not multiplayer.is_server():
return
# Timed waves - spawn a fresh wave on a repeating interval
if _auto_waves_active:
_auto_wave_timer -= _delta
if _auto_wave_timer <= 0:
_auto_wave_timer = wave_interval
start_wave()
# Handle wave delay timer
if _waiting_for_next_wave:
_wave_delay_timer -= _delta
+51
View File
@@ -0,0 +1,51 @@
extends Area3D
class_name HealthOrb
## Floating pickup that restores health when a player walks into it.
## Server-authoritative pickup; the bob/spin visuals run locally on every peer.
@export var heal_amount: float = 25.0
@export var bob_height: float = 0.3
@export var bob_speed: float = 2.0
@export var spin_speed: float = 2.0
var orb_id: int = -1 # Assigned by Level when spawned
var _base_y: float = 0.0
var _time: float = 0.0
var _collected: bool = false
@onready var _mesh: Node3D = get_node_or_null("Mesh")
func _ready():
_base_y = position.y
_time = randf() * TAU # Random phase so multiple orbs don't bob in unison
# Detect players (physics layer 1) without colliding with anything
collision_layer = 0
collision_mask = 1
# Only the server resolves pickups
if multiplayer.is_server():
body_entered.connect(_on_body_entered)
func _process(delta):
_time += delta
position.y = _base_y + sin(_time * bob_speed) * bob_height
if _mesh:
_mesh.rotate_y(spin_speed * delta)
func _on_body_entered(body: Node3D):
if _collected or not multiplayer.is_server():
return
if not (body is Character) or body.is_dead:
return
# Leave the orb in place if the player is already at full health
if body.current_health >= body.max_health:
return
_collected = true
body.heal(heal_amount)
# Remove the orb from every client via the level's centralized system
var level = get_tree().get_current_scene()
if level and level.has_method("remove_health_orb"):
level.remove_health_orb(orb_id)
+1
View File
@@ -0,0 +1 @@
uid://bbekeah8iio6t
+92
View File
@@ -6,6 +6,7 @@ extends Node3D
@onready var players_container: Node3D = $PlayersContainer
@onready var weapons_container: Node3D = $WeaponsContainer
@onready var enemies_container: Node3D = $EnemiesContainer
var orbs_container: Node3D = null
@onready var enemy_spawner: EnemySpawner = $EnemySpawner
@onready var player_spawn_points: Node3D = $PlayerSpawnPoints
@onready var menu: Control = $Menu
@@ -59,6 +60,15 @@ func _ready():
add_child(enemies_container)
print("Created EnemiesContainer")
# Create or find health orbs container
if has_node("OrbsContainer"):
orbs_container = get_node("OrbsContainer")
else:
orbs_container = Node3D.new()
orbs_container.name = "OrbsContainer"
add_child(orbs_container)
print("Created OrbsContainer")
# Don't initialize weapons in _ready() - wait for Host/Join to be pressed
# This is handled in initialize_multiplayer() which is called after the
# multiplayer peer is properly set up
@@ -96,6 +106,13 @@ func initialize_multiplayer():
# Spawn armed enemies
_spawn_armed_enemies()
# Scatter a few health orbs so the pickup is visible from the start
_spawn_initial_health_orbs()
# Begin automatic timed enemy waves
if enemy_spawner:
enemy_spawner.start_auto_waves()
# Spawn the host player (peer ID 1)
print("[Level] Spawning host player")
var host_info = Network.players.get(1, {"nick": "Host", "skin": "blue"})
@@ -265,6 +282,11 @@ func _on_player_connected(peer_id, player_info):
print("[Server] Syncing enemy ", enemy.name, " at position ", enemy.global_position, " to peer ", peer_id)
rpc_id(peer_id, "_spawn_dummy_local", enemy_id, enemy.global_position)
# Sync existing health orbs to the newly joined player
print("[Server] Syncing health orbs to newly connected peer: ", peer_id)
for orb_id in _active_orbs.keys():
rpc_id(peer_id, "_spawn_health_orb_local", orb_id, _active_orbs[orb_id])
# Sync equipped weapons for all existing players to the newly joined player
print("[Server] Syncing equipped weapons to newly connected peer: ", peer_id)
for player_node in players_container.get_children():
@@ -647,6 +669,76 @@ func _client_spawn_weapon(weapon_data_path: String, spawn_position: Vector3, ini
print("[Client ", multiplayer.get_unique_id(), "] Calling spawn_world_weapon locally")
spawn_world_weapon(weapon_data_path, spawn_position, initial_velocity, weapon_id)
# ---------- HEALTH ORBS ----------
var _orb_spawn_counter: int = 0
var _active_orbs: Dictionary = {} # orb_id -> spawn position (Vector3), for late-join sync
## Spawn a health orb at a position (server only, replicates to all clients)
func spawn_health_orb(spawn_pos: Vector3):
if not multiplayer.is_server():
return
_orb_spawn_counter += 1
rpc("_spawn_health_orb_local", _orb_spawn_counter, spawn_pos)
## Spawn a health orb on all clients (call_local spawns on server too)
@rpc("any_peer", "call_local", "reliable")
func _spawn_health_orb_local(orb_id: int, spawn_pos: Vector3):
if not orbs_container:
push_error("[Level] OrbsContainer not found!")
return
var orb_name = "HealthOrb_" + str(orb_id)
if orbs_container.has_node(orb_name):
return # Already spawned (e.g. duplicate sync)
var orb_scene = load("res://level/scenes/health_orb.tscn")
if not orb_scene:
push_error("[Level] Failed to load health_orb.tscn")
return
var orb = orb_scene.instantiate()
orb.orb_id = orb_id
orb.name = orb_name
orb.position = spawn_pos
orbs_container.add_child(orb, true)
# Server tracks active orbs for late-join sync
if multiplayer.is_server():
_active_orbs[orb_id] = spawn_pos
print("[Peer ", multiplayer.get_unique_id(), "] Spawned health orb ", orb_name, " at ", spawn_pos)
## Remove a health orb from all clients (called by HealthOrb when collected)
func remove_health_orb(orb_id: int):
if not multiplayer.is_server():
return
if _active_orbs.has(orb_id):
_active_orbs.erase(orb_id)
rpc("_remove_orb_on_clients", orb_id)
## RPC to remove an orb on all clients
@rpc("any_peer", "call_local", "reliable")
func _remove_orb_on_clients(orb_id: int):
var orb_name = "HealthOrb_" + str(orb_id)
if orbs_container and orbs_container.has_node(orb_name):
orbs_container.get_node(orb_name).queue_free()
## Scatter a few health orbs at server start
func _spawn_initial_health_orbs():
if not multiplayer.is_server():
return
# Wait a frame for everything to be ready
await get_tree().process_frame
var orb_positions = [
Vector3(6, 1, 6),
Vector3(-6, 1, -6),
Vector3(-6, 1, 6),
]
for pos in orb_positions:
spawn_health_orb(pos)
# ---------- ARMED ENEMY SPAWNING ----------
var _armed_enemy_counter: int = 0
+5
View File
@@ -69,6 +69,11 @@ func _enter_tree():
$SpringArmOffset/SpringArm3D/Camera3D.current = is_multiplayer_authority()
func _ready():
# Players passively regenerate health (set before super._ready so the regen timer is created)
if health_regen <= 0.0:
health_regen = 3.0
regen_delay = 5.0
super._ready()
# Set respawn point to current position (where we spawned) - base_unit._ready already does this
# Don't override with a hardcoded position