Made more enemies, easier to kill, more slashy. You get passive health regen, health orbs. Little key bindings tooltip.
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bbekeah8iio6t
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user