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:
@@ -13,6 +13,10 @@ func apply_rotation(_velocity: Vector3) -> void:
|
||||
|
||||
# rpc("sync_player_rotation", new_rotation_y)
|
||||
|
||||
## Instantly face a direction (no lerp) - used to lock attacks to camera facing
|
||||
func snap_rotation(_direction: Vector3) -> void:
|
||||
rotation.y = atan2(-_direction.x, -_direction.z)
|
||||
|
||||
func animate(_velocity: Vector3) -> void:
|
||||
# Don't override attack animation if it's playing
|
||||
if animation_player.is_playing() and animation_player.current_animation == "Attack1":
|
||||
|
||||
@@ -59,6 +59,13 @@ func _enter_tree():
|
||||
func _ready():
|
||||
super._ready()
|
||||
|
||||
# Armed gladiators are worth more than basic enemies (arena economy)
|
||||
gold_reward = 25
|
||||
|
||||
# Gladiators die for good - respawning would make them a repeatable gold farm
|
||||
# (they also don't re-show their body on respawn, so this avoids invisible enemies)
|
||||
can_respawn = false
|
||||
|
||||
# Auto-find body if not set
|
||||
if _body == null:
|
||||
if has_node("LilguyRigged/Armature"):
|
||||
|
||||
@@ -12,6 +12,8 @@ var current_target: Node = null
|
||||
@export var detection_range: float = 10.0
|
||||
## Whether this enemy is aggressive (will attack players)
|
||||
@export var is_aggressive: bool = true
|
||||
## Gold credited to the killing player (arena economy)
|
||||
@export var gold_reward: int = 10
|
||||
|
||||
func _ready():
|
||||
super._ready()
|
||||
|
||||
@@ -71,6 +71,9 @@ func start_wave():
|
||||
print("[EnemySpawner] Starting wave ", current_wave)
|
||||
wave_started.emit(current_wave)
|
||||
|
||||
# Broadcast wave number to every peer's GameState (for HUD + run stats)
|
||||
GameState.server_sync_wave(current_wave)
|
||||
|
||||
# Spawn enemies
|
||||
for i in range(enemies_per_wave):
|
||||
_spawn_enemy(i, enemies_per_wave)
|
||||
@@ -152,6 +155,10 @@ func _on_enemy_died(killer_id: int, enemy: Node):
|
||||
|
||||
print("[EnemySpawner] Enemy ", enemy.name, " defeated by ", killer_id)
|
||||
|
||||
# Credit kill gold to the killing player (server decides, credits their peer)
|
||||
if killer_id > 0 and Network.players.has(killer_id) and enemy is BaseEnemy:
|
||||
GameState.server_credit_kill(killer_id, enemy.gold_reward)
|
||||
|
||||
# Will be cleaned up in _update_active_enemies
|
||||
|
||||
## Update list of active enemies and check if wave complete
|
||||
@@ -177,6 +184,11 @@ func _on_wave_completed():
|
||||
print("[EnemySpawner] Wave ", current_wave, " completed!")
|
||||
wave_completed.emit(current_wave)
|
||||
|
||||
# Wave-clear bonus for every living player (scales with wave number)
|
||||
var wave_bonus: int = 25 + current_wave * 5
|
||||
for peer_id in Network.players.keys():
|
||||
GameState.server_credit_gold(peer_id, wave_bonus)
|
||||
|
||||
# Clean up dead enemies after a delay
|
||||
await get_tree().create_timer(2.0).timeout
|
||||
_cleanup_dead_enemies()
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
extends Node
|
||||
## Autoload singleton holding the gladiator roguelike economy + meta-progression.
|
||||
## Accessible globally as `GameState`.
|
||||
##
|
||||
## - banked_gold persists between sessions (saved to user://save.cfg).
|
||||
## - owned_items are resource paths of weapons the player has bought.
|
||||
## - run_gold is gold earned during the current arena run (NOT persisted).
|
||||
##
|
||||
## Death settlement: on run end you keep 10% of your total value (banked + run
|
||||
## gold + owned gear value), floored at the DEFAULT_GOLD starting amount so you
|
||||
## can never fall below a fresh start. This punishes hoarding and rewards spending
|
||||
## before you enter the arena.
|
||||
|
||||
const SAVE_PATH: String = "user://save.cfg"
|
||||
const DEFAULT_GOLD: int = 500
|
||||
## Fraction of total value kept on death.
|
||||
const SURVIVAL_KEEP_FRACTION: float = 0.10
|
||||
|
||||
# Persisted progression
|
||||
var banked_gold: int = DEFAULT_GOLD
|
||||
var owned_items: Array[String] = []
|
||||
# Loadout chosen in the outfitting screen: slot -> WeaponData resource path ("" = empty)
|
||||
var selected_loadout: Dictionary = {"main": "", "offhand": ""}
|
||||
|
||||
# Per-run state (reset each run, not saved)
|
||||
var run_gold: int = 0
|
||||
var run_active: bool = false
|
||||
var current_wave: int = 0
|
||||
var kills_this_run: int = 0
|
||||
var last_run_stats: Dictionary = {}
|
||||
var _run_start_msec: int = 0
|
||||
|
||||
signal gold_changed(banked_gold: int)
|
||||
signal run_gold_changed(run_gold: int)
|
||||
signal wave_changed(wave: int)
|
||||
signal run_ended(stats: Dictionary)
|
||||
signal loadout_changed(loadout: Dictionary)
|
||||
|
||||
func _ready() -> void:
|
||||
load_save()
|
||||
|
||||
# --- Persistence ---------------------------------------------------------
|
||||
|
||||
func load_save() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
var err := cfg.load(SAVE_PATH)
|
||||
if err != OK:
|
||||
# No save yet (or unreadable): start fresh.
|
||||
banked_gold = DEFAULT_GOLD
|
||||
owned_items = []
|
||||
save_game()
|
||||
return
|
||||
banked_gold = int(cfg.get_value("progress", "banked_gold", DEFAULT_GOLD))
|
||||
# ConfigFile returns an untyped Array; copy into the typed array.
|
||||
owned_items.clear()
|
||||
for path in cfg.get_value("progress", "owned_items", []):
|
||||
owned_items.append(str(path))
|
||||
# Restore loadout, dropping anything no longer owned
|
||||
var saved_loadout = cfg.get_value("progress", "selected_loadout", {"main": "", "offhand": ""})
|
||||
for slot in ["main", "offhand"]:
|
||||
var path := str(saved_loadout.get(slot, ""))
|
||||
selected_loadout[slot] = path if owns_item(path) else ""
|
||||
|
||||
func save_game() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
cfg.set_value("progress", "banked_gold", banked_gold)
|
||||
cfg.set_value("progress", "owned_items", owned_items)
|
||||
cfg.set_value("progress", "selected_loadout", selected_loadout)
|
||||
cfg.save(SAVE_PATH)
|
||||
|
||||
# --- Shop economy --------------------------------------------------------
|
||||
|
||||
func can_afford(cost: int) -> bool:
|
||||
return banked_gold >= cost
|
||||
|
||||
## Buy an item by its WeaponData resource path. Returns true on success.
|
||||
func buy_item(resource_path: String, cost: int) -> bool:
|
||||
if not can_afford(cost):
|
||||
return false
|
||||
banked_gold -= cost
|
||||
if not owned_items.has(resource_path):
|
||||
owned_items.append(resource_path)
|
||||
gold_changed.emit(banked_gold)
|
||||
save_game()
|
||||
return true
|
||||
|
||||
func owns_item(resource_path: String) -> bool:
|
||||
return owned_items.has(resource_path)
|
||||
|
||||
# --- Loadout (what you walk into the arena with) --------------------------
|
||||
|
||||
## Equip an owned item into the slot its hand_type dictates.
|
||||
## Two-handed weapons take the main slot and clear the off-hand.
|
||||
func equip_loadout_item(resource_path: String) -> bool:
|
||||
if not owns_item(resource_path):
|
||||
return false
|
||||
var data = load(resource_path)
|
||||
if not data is WeaponData:
|
||||
return false
|
||||
|
||||
match data.hand_type:
|
||||
WeaponData.Hand.OFF_HAND:
|
||||
selected_loadout["offhand"] = resource_path
|
||||
# An off-hand displaces a two-handed main - you only have two hands
|
||||
if _is_two_hand(selected_loadout.get("main", "")):
|
||||
selected_loadout["main"] = ""
|
||||
WeaponData.Hand.TWO_HAND:
|
||||
selected_loadout["main"] = resource_path
|
||||
selected_loadout["offhand"] = ""
|
||||
_: # MAIN_HAND
|
||||
selected_loadout["main"] = resource_path
|
||||
# If the previous main was two-handed it's gone now; offhand stays as-is
|
||||
loadout_changed.emit(selected_loadout)
|
||||
save_game()
|
||||
return true
|
||||
|
||||
func unequip_loadout_slot(slot: String) -> void:
|
||||
if selected_loadout.get(slot, "") != "":
|
||||
selected_loadout[slot] = ""
|
||||
loadout_changed.emit(selected_loadout)
|
||||
save_game()
|
||||
|
||||
func is_loadout_item_selected(resource_path: String) -> bool:
|
||||
return selected_loadout.get("main", "") == resource_path \
|
||||
or selected_loadout.get("offhand", "") == resource_path
|
||||
|
||||
func get_selected_loadout() -> Dictionary:
|
||||
return selected_loadout
|
||||
|
||||
# --- Run lifecycle -------------------------------------------------------
|
||||
|
||||
func start_run() -> void:
|
||||
run_gold = 0
|
||||
current_wave = 0
|
||||
kills_this_run = 0
|
||||
run_active = true
|
||||
_run_start_msec = Time.get_ticks_msec()
|
||||
run_gold_changed.emit(run_gold)
|
||||
wave_changed.emit(current_wave)
|
||||
|
||||
func add_run_gold(amount: int) -> void:
|
||||
run_gold += amount
|
||||
run_gold_changed.emit(run_gold)
|
||||
|
||||
## Seconds since the current run started.
|
||||
func get_run_time() -> float:
|
||||
if _run_start_msec == 0:
|
||||
return 0.0
|
||||
return (Time.get_ticks_msec() - _run_start_msec) / 1000.0
|
||||
|
||||
## Total liquid value of the player: banked + earned this run + value of owned gear.
|
||||
func get_total_value() -> int:
|
||||
var total := banked_gold + run_gold
|
||||
for path in owned_items:
|
||||
total += _item_cost(path)
|
||||
return total
|
||||
|
||||
## Settle a finished run (death). Liquidates gear, keeps 10% of total value
|
||||
## floored at DEFAULT_GOLD, then persists. Returns a stats Dictionary.
|
||||
## Guarded against double-calls (died can fire more than once on the host).
|
||||
func end_run() -> Dictionary:
|
||||
if not run_active:
|
||||
return last_run_stats
|
||||
run_active = false
|
||||
|
||||
var total := get_total_value()
|
||||
var kept: int = max(DEFAULT_GOLD, int(floor(total * SURVIVAL_KEEP_FRACTION)))
|
||||
|
||||
last_run_stats = {
|
||||
"waves": current_wave,
|
||||
"kills": kills_this_run,
|
||||
"time": get_run_time(),
|
||||
"gold_earned": run_gold,
|
||||
"total_value": total,
|
||||
"kept": kept,
|
||||
}
|
||||
|
||||
banked_gold = kept
|
||||
owned_items.clear()
|
||||
selected_loadout = {"main": "", "offhand": ""}
|
||||
run_gold = 0
|
||||
gold_changed.emit(banked_gold)
|
||||
run_gold_changed.emit(run_gold)
|
||||
loadout_changed.emit(selected_loadout)
|
||||
save_game()
|
||||
run_ended.emit(last_run_stats)
|
||||
return last_run_stats
|
||||
|
||||
# --- Multiplayer crediting (server -> owning peer) ------------------------
|
||||
# GameState is an autoload, so it exists at the same path on every peer with
|
||||
# the server (peer 1) as its multiplayer authority. The server decides who
|
||||
# earned gold and credits exactly that peer's local GameState.
|
||||
|
||||
## Server-side entry point: credit kill gold (and a kill) to a specific peer.
|
||||
func server_credit_kill(peer_id: int, amount: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
if peer_id == 1:
|
||||
credit_kill(amount) # Host credits itself directly
|
||||
elif _is_known_peer(peer_id):
|
||||
rpc_id(peer_id, "credit_kill", amount)
|
||||
|
||||
## Server-side entry point: credit plain gold (e.g. wave bonus) to a peer.
|
||||
func server_credit_gold(peer_id: int, amount: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
if peer_id == 1:
|
||||
credit_gold(amount)
|
||||
elif _is_known_peer(peer_id):
|
||||
rpc_id(peer_id, "credit_gold", amount)
|
||||
|
||||
## Runtime lookup so this singleton has no compile-time dependency on Network
|
||||
## (keeps the economy testable standalone).
|
||||
func _is_known_peer(peer_id: int) -> bool:
|
||||
var network = get_node_or_null("/root/Network")
|
||||
return network != null and network.players.has(peer_id)
|
||||
|
||||
## Server-side entry point: broadcast the current wave number to all peers.
|
||||
func server_sync_wave(wave: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
rpc("sync_wave", wave)
|
||||
|
||||
@rpc("authority", "reliable")
|
||||
func credit_gold(amount: int) -> void:
|
||||
if not run_active:
|
||||
return
|
||||
add_run_gold(amount)
|
||||
|
||||
@rpc("authority", "reliable")
|
||||
func credit_kill(amount: int) -> void:
|
||||
if not run_active:
|
||||
return
|
||||
kills_this_run += 1
|
||||
add_run_gold(amount)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_wave(wave: int) -> void:
|
||||
current_wave = wave
|
||||
wave_changed.emit(current_wave)
|
||||
|
||||
# --- Helpers -------------------------------------------------------------
|
||||
|
||||
func _is_two_hand(resource_path: String) -> bool:
|
||||
if resource_path.is_empty() or not ResourceLoader.exists(resource_path):
|
||||
return false
|
||||
var res = load(resource_path)
|
||||
return res is WeaponData and res.hand_type == WeaponData.Hand.TWO_HAND
|
||||
|
||||
func _item_cost(resource_path: String) -> int:
|
||||
if resource_path.is_empty() or not ResourceLoader.exists(resource_path):
|
||||
return 0
|
||||
var res := load(resource_path)
|
||||
if res is WeaponData:
|
||||
return res.cost
|
||||
return 0
|
||||
@@ -0,0 +1 @@
|
||||
uid://dlcyyjtlb8s7x
|
||||
@@ -14,8 +14,8 @@ 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
|
||||
## Global debug visibility toggle (press H in-game to toggle)
|
||||
static var debug_visible: bool = false
|
||||
|
||||
## Whether hitbox is currently active (only deals damage when active)
|
||||
var is_active: bool = false
|
||||
|
||||
@@ -5,8 +5,8 @@ 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
|
||||
## Global debug visibility toggle (press H in-game to toggle)
|
||||
static var debug_visible: bool = false
|
||||
|
||||
## The entity that owns this hurtbox (should be a BaseUnit or similar)
|
||||
@export var owner_entity: Node = null
|
||||
|
||||
@@ -42,6 +42,9 @@ func _ready():
|
||||
# Add quick-fill preset buttons
|
||||
_create_preset_buttons()
|
||||
|
||||
# Reshape the menu into the gladiator outfitting screen
|
||||
_setup_outfitting_menu()
|
||||
|
||||
# Create or find weapons container
|
||||
if has_node("WeaponsContainer"):
|
||||
weapons_container = get_node("WeaponsContainer")
|
||||
@@ -89,6 +92,9 @@ func initialize_multiplayer():
|
||||
|
||||
_multiplayer_initialized = true
|
||||
|
||||
# Begin this player's arena run (starts the survival clock + gold tracking)
|
||||
GameState.start_run()
|
||||
|
||||
if multiplayer.is_server():
|
||||
print("[Level] Running server initialization")
|
||||
Network.connect("player_connected", Callable(self, "_on_player_connected"))
|
||||
@@ -377,6 +383,13 @@ func _spawn_player_local(id: int, spawn_pos: Vector3):
|
||||
else:
|
||||
push_warning("[Level] HUD autoload not found! Make sure to restart Godot to register the new autoload.")
|
||||
|
||||
# Equip the loadout chosen in the outfitting screen (broadcasts to all peers)
|
||||
var loadout = GameState.get_selected_loadout()
|
||||
if loadout.get("main", "") != "":
|
||||
player.rpc("equip_weapon_from_world", loadout["main"])
|
||||
if loadout.get("offhand", "") != "":
|
||||
player.rpc("equip_weapon_from_world", loadout["offhand"])
|
||||
|
||||
var skin_enum = player_info["skin"]
|
||||
player.set_player_skin(skin_enum)
|
||||
# rpc("sync_player_skin", id, skin_enum)
|
||||
@@ -470,6 +483,40 @@ func _on_send_pressed() -> void:
|
||||
func msg_rpc(nick, msg):
|
||||
chat.text += str(nick, " : ", msg, "\n")
|
||||
|
||||
# ---------- OUTFITTING MENU ----------
|
||||
## Turn the plain host/join menu into the gladiator camp:
|
||||
## menu options on the left, armory shop + character sheet preview on the right.
|
||||
func _setup_outfitting_menu():
|
||||
# Retitle and shrink the header so it fits the narrowed left column
|
||||
var title = menu.get_node_or_null("MainContainer/Label")
|
||||
if title:
|
||||
title.text = "SURVIVAL OF\nTHE SNIPPEST"
|
||||
title.add_theme_font_size_override("font_size", 42)
|
||||
|
||||
# Narrow the existing menu column to the left third of the screen
|
||||
var main_container = menu.get_node_or_null("MainContainer")
|
||||
if main_container:
|
||||
main_container.anchor_right = 0.34
|
||||
|
||||
# Rename buttons to fit the arena fiction
|
||||
var host_button = menu.get_node_or_null("MainContainer/MainMenu/Buttons/Host")
|
||||
if host_button:
|
||||
host_button.text = "ENTER ARENA"
|
||||
var join_button = menu.get_node_or_null("MainContainer/MainMenu/Buttons/Join")
|
||||
if join_button:
|
||||
join_button.text = "JOIN FRIEND"
|
||||
|
||||
# Armory + preview fill the rest of the screen
|
||||
var outfitting_script = load("res://level/ui/scripts/outfitting_screen.gd")
|
||||
var outfitting = outfitting_script.new()
|
||||
outfitting.name = "OutfittingScreen"
|
||||
outfitting.anchor_left = 0.35
|
||||
outfitting.anchor_top = 0.04
|
||||
outfitting.anchor_right = 0.985
|
||||
outfitting.anchor_bottom = 0.96
|
||||
menu.add_child(outfitting)
|
||||
outfitting.set_nick_input(nick_input)
|
||||
|
||||
# ---------- PRESET BUTTONS ----------
|
||||
func _create_preset_buttons():
|
||||
# Create a container for preset buttons
|
||||
@@ -795,6 +842,18 @@ func _spawn_armed_enemy_local(enemy_name: String, spawn_pos: Vector3, main_weapo
|
||||
enemies_container.add_child(enemy, true)
|
||||
print("[Peer ", multiplayer.get_unique_id(), "] Armed enemy ", enemy_name, " spawned successfully")
|
||||
|
||||
# Server credits kill gold for armed enemies spawned outside the wave spawner
|
||||
if multiplayer.is_server():
|
||||
enemy.died.connect(_on_level_enemy_died.bind(enemy))
|
||||
|
||||
## Kill-gold payout for enemies spawned directly by the level (not the wave spawner).
|
||||
## Practice dummies are deliberately NOT connected - they respawn and would be farmable.
|
||||
func _on_level_enemy_died(killer_id: int, enemy: Node):
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
if killer_id > 0 and Network.players.has(killer_id) and enemy is BaseEnemy:
|
||||
GameState.server_credit_kill(killer_id, enemy.gold_reward)
|
||||
|
||||
## Spawn initial armed enemies when server starts
|
||||
func _spawn_armed_enemies():
|
||||
if not multiplayer.is_server():
|
||||
|
||||
@@ -11,6 +11,10 @@ func apply_rotation(_velocity: Vector3) -> void:
|
||||
var new_rotation_y = lerp_angle(rotation.y, atan2(_velocity.x, _velocity.z), LERP_VELOCITY)
|
||||
rotation.y = new_rotation_y
|
||||
|
||||
## Instantly face a direction (no lerp) - used to lock attacks to camera facing
|
||||
func snap_rotation(_direction: Vector3) -> void:
|
||||
rotation.y = atan2(_direction.x, _direction.z)
|
||||
|
||||
func animate(_velocity: Vector3) -> void:
|
||||
if not animation_player:
|
||||
return
|
||||
|
||||
+53
-5
@@ -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:
|
||||
|
||||
@@ -11,6 +11,12 @@ enum Hand { MAIN_HAND, OFF_HAND, TWO_HAND }
|
||||
@export_multiline var description: String = ""
|
||||
@export var hand_type: Hand = Hand.MAIN_HAND
|
||||
|
||||
@export_category("Shop / Economy")
|
||||
## Cost to buy this weapon in the character-creation shop.
|
||||
@export var cost: int = 100
|
||||
## Rough power tier (1 = starter, higher = stronger). Used for shop sorting/grouping.
|
||||
@export var tier: int = 1
|
||||
|
||||
@export_category("Combat Stats")
|
||||
@export var damage: float = 10.0
|
||||
@export var attack_range: float = 3.0
|
||||
|
||||
Reference in New Issue
Block a user