Made more enemies, easier to kill, more slashy. You get passive health regen, health orbs. Little key bindings tooltip.
This commit is contained in:
@@ -133,7 +133,7 @@ _spring_arm = NodePath("SpringArm3D")
|
|||||||
|
|
||||||
[node name="SpringArm3D" type="SpringArm3D" parent="SpringArmOffset"]
|
[node name="SpringArm3D" type="SpringArm3D" parent="SpringArmOffset"]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
|
||||||
spring_length = 5.0
|
spring_length = 10.0
|
||||||
|
|
||||||
[node name="Camera3D" type="Camera3D" parent="SpringArmOffset/SpringArm3D"]
|
[node name="Camera3D" type="Camera3D" parent="SpringArmOffset/SpringArm3D"]
|
||||||
current = true
|
current = true
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ script = ExtResource("1_basic_enemy")
|
|||||||
move_speed = 3.5
|
move_speed = 3.5
|
||||||
attack_damage = 5.0
|
attack_damage = 5.0
|
||||||
detection_range = 500.0
|
detection_range = 500.0
|
||||||
max_health = 20.0
|
max_health = 10.0
|
||||||
|
|
||||||
[node name="Mesh" type="MeshInstance3D" parent="."]
|
[node name="Mesh" type="MeshInstance3D" parent="."]
|
||||||
transform = Transform3D(-0.29981995, -0.01038597, -0.00033419454, 0.00636219, -0.19110087, 0.23117083, -0.008215994, 0.23102501, 0.19120643, 0, 0.41641736, 0)
|
transform = Transform3D(-0.29981995, -0.01038597, -0.00033419454, 0.00636219, -0.19110087, 0.23117083, -0.008215994, 0.23102501, 0.19120643, 0, 0.41641736, 0)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ shading_mode = 0
|
|||||||
script = ExtResource("1_spawner")
|
script = ExtResource("1_spawner")
|
||||||
spawn_radius = 20.0
|
spawn_radius = 20.0
|
||||||
spawn_height = 0.5
|
spawn_height = 0.5
|
||||||
enemies_per_wave = 3
|
enemies_per_wave = 10
|
||||||
auto_start_next_wave = false
|
auto_start_next_wave = false
|
||||||
wave_delay = 5.0
|
wave_delay = 5.0
|
||||||
enemy_scenes = Array[PackedScene]([ExtResource("2_basic_enemy")])
|
enemy_scenes = Array[PackedScene]([ExtResource("2_basic_enemy")])
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[gd_scene load_steps=5 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://level/scripts/health_orb.gd" id="1_orb"]
|
||||||
|
|
||||||
|
[sub_resource type="SphereMesh" id="SphereMesh_orb"]
|
||||||
|
radius = 0.35
|
||||||
|
height = 0.7
|
||||||
|
|
||||||
|
[sub_resource type="StandardMaterial3D" id="Mat_orb"]
|
||||||
|
albedo_color = Color(0.2, 1, 0.35, 1)
|
||||||
|
emission_enabled = true
|
||||||
|
emission = Color(0.2, 1, 0.35, 1)
|
||||||
|
emission_energy_multiplier = 2.0
|
||||||
|
|
||||||
|
[sub_resource type="SphereShape3D" id="Shape_orb"]
|
||||||
|
radius = 0.8
|
||||||
|
|
||||||
|
[node name="HealthOrb" type="Area3D"]
|
||||||
|
script = ExtResource("1_orb")
|
||||||
|
|
||||||
|
[node name="Mesh" type="MeshInstance3D" parent="."]
|
||||||
|
mesh = SubResource("SphereMesh_orb")
|
||||||
|
surface_material_override/0 = SubResource("Mat_orb")
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||||
|
shape = SubResource("Shape_orb")
|
||||||
|
|
||||||
|
[node name="Glow" type="OmniLight3D" parent="."]
|
||||||
|
light_color = Color(0.4, 1, 0.5, 1)
|
||||||
|
light_energy = 1.5
|
||||||
|
omni_range = 3.0
|
||||||
@@ -5055,7 +5055,7 @@ _spring_arm = NodePath("SpringArm3D")
|
|||||||
|
|
||||||
[node name="SpringArm3D" type="SpringArm3D" parent="SpringArmOffset"]
|
[node name="SpringArm3D" type="SpringArm3D" parent="SpringArmOffset"]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
|
||||||
spring_length = 5.0
|
spring_length = 10.0
|
||||||
|
|
||||||
[node name="Camera3D" type="Camera3D" parent="SpringArmOffset/SpringArm3D"]
|
[node name="Camera3D" type="Camera3D" parent="SpringArmOffset/SpringArm3D"]
|
||||||
current = true
|
current = true
|
||||||
|
|||||||
@@ -11,15 +11,29 @@ signal respawned()
|
|||||||
@export var max_health: float = 100.0
|
@export var max_health: float = 100.0
|
||||||
@export var can_respawn: bool = true
|
@export var can_respawn: bool = true
|
||||||
@export var respawn_delay: float = 3.0
|
@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 current_health: float = 100.0
|
||||||
var is_dead: bool = false
|
var is_dead: bool = false
|
||||||
var _respawn_point: Vector3 = Vector3.ZERO
|
var _respawn_point: Vector3 = Vector3.ZERO
|
||||||
|
var _last_damage_time: float = -1000.0
|
||||||
|
|
||||||
func _ready():
|
func _ready():
|
||||||
current_health = max_health
|
current_health = max_health
|
||||||
_respawn_point = global_position
|
_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():
|
func _enter_tree():
|
||||||
set_multiplayer_authority(str(name).to_int())
|
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:
|
if is_dead:
|
||||||
return
|
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)
|
# Apply blocking reduction if applicable (duck typing - check if method exists)
|
||||||
var final_damage = amount
|
var final_damage = amount
|
||||||
var final_knockback = knockback
|
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:
|
if current_health <= 0:
|
||||||
_die(attacker_id)
|
_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
|
## Heal the unit
|
||||||
@rpc("any_peer", "reliable")
|
@rpc("any_peer", "reliable")
|
||||||
func heal(amount: float):
|
func heal(amount: float):
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class_name BasicEnemy
|
|||||||
@export var attack_damage: float = 15.0
|
@export var attack_damage: float = 15.0
|
||||||
@export var attack_knockback: float = 10.0
|
@export var attack_knockback: float = 10.0
|
||||||
@export var attack_cooldown: float = 1.5
|
@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_category("Attack Timing")
|
||||||
@export var attack_startup: float = 0.3 # Wind-up before hit
|
@export var attack_startup: float = 0.3 # Wind-up before hit
|
||||||
@export var attack_active: float = 0.4 # Hit window duration
|
@export var attack_active: float = 0.4 # Hit window duration
|
||||||
@@ -36,6 +37,9 @@ func _enter_tree():
|
|||||||
func _ready():
|
func _ready():
|
||||||
super._ready()
|
super._ready()
|
||||||
|
|
||||||
|
# Wave lobsters die for good — no reviving (BaseEnemy defaults this to true)
|
||||||
|
can_respawn = false
|
||||||
|
|
||||||
# Find mesh, hitbox, and hurtbox
|
# Find mesh, hitbox, and hurtbox
|
||||||
_mesh = get_node_or_null("Mesh")
|
_mesh = get_node_or_null("Mesh")
|
||||||
_hitbox = get_node_or_null("HitBox")
|
_hitbox = get_node_or_null("HitBox")
|
||||||
@@ -255,6 +259,34 @@ func _reset_material():
|
|||||||
if _mesh and _original_material:
|
if _mesh and _original_material:
|
||||||
_mesh.set_surface_override_material(0, _original_material.duplicate())
|
_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
|
## Death callback
|
||||||
func _on_enemy_died(killer_id: int):
|
func _on_enemy_died(killer_id: int):
|
||||||
super._on_enemy_died(killer_id)
|
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 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 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_category("Enemy Pool")
|
||||||
@export var enemy_scenes: Array[PackedScene] = [] ## List of enemy scenes to spawn from
|
@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 _wave_delay_timer: float = 0.0
|
||||||
var _waiting_for_next_wave: bool = false
|
var _waiting_for_next_wave: bool = false
|
||||||
var _enemy_id_counter: int = 0
|
var _enemy_id_counter: int = 0
|
||||||
|
var _auto_waves_active: bool = false
|
||||||
|
var _auto_wave_timer: float = 0.0
|
||||||
|
|
||||||
## Debug visualization
|
## Debug visualization
|
||||||
@export var show_spawn_radius: bool = false ## Show spawn radius circle in game
|
@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())
|
print("[EnemySpawner] Ready. Server: ", multiplayer.is_server())
|
||||||
_create_debug_circle()
|
_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
|
## Start a new wave
|
||||||
func start_wave():
|
func start_wave():
|
||||||
if not multiplayer.is_server():
|
if not multiplayer.is_server():
|
||||||
@@ -240,6 +259,13 @@ func _process(_delta):
|
|||||||
if multiplayer.multiplayer_peer == null or not multiplayer.is_server():
|
if multiplayer.multiplayer_peer == null or not multiplayer.is_server():
|
||||||
return
|
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
|
# Handle wave delay timer
|
||||||
if _waiting_for_next_wave:
|
if _waiting_for_next_wave:
|
||||||
_wave_delay_timer -= _delta
|
_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 players_container: Node3D = $PlayersContainer
|
||||||
@onready var weapons_container: Node3D = $WeaponsContainer
|
@onready var weapons_container: Node3D = $WeaponsContainer
|
||||||
@onready var enemies_container: Node3D = $EnemiesContainer
|
@onready var enemies_container: Node3D = $EnemiesContainer
|
||||||
|
var orbs_container: Node3D = null
|
||||||
@onready var enemy_spawner: EnemySpawner = $EnemySpawner
|
@onready var enemy_spawner: EnemySpawner = $EnemySpawner
|
||||||
@onready var player_spawn_points: Node3D = $PlayerSpawnPoints
|
@onready var player_spawn_points: Node3D = $PlayerSpawnPoints
|
||||||
@onready var menu: Control = $Menu
|
@onready var menu: Control = $Menu
|
||||||
@@ -59,6 +60,15 @@ func _ready():
|
|||||||
add_child(enemies_container)
|
add_child(enemies_container)
|
||||||
print("Created EnemiesContainer")
|
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
|
# Don't initialize weapons in _ready() - wait for Host/Join to be pressed
|
||||||
# This is handled in initialize_multiplayer() which is called after the
|
# This is handled in initialize_multiplayer() which is called after the
|
||||||
# multiplayer peer is properly set up
|
# multiplayer peer is properly set up
|
||||||
@@ -96,6 +106,13 @@ func initialize_multiplayer():
|
|||||||
# Spawn armed enemies
|
# Spawn armed enemies
|
||||||
_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)
|
# Spawn the host player (peer ID 1)
|
||||||
print("[Level] Spawning host player")
|
print("[Level] Spawning host player")
|
||||||
var host_info = Network.players.get(1, {"nick": "Host", "skin": "blue"})
|
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)
|
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)
|
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
|
# Sync equipped weapons for all existing players to the newly joined player
|
||||||
print("[Server] Syncing equipped weapons to newly connected peer: ", peer_id)
|
print("[Server] Syncing equipped weapons to newly connected peer: ", peer_id)
|
||||||
for player_node in players_container.get_children():
|
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")
|
print("[Client ", multiplayer.get_unique_id(), "] Calling spawn_world_weapon locally")
|
||||||
spawn_world_weapon(weapon_data_path, spawn_position, initial_velocity, weapon_id)
|
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 ----------
|
# ---------- ARMED ENEMY SPAWNING ----------
|
||||||
var _armed_enemy_counter: int = 0
|
var _armed_enemy_counter: int = 0
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ func _enter_tree():
|
|||||||
$SpringArmOffset/SpringArm3D/Camera3D.current = is_multiplayer_authority()
|
$SpringArmOffset/SpringArm3D/Camera3D.current = is_multiplayer_authority()
|
||||||
|
|
||||||
func _ready():
|
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()
|
super._ready()
|
||||||
# Set respawn point to current position (where we spawned) - base_unit._ready already does this
|
# Set respawn point to current position (where we spawned) - base_unit._ready already does this
|
||||||
# Don't override with a hardcoded position
|
# Don't override with a hardcoded position
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
[gd_scene load_steps=4 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://level/ui/scripts/keybind_hint.gd" id="1_script"]
|
||||||
|
[ext_resource type="Theme" uid="uid://dvsh7tuhulnfm" path="res://level/ui/theme/wow_style.tres" id="2_theme"]
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_kb"]
|
||||||
|
bg_color = Color(0.15, 0.15, 0.15, 0.85)
|
||||||
|
border_width_left = 2
|
||||||
|
border_width_top = 2
|
||||||
|
border_width_right = 2
|
||||||
|
border_width_bottom = 2
|
||||||
|
border_color = Color(0.6, 0.5, 0.2, 1)
|
||||||
|
corner_radius_top_left = 4
|
||||||
|
corner_radius_top_right = 4
|
||||||
|
corner_radius_bottom_right = 4
|
||||||
|
corner_radius_bottom_left = 4
|
||||||
|
|
||||||
|
[node name="KeybindHint" type="PanelContainer"]
|
||||||
|
anchors_preset = 1
|
||||||
|
anchor_left = 1.0
|
||||||
|
anchor_right = 1.0
|
||||||
|
offset_left = -210.0
|
||||||
|
offset_top = 20.0
|
||||||
|
offset_right = -20.0
|
||||||
|
offset_bottom = 20.0
|
||||||
|
grow_horizontal = 0
|
||||||
|
grow_vertical = 1
|
||||||
|
theme = ExtResource("2_theme")
|
||||||
|
theme_override_styles/panel = SubResource("StyleBoxFlat_kb")
|
||||||
|
script = ExtResource("1_script")
|
||||||
|
|
||||||
|
[node name="Margin" type="MarginContainer" parent="."]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/margin_left = 10
|
||||||
|
theme_override_constants/margin_top = 8
|
||||||
|
theme_override_constants/margin_right = 10
|
||||||
|
theme_override_constants/margin_bottom = 8
|
||||||
|
|
||||||
|
[node name="Content" type="VBoxContainer" parent="Margin"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 4
|
||||||
@@ -8,6 +8,7 @@ var unit_frame: Control = null
|
|||||||
var target_frame: Control = null
|
var target_frame: Control = null
|
||||||
var character_sheet: Control = null
|
var character_sheet: Control = null
|
||||||
var tab_hint: Control = null
|
var tab_hint: Control = null
|
||||||
|
var keybind_hint: Control = null
|
||||||
var escape_menu: Control = null
|
var escape_menu: Control = null
|
||||||
|
|
||||||
# Player reference
|
# Player reference
|
||||||
@@ -77,6 +78,7 @@ func _create_ui_components():
|
|||||||
_create_unit_frame()
|
_create_unit_frame()
|
||||||
_create_character_sheet()
|
_create_character_sheet()
|
||||||
_create_tab_hint()
|
_create_tab_hint()
|
||||||
|
_create_keybind_hint()
|
||||||
_create_escape_menu()
|
_create_escape_menu()
|
||||||
|
|
||||||
## Create action bar at bottom of screen
|
## Create action bar at bottom of screen
|
||||||
@@ -125,6 +127,16 @@ func _create_tab_hint():
|
|||||||
else:
|
else:
|
||||||
push_error("[HUD] Failed to load tab_hint.tscn")
|
push_error("[HUD] Failed to load tab_hint.tscn")
|
||||||
|
|
||||||
|
## Create keybind hint (always visible, top-right corner)
|
||||||
|
func _create_keybind_hint():
|
||||||
|
var keybind_hint_scene = load("res://level/ui/scenes/keybind_hint.tscn")
|
||||||
|
if keybind_hint_scene:
|
||||||
|
keybind_hint = keybind_hint_scene.instantiate()
|
||||||
|
add_child(keybind_hint)
|
||||||
|
print("[HUD] Keybind hint created")
|
||||||
|
else:
|
||||||
|
push_error("[HUD] Failed to load keybind_hint.tscn")
|
||||||
|
|
||||||
## Create escape menu (toggle with Escape)
|
## Create escape menu (toggle with Escape)
|
||||||
func _create_escape_menu():
|
func _create_escape_menu():
|
||||||
var escape_menu_scene = load("res://level/ui/scenes/escape_menu.tscn")
|
var escape_menu_scene = load("res://level/ui/scenes/escape_menu.tscn")
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
extends PanelContainer
|
||||||
|
## Always-on compact panel listing the current controls.
|
||||||
|
## Reads bindings straight from the InputMap so it stays accurate if keys are rebound.
|
||||||
|
|
||||||
|
# Each row is a friendly label plus the input action(s) that feed it.
|
||||||
|
# Grouped actions (like movement) show their keys joined together.
|
||||||
|
const ROWS := [
|
||||||
|
{ "label": "Move", "actions": ["move_forward", "move_left", "move_backward", "move_right"] },
|
||||||
|
{ "label": "Jump", "actions": ["jump"] },
|
||||||
|
{ "label": "Sprint", "actions": ["shift"] },
|
||||||
|
{ "label": "Dash", "actions": ["dash"] },
|
||||||
|
{ "label": "Attack", "actions": ["attack"] },
|
||||||
|
{ "label": "Block", "actions": ["block"] },
|
||||||
|
{ "label": "Pick Up / Drop", "actions": ["pickup"] },
|
||||||
|
{ "label": "Character Sheet", "actions": ["toggle_character_sheet"] },
|
||||||
|
{ "label": "Chat", "actions": ["toggle_chat"] },
|
||||||
|
{ "label": "Menu", "actions": ["quit"] },
|
||||||
|
]
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_build()
|
||||||
|
|
||||||
|
func _build() -> void:
|
||||||
|
var content: VBoxContainer = $Margin/Content
|
||||||
|
|
||||||
|
var title := Label.new()
|
||||||
|
title.text = "CONTROLS"
|
||||||
|
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
title.add_theme_font_size_override("font_size", 16)
|
||||||
|
title.add_theme_color_override("font_color", Color(1, 0.8, 0, 1))
|
||||||
|
title.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||||
|
title.add_theme_constant_override("outline_size", 2)
|
||||||
|
content.add_child(title)
|
||||||
|
|
||||||
|
var grid := GridContainer.new()
|
||||||
|
grid.columns = 2
|
||||||
|
grid.add_theme_constant_override("h_separation", 12)
|
||||||
|
grid.add_theme_constant_override("v_separation", 3)
|
||||||
|
content.add_child(grid)
|
||||||
|
|
||||||
|
for row in ROWS:
|
||||||
|
var keys: Array[String] = []
|
||||||
|
for action in row["actions"]:
|
||||||
|
var k := _key_text(action)
|
||||||
|
if k != "":
|
||||||
|
keys.append(k)
|
||||||
|
if keys.is_empty():
|
||||||
|
continue
|
||||||
|
grid.add_child(_make_label(" ".join(keys), Color(1, 0.82, 0.2, 1)))
|
||||||
|
grid.add_child(_make_label(row["label"], Color.WHITE))
|
||||||
|
|
||||||
|
func _make_label(text: String, color: Color) -> Label:
|
||||||
|
var l := Label.new()
|
||||||
|
l.text = text
|
||||||
|
l.add_theme_font_size_override("font_size", 13)
|
||||||
|
l.add_theme_color_override("font_color", color)
|
||||||
|
l.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||||
|
l.add_theme_constant_override("outline_size", 1)
|
||||||
|
return l
|
||||||
|
|
||||||
|
## Returns a human-readable name for the first event bound to an action.
|
||||||
|
func _key_text(action: String) -> String:
|
||||||
|
if not InputMap.has_action(action):
|
||||||
|
return ""
|
||||||
|
for ev in InputMap.action_get_events(action):
|
||||||
|
if ev is InputEventKey:
|
||||||
|
var code: int = ev.physical_keycode if ev.physical_keycode != 0 else ev.keycode
|
||||||
|
return OS.get_keycode_string(code)
|
||||||
|
elif ev is InputEventMouseButton:
|
||||||
|
match ev.button_index:
|
||||||
|
MOUSE_BUTTON_LEFT: return "LMB"
|
||||||
|
MOUSE_BUTTON_RIGHT: return "RMB"
|
||||||
|
MOUSE_BUTTON_MIDDLE: return "MMB"
|
||||||
|
_: return "Mouse %d" % ev.button_index
|
||||||
|
return ""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dh85b10pfih5e
|
||||||
+4
-2
@@ -24,8 +24,10 @@ HUD="*res://level/ui/scripts/hud_manager.gd"
|
|||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
window/size/viewport_width=1280
|
window/size/viewport_width=1920
|
||||||
window/size/viewport_height=720
|
window/size/viewport_height=1080
|
||||||
|
window/stretch/mode="canvas_items"
|
||||||
|
window/stretch/aspect="expand"
|
||||||
|
|
||||||
[input]
|
[input]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user