Gladiator arena: gold economy, outfitting shop, permadeath runs

Turns the sandbox into a survival roguelike loop: outfit your gladiator
with banked gold, enter the arena, earn gold from kills and wave clears,
die, keep 10% of your total value (floored at the 500 fresh-start).

Economy (GameState autoload):
- banked_gold, owned items, and loadout persist to user://save.cfg
- Kill gold (basic 10g, armed 25g) credited server-side to the killer
- Wave-clear bonus (25 + 5/wave) for all players; wave synced to peers
- Death settlement liquidates gear, guarded against double-fire

Character creation / shop:
- Menu reframed: armory shop + live Character Sheet loadout preview
- Character Sheet gains preview mode (renders from loadout, no player)
- Buy/Equip with hand rules; two-handers and off-hands displace each
  other symmetrically (shop and in-game pickups)
- Chosen loadout auto-equips on spawn via existing RPC path

Combat feel:
- Attacks snap to camera facing and lock direction for the swing
- Dash commits to its direction for the full duration
- Weapon slots show name text when no icon is set
- Hitbox/hurtbox debug meshes hidden by default (H toggles)

Death is final: players no longer respawn; a results screen shows waves,
time, kills, and the settlement, then returns to camp. Armed enemies no
longer respawn (were an infinite gold farm and respawned invisible).
This commit is contained in:
2026-07-01 23:38:59 +01:00
parent 97ebbb1618
commit 1562b37563
25 changed files with 1023 additions and 43 deletions
+53 -5
View File
@@ -59,6 +59,9 @@ var _dash_cooldown_timer: float = 0.0
var _is_dashing: bool = false
var _dash_direction: Vector3 = Vector3.ZERO
# Facing lock: while > 0 the body cannot turn (attacks aim at camera, dashes stay straight)
var _facing_locked_timer: float = 0.0
# UI Signals
signal dash_cooldown_updated(remaining: float, total: float)
signal attack_cooldown_updated(remaining: float, total: float)
@@ -74,6 +77,9 @@ func _ready():
health_regen = 3.0
regen_delay = 5.0
# Gladiator arena: death ends your run - no respawning
can_respawn = false
super._ready()
# Set respawn point to current position (where we spawned) - base_unit._ready already does this
# Don't override with a hardcoded position
@@ -240,6 +246,10 @@ func _process(delta):
_attack_timer -= delta
attack_cooldown_updated.emit(_attack_timer, attack_cooldown)
# Update facing lock (attack/dash direction commitment)
if _facing_locked_timer > 0:
_facing_locked_timer -= delta
# Update dash timers
if _dash_timer > 0:
_dash_timer -= delta
@@ -295,12 +305,10 @@ func freeze():
_body.animate(Vector3.ZERO)
func _move() -> void:
# If dashing, use dash movement
# If dashing, use dash movement (facing already snapped and locked at dash start)
if _is_dashing:
velocity.x = _dash_direction.x * _current_speed * dash_speed_multiplier
velocity.z = _dash_direction.z * _current_speed * dash_speed_multiplier
if _body:
_body.apply_rotation(velocity)
return
var _input_direction: Vector2 = Vector2.ZERO
@@ -319,13 +327,35 @@ func _move() -> void:
if _direction:
velocity.x = _direction.x * _current_speed
velocity.z = _direction.z * _current_speed
if _body:
# Body only turns toward movement while facing isn't locked by an attack/dash
if _body and _facing_locked_timer <= 0:
_body.apply_rotation(velocity)
return
velocity.x = move_toward(velocity.x, 0, _current_speed)
velocity.z = move_toward(velocity.z, 0, _current_speed)
## Horizontal direction the camera is looking (movement-space, matches _move math)
func _get_camera_forward() -> Vector3:
var fwd: Vector3 = transform.basis * Vector3(0, 0, -1)
if _spring_arm_offset:
fwd = fwd.rotated(Vector3.UP, _spring_arm_offset.rotation.y)
fwd.y = 0
return fwd.normalized()
## Snap the body to face a direction and hold it there for `duration` seconds
func _lock_facing(direction: Vector3, duration: float):
_facing_locked_timer = max(_facing_locked_timer, duration)
if _body and direction.length_squared() > 0.001:
if _body.has_method("snap_rotation"):
_body.snap_rotation(direction)
else:
_body.apply_rotation(direction)
## Attacks commit to where the camera looks, not where the character was walking
func _lock_facing_to_camera(duration: float):
_lock_facing(_get_camera_forward(), duration)
func is_running() -> bool:
if Input.is_action_pressed("shift"):
_current_speed = SPRINT_SPEED
@@ -423,11 +453,14 @@ func _perform_attack():
# Use main hand weapon if available
if equipped_weapon and equipped_weapon.can_attack():
# Aim the swing at the camera direction and commit to it
_lock_facing_to_camera(equipped_weapon.weapon_data.startup_time + equipped_weapon.weapon_data.active_time)
equipped_weapon.perform_attack()
return
# Or use off-hand weapon if available
if equipped_offhand and equipped_offhand.can_attack():
_lock_facing_to_camera(equipped_offhand.weapon_data.startup_time + equipped_offhand.weapon_data.active_time)
equipped_offhand.perform_attack()
return
@@ -445,6 +478,9 @@ func _perform_attack():
_attack_timer = cooldown
_is_unarmed_attacking = true
# Aim the punch at the camera direction and commit to it
_lock_facing_to_camera(total_duration)
# Play attack animation once
if _body:
_body.play_attack("Attack_OneHand")
@@ -517,7 +553,7 @@ func _on_died(killer_id: int):
# Show death message on UI
if has_node("HealthUI/HealthText"):
get_node("HealthUI/HealthText").text = "DEAD - Respawning..."
get_node("HealthUI/HealthText").text = "DEAD - Run Over"
func _on_respawned():
print("[Player ", name, "] _on_respawned called. Authority: ", is_multiplayer_authority(), " Position: ", global_position)
@@ -560,6 +596,9 @@ func _perform_dash():
_dash_timer = dash_duration
_dash_cooldown_timer = dash_cooldown
# Commit to the dash direction - no steering or turning mid-dash
_lock_facing(_dash_direction, dash_duration)
# Animation is handled by the Body's animate function (Jump animation plays during dash)
## Sync attack animation to all clients
@@ -715,6 +754,15 @@ func equip_weapon(data: WeaponData):
# Determine which hand based on weapon type
var is_offhand = (data.hand_type == WeaponData.Hand.OFF_HAND)
# Two-handed weapons occupy both hands - clear the off-hand
if data.hand_type == WeaponData.Hand.TWO_HAND and equipped_offhand:
unequip_weapon(true)
# And symmetrically: an off-hand displaces a two-handed main weapon
if is_offhand and equipped_weapon and equipped_weapon.weapon_data \
and equipped_weapon.weapon_data.hand_type == WeaponData.Hand.TWO_HAND:
unequip_weapon(false)
# Unequip current weapon in that hand first
if is_offhand:
if equipped_offhand: