Compare commits
6
Commits
97ebbb1618
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4eb2e00be | ||
|
|
4d2b177f7b | ||
|
|
8f28304d0f | ||
|
|
fb10f7b042 | ||
|
|
f5e0642b39 | ||
|
|
1562b37563 |
@@ -4,111 +4,127 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
This is a 3D multiplayer fighting game built in Godot 4.5 using an inheritance-based architecture. The game uses ENet for client-server networking with the Network singleton (autoload) managing all multiplayer connections.
|
**Survival of the Snippest** — a 3D multiplayer gladiator arena survival roguelike built in Godot 4.5.
|
||||||
|
Outfit a gladiator with banked gold, enter the colosseum, survive escalating enemy waves, earn gold
|
||||||
|
from kills and wave clears, and either retire alive (keep everything) or die (keep 10% of your total
|
||||||
|
value, floored at the 500-gold fresh start). Uses ENet client-server networking.
|
||||||
|
|
||||||
## Key Commands
|
## Key Commands
|
||||||
|
|
||||||
### Running the Game
|
### Running the Game
|
||||||
- Open the project in Godot Editor and press F5 to run
|
- Open the project in Godot Editor and press F5, or: `godot --path . res://level/scenes/level.tscn`
|
||||||
- Or use: `godot --path . res://level/scenes/level.tscn`
|
- Headless script-error check: `godot --headless --path . --quit`
|
||||||
|
|
||||||
### Testing Multiplayer Locally
|
### Testing Multiplayer Locally
|
||||||
1. Run the main scene (level.tscn)
|
1. Run one instance, click ENTER ARENA to host (port 8080)
|
||||||
2. Click "Host" on one instance to create a server (default port 8080)
|
2. Run a second instance, click JOIN FRIEND to connect (IP 127.0.0.1)
|
||||||
3. Run another instance and click "Join" to connect as a client
|
|
||||||
|
### In-Game Debug Keys
|
||||||
|
- `N` — start next enemy wave (server only)
|
||||||
|
- `H` — toggle hitbox/hurtbox debug visualization (off by default)
|
||||||
|
|
||||||
|
## The Core Loop
|
||||||
|
|
||||||
|
1. **Outfitting screen** (main menu): armory shop on the left/middle, Character Sheet in preview
|
||||||
|
mode on the right showing the pending loadout live. Buy weapons with banked gold; hand rules
|
||||||
|
enforced (two-handers and off-hands displace each other).
|
||||||
|
2. **Arena run**: chosen loadout auto-equips on spawn. Waves spawn on a timer; kills pay gold
|
||||||
|
(server-credited to the killer's peer), wave clears pay a bonus to all players.
|
||||||
|
3. **Run end**:
|
||||||
|
- **Death** (permadeath, no respawn): results screen; keep `max(500, 10% of total value)`;
|
||||||
|
gear and loadout liquidated.
|
||||||
|
- **Retire** (escape menu, or quitting alive): bank 100% of run gold, keep all gear.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Inheritance Structure
|
### Autoloads (singletons)
|
||||||
The codebase follows an inheritance-based design pattern:
|
- **Network** (`level/scripts/network.gd`) — ENet setup, host/join, `players` dict keyed by peer_id
|
||||||
- **BaseUnit** (base class) - Common functionality for all game entities that can take damage/have health
|
(`{"nick": String, "skin": Character.SkinColor}`). Server = peer 1, MAX_PLAYERS = 10.
|
||||||
- **Character** extends **CharacterBody3D** - Player character class (class_name: Character)
|
- **GameState** (`level/scripts/game_state.gd`) — the economy. Persists `banked_gold`,
|
||||||
- Currently implements movement, jumping, sprinting, respawning, and skin customization
|
`owned_items`, `selected_loadout` to `user://save.cfg`. Per-run: `run_gold`, `current_wave`,
|
||||||
- Uses multiplayer authority for input handling (only the owner processes input)
|
`kills_this_run`, `run_active`. Key API: `buy_item`, `equip_loadout_item`, `start_run`,
|
||||||
|
`end_run` (death settlement, double-call guarded), `retire_run` (bank everything),
|
||||||
|
`server_credit_kill/gold` (server → owning peer RPCs), `server_sync_wave`.
|
||||||
|
No compile-time dependency on Network — keep it that way (testable standalone via `--script`).
|
||||||
|
- **HUD** (`level/ui/scripts/hud_manager.gd`) — builds all in-run UI for the local player
|
||||||
|
(action bar, unit frame, character sheet, arena status wave/gold readout, escape menu,
|
||||||
|
run results on death). `reset()` tears everything down when returning to the menu —
|
||||||
|
autoload children survive scene reloads, so always reset before `reload_current_scene()`.
|
||||||
|
|
||||||
### Networking (Network.gd)
|
### Class hierarchy
|
||||||
- **Type**: Autoload singleton (accessible via `Network` globally)
|
- **BaseUnit** (`base_unit.gd`) — health, damage, death, respawn, passive regen. Server validates
|
||||||
- **Protocol**: ENet (UDP-based)
|
all damage. NOTE: `died` can fire twice on the host (direct emit + call_local sync) — guard
|
||||||
- **Architecture**: Client-Server (one host, multiple clients up to MAX_PLAYERS=10)
|
anything expensive hooked to it.
|
||||||
- **Player Data**: Stored in `Network.players` dictionary keyed by peer_id
|
- **Character** (`player.gd`, class_name Character) — player: movement, dash, attacks, blocking,
|
||||||
- Contains: `{"nick": String, "skin": Character.SkinColor}`
|
weapon equip/drop/pickup. `can_respawn = false` (death ends the run). Attacks snap the body to
|
||||||
|
camera facing and lock direction for startup+active (`_lock_facing_to_camera`); dash locks
|
||||||
|
direction for its duration.
|
||||||
|
- **BaseEnemy** (`base_enemy.gd`) — targeting, `gold_reward` (basic 10, armed 25). Enemies are
|
||||||
|
server-authoritative (authority = 1).
|
||||||
|
- **BasicEnemy** — melee chaser; `can_respawn = false`, despawns after death, 40% health orb drop.
|
||||||
|
- **ArmedEnemy** — player-model enemy that seeks/equips weapons and drops them on death.
|
||||||
|
`can_respawn = false` (respawning would be a gold farm). Practice dummies DO respawn and
|
||||||
|
deliberately pay no gold.
|
||||||
|
|
||||||
|
### Weapons
|
||||||
|
- **WeaponData** (`weapon_data.gd`) — Resource in `level/resources/*.tres`: combat stats, hand type
|
||||||
|
(MAIN_HAND / OFF_HAND / TWO_HAND), block stats, `cost`/`tier` for the shop, `icon` (optional —
|
||||||
|
weapon slots fall back to name text when missing).
|
||||||
|
- **BaseWeapon** — equipped instance on a hand bone; **WorldWeapon** — pickable RigidBody3D.
|
||||||
|
- No free floor weapons: manually placed WorldWeapons in level.tscn are removed at init on the
|
||||||
|
server (`_remove_manual_weapons_on_server`). Gear enters play via the shop or enemy drops only.
|
||||||
|
- The shop catalog is auto-discovered: any WeaponData `.tres` in `level/resources/` appears in
|
||||||
|
the armory (`outfitting_screen.gd`).
|
||||||
|
|
||||||
|
### UI
|
||||||
|
- `character_sheet.gd` has two modes: live player (Tab in-game) and `preview_mode` (embedded in
|
||||||
|
the outfitting screen, rendering from a loadout dictionary). Keep both paths working.
|
||||||
|
- UI is largely built in code; match that style for new components.
|
||||||
|
|
||||||
### RPC Patterns
|
### RPC Patterns
|
||||||
The project uses Godot 4's RPC system for multiplayer synchronization:
|
- `@rpc("any_peer", "reliable")` — server-validated actions (damage, heal); server checks
|
||||||
- Use `@rpc("any_peer", "reliable")` for critical data (health, damage)
|
`multiplayer.is_server()` inside.
|
||||||
- Use `@rpc("any_peer", "unreliable")` for frequent position updates
|
- `@rpc("any_peer", "call_local", "reliable")` — state sync to all peers (spawns, equips, health).
|
||||||
- The server (multiplayer.is_server()) is authoritative for game state
|
- `@rpc("authority", ...)` — server-initiated (GameState credits, wave sync).
|
||||||
- Use `multiplayer.get_remote_sender_id()` to identify RPC sender
|
- Use `rpc_id(peer_id, ...)` for targeted sync (late-join). When the server targets itself
|
||||||
- Use `rpc_id(peer_id, "method_name")` for targeted RPCs
|
(peer 1), call the method directly instead of `rpc_id(1, ...)`.
|
||||||
|
- Late-join sync lives in `level.gd _on_player_connected` — new networked object types must be
|
||||||
|
added there (players, weapons, enemies, orbs, equipped gear all have precedents).
|
||||||
|
|
||||||
### Scene Structure
|
### Scene Structure (level.tscn)
|
||||||
- **level/scenes/level.tscn** - Main scene with menu UI, chat, and PlayersContainer
|
- Containers the code expects by name: `PlayersContainer`, `WeaponsContainer`, `EnemiesContainer`,
|
||||||
- level.gd spawns players dynamically when they connect
|
`OrbsContainer`, `PlayerSpawnPoints`, `EnemySpawner` (spawn points are its children named
|
||||||
- **level/scenes/player.tscn** - Player character scene
|
`EnemySpawnPoint*`).
|
||||||
- Instantiated by server when players connect
|
- Networked nodes are named deterministically for lookup: players by peer_id, `WorldWeapon_<id>`,
|
||||||
- Named with peer_id for easy lookup
|
`Enemy_<wave>_<n>`, `ArmedEnemy_<n>`, `HealthOrb_<id>`.
|
||||||
|
|
||||||
### Physics Layers
|
### Physics Layers
|
||||||
- Layer 1: "player" - Player collision
|
1 player · 2 world · 3 weapon · 4 hitbox · 5 hurtbox
|
||||||
- Layer 2: "world" - Environment collision
|
|
||||||
|
|
||||||
### Input Actions
|
### Input Actions (project.godot)
|
||||||
Defined in project.godot:
|
`move_left/right/forward/backward` (WASD), `jump` (Space), `shift` (sprint), `dash` (F),
|
||||||
- `move_left`, `move_right`, `move_forward`, `move_backward` (WASD)
|
`attack` (LMB), `block` (RMB), `pickup` (E), `toggle_character_sheet` (Tab),
|
||||||
- `jump` (Space)
|
`toggle_chat` (F12), `quit` (Esc).
|
||||||
- `shift` (Left Shift for sprinting)
|
|
||||||
- `quit` (Escape)
|
|
||||||
- `toggle_chat` (Enter)
|
|
||||||
|
|
||||||
## Development Guidelines
|
## Development Guidelines
|
||||||
|
|
||||||
### Git Commits
|
### Git Commits
|
||||||
When creating git commits, do NOT include "🤖 Generated with [Claude Code]" or "Co-Authored-By: Claude" in commit messages. Keep commit messages clean and professional.
|
When creating git commits, do NOT include "🤖 Generated with [Claude Code]" or
|
||||||
|
"Co-Authored-By: Claude" in commit messages. Keep commit messages clean and professional.
|
||||||
|
|
||||||
### Adding New Components
|
### Verification
|
||||||
1. Create scripts in `level/scripts/`
|
- Always run `godot --headless --path . --quit` after script changes and check for SCRIPT ERROR.
|
||||||
2. For multiplayer-synchronized components:
|
(Pre-existing "invalid UID" warnings on mesh/theme assets are known noise.)
|
||||||
- Always check `is_multiplayer_authority()` before processing input
|
- Economy logic can be tested standalone: a SceneTree script that instantiates
|
||||||
- Use RPC for state changes that need to replicate
|
`game_state.gd` and exercises the API (`--script` mode; autoload `_ready` doesn't fire there,
|
||||||
- Server should validate all important state changes
|
call `load_save()` manually). Restore the default save afterwards.
|
||||||
|
- Test multiplayer with 2 instances (host + client); check late joins and death/retire flows.
|
||||||
|
|
||||||
### Adding New Unit Types (Enemies, NPCs)
|
### Adding Content
|
||||||
1. Create a new class that extends BaseUnit
|
- **New weapon**: create a WeaponData `.tres` in `level/resources/` with `cost`/`tier` — the shop
|
||||||
2. Implement required virtual methods
|
picks it up automatically.
|
||||||
3. Add any unique behavior in _physics_process/_process
|
- **New enemy**: extend BaseEnemy, set `gold_reward`, decide `can_respawn` (respawning enemies
|
||||||
4. Ensure multiplayer authority is set correctly
|
must not pay gold), add scene to the EnemySpawner pool or spawn via level.gd (connect its
|
||||||
|
`died` signal for gold there).
|
||||||
### Testing Multiplayer
|
- **Multiplayer state changes**: server validates, then syncs via RPC; check
|
||||||
- Always test with at least 2 instances (1 host, 1 client)
|
`is_multiplayer_authority()` before processing input.
|
||||||
- Test edge cases: late joins, disconnections, packet loss
|
|
||||||
- Verify state is synchronized correctly across all clients
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Spawning Networked Entities
|
|
||||||
```gdscript
|
|
||||||
# Server-side only
|
|
||||||
if multiplayer.is_server():
|
|
||||||
var entity = entity_scene.instantiate()
|
|
||||||
entity.name = str(unique_id) # Name with ID for easy lookup
|
|
||||||
entity.set_multiplayer_authority(owner_peer_id)
|
|
||||||
container.add_child(entity, true) # true = force readable name
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multiplayer Authority Check
|
|
||||||
```gdscript
|
|
||||||
func _physics_process(delta):
|
|
||||||
if not is_multiplayer_authority():
|
|
||||||
return
|
|
||||||
# Only the authority processes input/logic
|
|
||||||
```
|
|
||||||
|
|
||||||
### RPC for State Changes
|
|
||||||
```gdscript
|
|
||||||
@rpc("any_peer", "reliable")
|
|
||||||
func take_damage(amount: int, attacker_id: int):
|
|
||||||
if not multiplayer.is_server():
|
|
||||||
return # Server validates
|
|
||||||
# Apply damage logic
|
|
||||||
rpc("sync_health", current_health) # Broadcast to all
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
script = ExtResource("2_hfi3c")
|
script = ExtResource("2_hfi3c")
|
||||||
weapon_name = "Apple Sword"
|
weapon_name = "Apple Sword"
|
||||||
description = "yum"
|
description = "yum"
|
||||||
|
cost = 300
|
||||||
|
tier = 2
|
||||||
damage = 20.0
|
damage = 20.0
|
||||||
attack_range = 3.5
|
attack_range = 3.5
|
||||||
attack_cooldown = 0.6
|
attack_cooldown = 0.6
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
weapon_name = "Lobster Axe"
|
weapon_name = "Lobster Axe"
|
||||||
description = "A heavy-hitting axe shaped like a lobster claw. Surprisingly quick for its size."
|
description = "A heavy-hitting axe shaped like a lobster claw. Surprisingly quick for its size."
|
||||||
|
cost = 350
|
||||||
|
tier = 2
|
||||||
hand_type = 2
|
hand_type = 2
|
||||||
damage = 18.0
|
damage = 18.0
|
||||||
attack_cooldown = 0.7
|
attack_cooldown = 0.7
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
weapon_name = "Wooden Shield"
|
weapon_name = "Wooden Shield"
|
||||||
description = "A sturdy wooden shield. Can be used to bash enemies or block attacks."
|
description = "A sturdy wooden shield. Can be used to bash enemies or block attacks."
|
||||||
|
cost = 120
|
||||||
|
tier = 1
|
||||||
hand_type = 1
|
hand_type = 1
|
||||||
damage = 8.0
|
damage = 8.0
|
||||||
attack_range = 2.5
|
attack_range = 2.5
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
weapon_name = "Iron Sword"
|
weapon_name = "Iron Sword"
|
||||||
description = "A simple iron sword. Good for close combat."
|
description = "A simple iron sword. Good for close combat."
|
||||||
|
cost = 150
|
||||||
|
tier = 1
|
||||||
damage = 15.0
|
damage = 15.0
|
||||||
attack_range = 3.5
|
attack_range = 3.5
|
||||||
attack_cooldown = 0.6
|
attack_cooldown = 0.6
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
weapon_name = "Iron Sword"
|
weapon_name = "Iron Sword"
|
||||||
description = "A simple iron sword. Good for close combat."
|
description = "A simple iron sword. Good for close combat."
|
||||||
|
cost = 250
|
||||||
|
tier = 2
|
||||||
damage = 20.0
|
damage = 20.0
|
||||||
attack_range = 3.5
|
attack_range = 3.5
|
||||||
attack_cooldown = 0.6
|
attack_cooldown = 0.6
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[gd_scene load_steps=2 format=3 uid="uid://c76hql634gf5"]
|
[gd_scene load_steps=2 format=3 uid="uid://c76hql634gf5"]
|
||||||
|
|
||||||
[ext_resource type="ArrayMesh" uid="uid://cc1kxfbkvpo2d" path="res://assets/Objects/swordlowpoly.obj" id="1_8p8s3"]
|
[ext_resource type="ArrayMesh" uid="uid://ci1itpjmauvrp" path="res://assets/Objects/swordlowpoly.obj" id="1_8p8s3"]
|
||||||
|
|
||||||
[node name="BaseWeaponScene" type="Node3D"]
|
[node name="BaseWeaponScene" type="Node3D"]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[gd_scene load_steps=3 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="PackedScene" uid="uid://byknup31d2b53" path="res://level/scenes/enemies/basic_enemy.tscn" id="1_base"]
|
||||||
|
[ext_resource type="Script" path="res://level/scripts/boss_lobster.gd" id="2_boss_script"]
|
||||||
|
|
||||||
|
[node name="BossLobster" instance=ExtResource("1_base")]
|
||||||
|
script = ExtResource("2_boss_script")
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
[gd_scene load_steps=4 format=3 uid="uid://blm8lav3xh2yw"]
|
[gd_scene load_steps=5 format=3 uid="uid://blm8lav3xh2yw"]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://level/scripts/enemy_spawner.gd" id="1_spawner"]
|
[ext_resource type="Script" path="res://level/scripts/enemy_spawner.gd" id="1_spawner"]
|
||||||
[ext_resource type="PackedScene" uid="uid://byknup31d2b53" path="res://level/scenes/enemies/basic_enemy.tscn" id="2_basic_enemy"]
|
[ext_resource type="PackedScene" uid="uid://byknup31d2b53" path="res://level/scenes/enemies/basic_enemy.tscn" id="2_basic_enemy"]
|
||||||
|
[ext_resource type="PackedScene" path="res://level/scenes/enemies/boss_lobster.tscn" id="3_boss"]
|
||||||
|
|
||||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_indicator"]
|
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_indicator"]
|
||||||
albedo_color = Color(1, 0.5, 0, 0.3)
|
albedo_color = Color(1, 0.5, 0, 0.3)
|
||||||
@@ -17,6 +18,7 @@ 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")])
|
||||||
|
boss_scene = ExtResource("3_boss")
|
||||||
|
|
||||||
[node name="SpawnRadiusIndicator" type="MeshInstance3D" parent="."]
|
[node name="SpawnRadiusIndicator" type="MeshInstance3D" parent="."]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.1, 0)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.1, 0)
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
[gd_scene load_steps=48 format=4 uid="uid://cffjduipbb3s5"]
|
[gd_scene load_steps=48 format=4 uid="uid://cffjduipbb3s5"]
|
||||||
|
|
||||||
[ext_resource type="Script" uid="uid://c2si8gkbnde0c" path="res://level/scripts/player.gd" id="1_tdh26"]
|
[ext_resource type="Script" uid="uid://c2si8gkbnde0c" path="res://level/scripts/player.gd" id="1_tdh26"]
|
||||||
[ext_resource type="Texture2D" uid="uid://bdg48m6l86q8i" path="res://assets/characters/player/3DGodotRobot_GodotPalette.png" id="1_w11jc"]
|
[ext_resource type="Texture2D" uid="uid://5cevtcic4bab" path="res://assets/characters/player/3DGodotRobot_GodotPalette.png" id="1_w11jc"]
|
||||||
[ext_resource type="Script" uid="uid://v8j54rbc0ik" path="res://level/scripts/3d_godot_robot.gd" id="2_mexc5"]
|
[ext_resource type="Script" uid="uid://v8j54rbc0ik" path="res://level/scripts/3d_godot_robot.gd" id="2_mexc5"]
|
||||||
[ext_resource type="Texture2D" uid="uid://cw6pxwst2gh85" path="res://assets/characters/player/GodotRobotPaletteSwap/GodotYellowPalette.png" id="3_l3dv8"]
|
[ext_resource type="Texture2D" uid="uid://bx1whlf637r18" path="res://assets/characters/player/GodotRobotPaletteSwap/GodotYellowPalette.png" id="3_l3dv8"]
|
||||||
[ext_resource type="Texture2D" uid="uid://bbid6mowxhd5b" path="res://assets/characters/player/GodotRobotPaletteSwap/GodotGreenPalette.png" id="4_74ree"]
|
[ext_resource type="Texture2D" uid="uid://cjqqgbprwdl6h" path="res://assets/characters/player/GodotRobotPaletteSwap/GodotGreenPalette.png" id="4_74ree"]
|
||||||
[ext_resource type="Texture2D" uid="uid://fpmmv2oxjcdv" path="res://assets/characters/player/GodotRobotPaletteSwap/GodotPalette.png" id="4_max2b"]
|
[ext_resource type="Texture2D" uid="uid://b1wwwro3s4m16" path="res://assets/characters/player/GodotRobotPaletteSwap/GodotPalette.png" id="4_max2b"]
|
||||||
[ext_resource type="Script" uid="uid://bj7yrijm7bppq" path="res://level/scripts/spring_arm_offset.gd" id="4_ygggd"]
|
[ext_resource type="Script" uid="uid://bj7yrijm7bppq" path="res://level/scripts/spring_arm_offset.gd" id="4_ygggd"]
|
||||||
[ext_resource type="Texture2D" uid="uid://brp1gy30s4rks" path="res://assets/characters/player/GodotRobotPaletteSwap/GodotRedPalette.png" id="5_qlefn"]
|
[ext_resource type="Texture2D" uid="uid://1p20xwfvvfk2" path="res://assets/characters/player/GodotRobotPaletteSwap/GodotRedPalette.png" id="5_qlefn"]
|
||||||
[ext_resource type="ArrayMesh" uid="uid://dmottq5u3my52" path="res://assets/characters/Lobster/10029_Lobster_v1_iterations-2.obj" id="8_jeskb"]
|
[ext_resource type="ArrayMesh" uid="uid://dy0xld0fpulmk" path="res://assets/characters/Lobster/10029_Lobster_v1_iterations-2.obj" id="8_jeskb"]
|
||||||
|
|
||||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_mx45w"]
|
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_mx45w"]
|
||||||
radius = 0.35796
|
radius = 0.35796
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[gd_scene load_steps=4 format=3 uid="uid://cq8r5mkn3wvxj"]
|
[gd_scene load_steps=4 format=3 uid="uid://cq8r5mkn3wvxj"]
|
||||||
|
|
||||||
[ext_resource type="PackedScene" uid="uid://cejg4ixtc5xsf" path="res://level/scenes/weapons/LobsterAxe.glb" id="1_lobster"]
|
[ext_resource type="PackedScene" uid="uid://bk5akj878m2a3" path="res://level/scenes/weapons/LobsterAxe.glb" id="1_lobster"]
|
||||||
[ext_resource type="Script" uid="uid://jyas86y3f0jp" path="res://level/scripts/hit_box.gd" id="2_hitbox"]
|
[ext_resource type="Script" uid="uid://jyas86y3f0jp" path="res://level/scripts/hit_box.gd" id="2_hitbox"]
|
||||||
|
|
||||||
[sub_resource type="BoxShape3D" id="BoxShape3D_lobster"]
|
[sub_resource type="BoxShape3D" id="BoxShape3D_lobster"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[gd_scene load_steps=4 format=3 uid="uid://rkvkbxlweo60"]
|
[gd_scene load_steps=4 format=3 uid="uid://rkvkbxlweo60"]
|
||||||
|
|
||||||
[ext_resource type="PackedScene" uid="uid://df31n55xyn27i" path="res://assets/Objects/TestSword.glb" id="1_4fdvi"]
|
[ext_resource type="PackedScene" uid="uid://culurukxpqswh" path="res://assets/Objects/TestSword.glb" id="1_4fdvi"]
|
||||||
[ext_resource type="Script" uid="uid://jyas86y3f0jp" path="res://level/scripts/hit_box.gd" id="2_3qhqw"]
|
[ext_resource type="Script" uid="uid://jyas86y3f0jp" path="res://level/scripts/hit_box.gd" id="2_3qhqw"]
|
||||||
|
|
||||||
[sub_resource type="BoxShape3D" id="BoxShape3D_5u25i"]
|
[sub_resource type="BoxShape3D" id="BoxShape3D_5u25i"]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[gd_scene load_steps=3 format=3 uid="uid://rbvk4mg40ceg"]
|
[gd_scene load_steps=3 format=3 uid="uid://rbvk4mg40ceg"]
|
||||||
|
|
||||||
[ext_resource type="ArrayMesh" uid="uid://rsoymmi6yqhp" path="res://assets/Objects/shield.obj" id="1"]
|
[ext_resource type="ArrayMesh" uid="uid://bwpa5ct4buxs0" path="res://assets/Objects/shield.obj" id="1"]
|
||||||
[ext_resource type="ArrayMesh" uid="uid://dmottq5u3my52" path="res://assets/characters/Lobster/10029_Lobster_v1_iterations-2.obj" id="2_gfu4n"]
|
[ext_resource type="ArrayMesh" uid="uid://dy0xld0fpulmk" path="res://assets/characters/Lobster/10029_Lobster_v1_iterations-2.obj" id="2_gfu4n"]
|
||||||
|
|
||||||
[node name="ShieldMesh" type="Node3D"]
|
[node name="ShieldMesh" type="Node3D"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[gd_scene load_steps=4 format=3 uid="uid://dyjfaq654xne3"]
|
[gd_scene load_steps=4 format=3 uid="uid://dyjfaq654xne3"]
|
||||||
|
|
||||||
[ext_resource type="ArrayMesh" uid="uid://1wnuqcx2n4xq" path="res://assets/Objects/swordlowpoly.obj" id="1"]
|
[ext_resource type="ArrayMesh" uid="uid://ci1itpjmauvrp" path="res://assets/Objects/swordlowpoly.obj" id="1"]
|
||||||
[ext_resource type="Script" uid="uid://jyas86y3f0jp" path="res://level/scripts/hit_box.gd" id="2_wyi6r"]
|
[ext_resource type="Script" uid="uid://jyas86y3f0jp" path="res://level/scripts/hit_box.gd" id="2_wyi6r"]
|
||||||
|
|
||||||
[sub_resource type="BoxShape3D" id="BoxShape3D_mhdau"]
|
[sub_resource type="BoxShape3D" id="BoxShape3D_mhdau"]
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ func apply_rotation(_velocity: Vector3) -> void:
|
|||||||
|
|
||||||
# rpc("sync_player_rotation", new_rotation_y)
|
# 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:
|
func animate(_velocity: Vector3) -> void:
|
||||||
# Don't override attack animation if it's playing
|
# Don't override attack animation if it's playing
|
||||||
if animation_player.is_playing() and animation_player.current_animation == "Attack1":
|
if animation_player.is_playing() and animation_player.current_animation == "Attack1":
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ func _enter_tree():
|
|||||||
func _ready():
|
func _ready():
|
||||||
super._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
|
# Auto-find body if not set
|
||||||
if _body == null:
|
if _body == null:
|
||||||
if has_node("LilguyRigged/Armature"):
|
if has_node("LilguyRigged/Armature"):
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ var current_target: Node = null
|
|||||||
@export var detection_range: float = 10.0
|
@export var detection_range: float = 10.0
|
||||||
## Whether this enemy is aggressive (will attack players)
|
## Whether this enemy is aggressive (will attack players)
|
||||||
@export var is_aggressive: bool = true
|
@export var is_aggressive: bool = true
|
||||||
|
## Gold credited to the killing player (arena economy)
|
||||||
|
@export var gold_reward: int = 10
|
||||||
|
|
||||||
func _ready():
|
func _ready():
|
||||||
super._ready()
|
super._ready()
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
extends BasicEnemy
|
||||||
|
class_name BossLobster
|
||||||
|
## The Snippest itself: the basic lobster, scaled up in size and stats.
|
||||||
|
## Spawned by the EnemySpawner on boss milestone waves.
|
||||||
|
|
||||||
|
const BOSS_MULTIPLIER := 2.0
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
# Scaled combat stats - applied BEFORE super._ready() so the hitbox
|
||||||
|
# picks them up via set_stats(attack_damage, attack_knockback)
|
||||||
|
max_health *= BOSS_MULTIPLIER
|
||||||
|
attack_damage *= BOSS_MULTIPLIER
|
||||||
|
attack_knockback *= BOSS_MULTIPLIER
|
||||||
|
attack_range *= BOSS_MULTIPLIER # A bigger lobster has longer reach
|
||||||
|
|
||||||
|
# Movement deliberately stays near normal - big things lumber
|
||||||
|
move_speed *= 1.2
|
||||||
|
|
||||||
|
# Boss rewards: jackpot gold, guaranteed health orb
|
||||||
|
gold_reward = 250
|
||||||
|
health_orb_drop_chance = 1.0
|
||||||
|
|
||||||
|
super._ready()
|
||||||
|
|
||||||
|
_apply_boss_scale()
|
||||||
|
|
||||||
|
## Uniformly scale the visual and collision children around the root.
|
||||||
|
## Child-node scaling (not root CharacterBody3D scale, not shared shape
|
||||||
|
## resources) keeps physics sane and other lobsters unaffected.
|
||||||
|
func _apply_boss_scale():
|
||||||
|
for child_name in ["Mesh", "CollisionShape3D", "HurtBox", "HitBox"]:
|
||||||
|
var child = get_node_or_null(child_name)
|
||||||
|
if child:
|
||||||
|
child.transform = child.transform.scaled(Vector3.ONE * BOSS_MULTIPLIER)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b7mbsg83olhh4
|
||||||
@@ -25,6 +25,14 @@ signal all_enemies_defeated()
|
|||||||
@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
|
||||||
|
|
||||||
|
@export_category("Escalation")
|
||||||
|
@export var wave_count_growth: float = 1.0 ## Extra enemies added per wave
|
||||||
|
@export var max_enemies_per_wave: int = 20 ## Hard cap on wave size
|
||||||
|
@export var health_growth_per_wave: float = 0.15 ## +15% enemy health per wave
|
||||||
|
@export var damage_growth_per_wave: float = 0.10 ## +10% enemy damage per wave
|
||||||
|
@export var boss_wave_interval: int = 5 ## Every Nth wave is a boss wave (0 = never)
|
||||||
|
@export var boss_scene: PackedScene = null ## The Snippest itself
|
||||||
|
|
||||||
## Wave tracking
|
## Wave tracking
|
||||||
var current_wave: int = 0
|
var current_wave: int = 0
|
||||||
var active_enemies: Array[Node] = []
|
var active_enemies: Array[Node] = []
|
||||||
@@ -71,9 +79,49 @@ func start_wave():
|
|||||||
print("[EnemySpawner] Starting wave ", current_wave)
|
print("[EnemySpawner] Starting wave ", current_wave)
|
||||||
wave_started.emit(current_wave)
|
wave_started.emit(current_wave)
|
||||||
|
|
||||||
# Spawn enemies
|
# Broadcast wave number to every peer's GameState (for HUD + run stats)
|
||||||
for i in range(enemies_per_wave):
|
GameState.server_sync_wave(current_wave)
|
||||||
_spawn_enemy(i, enemies_per_wave)
|
|
||||||
|
# Boss milestone waves field the Snippest instead of a normal wave
|
||||||
|
if _is_boss_wave(current_wave):
|
||||||
|
print("[EnemySpawner] BOSS WAVE ", current_wave)
|
||||||
|
_spawn_boss()
|
||||||
|
else:
|
||||||
|
var count = _wave_enemy_count(current_wave)
|
||||||
|
for i in range(count):
|
||||||
|
_spawn_enemy(i, count)
|
||||||
|
|
||||||
|
## Every Nth wave, if a boss scene is configured
|
||||||
|
func _is_boss_wave(wave: int) -> bool:
|
||||||
|
return boss_wave_interval > 0 and boss_scene != null and wave % boss_wave_interval == 0
|
||||||
|
|
||||||
|
## Wave size grows linearly, capped
|
||||||
|
func _wave_enemy_count(wave: int) -> int:
|
||||||
|
return mini(enemies_per_wave + int((wave - 1) * wave_count_growth), max_enemies_per_wave)
|
||||||
|
|
||||||
|
func _wave_health_mult(wave: int) -> float:
|
||||||
|
return 1.0 + (wave - 1) * health_growth_per_wave
|
||||||
|
|
||||||
|
func _wave_damage_mult(wave: int) -> float:
|
||||||
|
return 1.0 + (wave - 1) * damage_growth_per_wave
|
||||||
|
|
||||||
|
## Spawn the boss at a random point on the spawn circle (server only)
|
||||||
|
func _spawn_boss():
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
|
||||||
|
_enemy_id_counter += 1
|
||||||
|
var boss_name = "Boss_" + str(current_wave) + "_" + str(_enemy_id_counter)
|
||||||
|
|
||||||
|
var angle = randf() * TAU
|
||||||
|
var spawn_pos = global_position + Vector3(
|
||||||
|
cos(angle) * spawn_radius,
|
||||||
|
spawn_height,
|
||||||
|
sin(angle) * spawn_radius
|
||||||
|
)
|
||||||
|
|
||||||
|
rpc("_spawn_enemy_on_client", boss_name, spawn_pos, boss_scene.resource_path,
|
||||||
|
_wave_health_mult(current_wave), _wave_damage_mult(current_wave))
|
||||||
|
|
||||||
## Spawn a single enemy at a position around the circle
|
## Spawn a single enemy at a position around the circle
|
||||||
func _spawn_enemy(index: int, total: int):
|
func _spawn_enemy(index: int, total: int):
|
||||||
@@ -105,11 +153,13 @@ func _spawn_enemy(index: int, total: int):
|
|||||||
|
|
||||||
# Spawn on all clients via RPC (call_local will spawn on server too)
|
# Spawn on all clients via RPC (call_local will spawn on server too)
|
||||||
var enemy_scene_path = enemy_scene.resource_path
|
var enemy_scene_path = enemy_scene.resource_path
|
||||||
rpc("_spawn_enemy_on_client", enemy_name, spawn_pos, enemy_scene_path)
|
rpc("_spawn_enemy_on_client", enemy_name, spawn_pos, enemy_scene_path,
|
||||||
|
_wave_health_mult(current_wave), _wave_damage_mult(current_wave))
|
||||||
|
|
||||||
## RPC to spawn enemy on all clients (including server via call_local)
|
## RPC to spawn enemy on all clients (including server via call_local)
|
||||||
@rpc("any_peer", "call_local", "reliable")
|
@rpc("any_peer", "call_local", "reliable")
|
||||||
func _spawn_enemy_on_client(enemy_name: String, spawn_pos: Vector3, scene_path: String):
|
func _spawn_enemy_on_client(enemy_name: String, spawn_pos: Vector3, scene_path: String,
|
||||||
|
health_mult: float = 1.0, damage_mult: float = 1.0):
|
||||||
# Load the enemy scene
|
# Load the enemy scene
|
||||||
var enemy_scene = load(scene_path)
|
var enemy_scene = load(scene_path)
|
||||||
if not enemy_scene:
|
if not enemy_scene:
|
||||||
@@ -121,6 +171,14 @@ func _spawn_enemy_on_client(enemy_name: String, spawn_pos: Vector3, scene_path:
|
|||||||
enemy.name = enemy_name
|
enemy.name = enemy_name
|
||||||
enemy.position = spawn_pos
|
enemy.position = spawn_pos
|
||||||
|
|
||||||
|
# Wave escalation - set BEFORE add_child so _ready picks the values up
|
||||||
|
# (current_health = max_health in BaseUnit, hitbox set_stats in BasicEnemy).
|
||||||
|
# Applied identically on every peer; the boss's own 5x stacks on top.
|
||||||
|
if health_mult != 1.0 and "max_health" in enemy:
|
||||||
|
enemy.max_health *= health_mult
|
||||||
|
if damage_mult != 1.0 and "attack_damage" in enemy:
|
||||||
|
enemy.attack_damage *= damage_mult
|
||||||
|
|
||||||
# Find enemies container
|
# Find enemies container
|
||||||
var level = get_tree().get_current_scene()
|
var level = get_tree().get_current_scene()
|
||||||
var enemies_container = null
|
var enemies_container = null
|
||||||
@@ -152,6 +210,10 @@ func _on_enemy_died(killer_id: int, enemy: Node):
|
|||||||
|
|
||||||
print("[EnemySpawner] Enemy ", enemy.name, " defeated by ", killer_id)
|
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
|
# Will be cleaned up in _update_active_enemies
|
||||||
|
|
||||||
## Update list of active enemies and check if wave complete
|
## Update list of active enemies and check if wave complete
|
||||||
@@ -177,6 +239,11 @@ func _on_wave_completed():
|
|||||||
print("[EnemySpawner] Wave ", current_wave, " completed!")
|
print("[EnemySpawner] Wave ", current_wave, " completed!")
|
||||||
wave_completed.emit(current_wave)
|
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
|
# Clean up dead enemies after a delay
|
||||||
await get_tree().create_timer(2.0).timeout
|
await get_tree().create_timer(2.0).timeout
|
||||||
_cleanup_dead_enemies()
|
_cleanup_dead_enemies()
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
|
func _notification(what: int) -> void:
|
||||||
|
# Closing the window mid-run counts as walking out alive - bank the winnings
|
||||||
|
if what == NOTIFICATION_WM_CLOSE_REQUEST and run_active:
|
||||||
|
retire_run()
|
||||||
|
|
||||||
|
# --- 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
|
||||||
|
|
||||||
|
## Walk out of the arena alive: bank 100% of run gold, keep all gear.
|
||||||
|
## The reward for retiring instead of dying (death only keeps 10%).
|
||||||
|
func retire_run() -> Dictionary:
|
||||||
|
if not run_active:
|
||||||
|
return last_run_stats
|
||||||
|
run_active = false
|
||||||
|
|
||||||
|
last_run_stats = {
|
||||||
|
"waves": current_wave,
|
||||||
|
"kills": kills_this_run,
|
||||||
|
"time": get_run_time(),
|
||||||
|
"gold_earned": run_gold,
|
||||||
|
"total_value": get_total_value(),
|
||||||
|
"kept": banked_gold + run_gold,
|
||||||
|
"retired": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
banked_gold += run_gold
|
||||||
|
run_gold = 0
|
||||||
|
gold_changed.emit(banked_gold)
|
||||||
|
run_gold_changed.emit(run_gold)
|
||||||
|
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)
|
## Owner entity (used to prevent self-damage and identify attacker)
|
||||||
@export var owner_entity: Node = null
|
@export var owner_entity: Node = null
|
||||||
|
|
||||||
## Global debug visibility toggle (static-like via class name access)
|
## Global debug visibility toggle (press H in-game to toggle)
|
||||||
static var debug_visible: bool = true
|
static var debug_visible: bool = false
|
||||||
|
|
||||||
## Whether hitbox is currently active (only deals damage when active)
|
## Whether hitbox is currently active (only deals damage when active)
|
||||||
var is_active: bool = false
|
var is_active: bool = false
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ class_name HurtBox
|
|||||||
## Attach to any entity that can be damaged (players, enemies, destructibles)
|
## Attach to any entity that can be damaged (players, enemies, destructibles)
|
||||||
## NOTE: This is a passive detection zone - HitBox handles the collision detection
|
## NOTE: This is a passive detection zone - HitBox handles the collision detection
|
||||||
|
|
||||||
## Global debug visibility toggle (static-like via class name access)
|
## Global debug visibility toggle (press H in-game to toggle)
|
||||||
static var debug_visible: bool = true
|
static var debug_visible: bool = false
|
||||||
|
|
||||||
## The entity that owns this hurtbox (should be a BaseUnit or similar)
|
## The entity that owns this hurtbox (should be a BaseUnit or similar)
|
||||||
@export var owner_entity: Node = null
|
@export var owner_entity: Node = null
|
||||||
|
|||||||
+92
-72
@@ -42,6 +42,12 @@ func _ready():
|
|||||||
# Add quick-fill preset buttons
|
# Add quick-fill preset buttons
|
||||||
_create_preset_buttons()
|
_create_preset_buttons()
|
||||||
|
|
||||||
|
# Reshape the menu into the gladiator outfitting screen
|
||||||
|
_setup_outfitting_menu()
|
||||||
|
|
||||||
|
# Clients: if the host closes the arena, bank winnings and return to camp
|
||||||
|
Network.server_disconnected.connect(_on_server_closed)
|
||||||
|
|
||||||
# Create or find weapons container
|
# Create or find weapons container
|
||||||
if has_node("WeaponsContainer"):
|
if has_node("WeaponsContainer"):
|
||||||
weapons_container = get_node("WeaponsContainer")
|
weapons_container = get_node("WeaponsContainer")
|
||||||
@@ -78,6 +84,19 @@ func _on_connected_to_server():
|
|||||||
print("[Level] Connected to server! Cleaning up manual weapons")
|
print("[Level] Connected to server! Cleaning up manual weapons")
|
||||||
_cleanup_manual_weapons_on_client()
|
_cleanup_manual_weapons_on_client()
|
||||||
|
|
||||||
|
## The host closed the arena while we were connected (client side).
|
||||||
|
## Leaving alive counts as retiring - bank everything, then back to camp.
|
||||||
|
func _on_server_closed():
|
||||||
|
print("[Level] Server disconnected - returning to camp")
|
||||||
|
if GameState.run_active:
|
||||||
|
GameState.retire_run()
|
||||||
|
|
||||||
|
if has_node("/root/HUD"):
|
||||||
|
get_node("/root/HUD").reset()
|
||||||
|
|
||||||
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
|
get_tree().reload_current_scene()
|
||||||
|
|
||||||
## Called by Network when multiplayer peer is set up
|
## Called by Network when multiplayer peer is set up
|
||||||
func initialize_multiplayer():
|
func initialize_multiplayer():
|
||||||
print("[Level] initialize_multiplayer called. is_server: ", multiplayer.is_server())
|
print("[Level] initialize_multiplayer called. is_server: ", multiplayer.is_server())
|
||||||
@@ -89,16 +108,17 @@ func initialize_multiplayer():
|
|||||||
|
|
||||||
_multiplayer_initialized = true
|
_multiplayer_initialized = true
|
||||||
|
|
||||||
|
# Begin this player's arena run (starts the survival clock + gold tracking)
|
||||||
|
GameState.start_run()
|
||||||
|
|
||||||
if multiplayer.is_server():
|
if multiplayer.is_server():
|
||||||
print("[Level] Running server initialization")
|
print("[Level] Running server initialization")
|
||||||
Network.connect("player_connected", Callable(self, "_on_player_connected"))
|
Network.connect("player_connected", Callable(self, "_on_player_connected"))
|
||||||
multiplayer.peer_disconnected.connect(_remove_player)
|
multiplayer.peer_disconnected.connect(_remove_player)
|
||||||
|
|
||||||
# Initialize any manually placed weapons in the scene
|
# Remove any manually placed weapons - free floor loot undermines the shop.
|
||||||
_initialize_manual_weapons()
|
# Weapons only enter the arena via the armory or dropped by slain enemies.
|
||||||
|
_remove_manual_weapons_on_server()
|
||||||
# Spawn initial weapons when server starts
|
|
||||||
_spawn_initial_weapons()
|
|
||||||
|
|
||||||
# Spawn practice dummies
|
# Spawn practice dummies
|
||||||
_spawn_practice_dummies()
|
_spawn_practice_dummies()
|
||||||
@@ -148,81 +168,25 @@ func _cleanup_manual_weapons_on_client():
|
|||||||
weapons_container.remove_child(weapon)
|
weapons_container.remove_child(weapon)
|
||||||
weapon.free()
|
weapon.free()
|
||||||
|
|
||||||
func _initialize_manual_weapons():
|
func _remove_manual_weapons_on_server():
|
||||||
"""Initialize any WorldWeapon nodes manually placed in the level scene"""
|
"""Remove WorldWeapon nodes manually placed in level.tscn (server side).
|
||||||
|
Clients already remove their local copies in _cleanup_manual_weapons_on_client.
|
||||||
|
The arena economy requires gear to be bought or looted from slain enemies."""
|
||||||
if not multiplayer.is_server():
|
if not multiplayer.is_server():
|
||||||
return
|
return
|
||||||
|
|
||||||
if not weapons_container:
|
if not weapons_container:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Find all WorldWeapon nodes in the weapons container
|
var removed = 0
|
||||||
var manual_weapons = []
|
|
||||||
for child in weapons_container.get_children():
|
for child in weapons_container.get_children():
|
||||||
if child is WorldWeapon:
|
if child is WorldWeapon and child.weapon_id == -1:
|
||||||
manual_weapons.append(child)
|
weapons_container.remove_child(child)
|
||||||
|
child.free()
|
||||||
|
removed += 1
|
||||||
|
|
||||||
if manual_weapons.is_empty():
|
if removed > 0:
|
||||||
print("[Server] No manually placed weapons found")
|
print("[Server] Removed ", removed, " manually placed floor weapon(s)")
|
||||||
return
|
|
||||||
|
|
||||||
print("[Server] Found ", manual_weapons.size(), " manually placed weapon(s)")
|
|
||||||
|
|
||||||
# Initialize each manually placed weapon
|
|
||||||
for weapon in manual_weapons:
|
|
||||||
# Skip if already initialized (weapon_id != -1)
|
|
||||||
if weapon.weapon_id != -1:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Assign unique ID
|
|
||||||
_weapon_spawn_counter += 1
|
|
||||||
weapon.weapon_id = _weapon_spawn_counter
|
|
||||||
|
|
||||||
# Set deterministic name for networking
|
|
||||||
var old_name = weapon.name
|
|
||||||
weapon.name = "WorldWeapon_" + str(weapon.weapon_id)
|
|
||||||
|
|
||||||
# Track in active weapons
|
|
||||||
_active_weapons[weapon.weapon_id] = weapon
|
|
||||||
|
|
||||||
# Connect cleanup signal
|
|
||||||
weapon.tree_exiting.connect(_on_weapon_removed.bind(weapon.weapon_id))
|
|
||||||
|
|
||||||
print("[Server] Initialized manual weapon '", old_name, "' with ID: ", weapon.weapon_id, " at position: ", weapon.global_position)
|
|
||||||
|
|
||||||
# Verify weapon_data is set
|
|
||||||
if not weapon.weapon_data:
|
|
||||||
push_error("Manual weapon '", old_name, "' has no WeaponData assigned!")
|
|
||||||
continue
|
|
||||||
|
|
||||||
func _spawn_initial_weapons():
|
|
||||||
if not multiplayer.is_server():
|
|
||||||
return
|
|
||||||
|
|
||||||
# Wait a frame for everything to be ready
|
|
||||||
await get_tree().process_frame
|
|
||||||
|
|
||||||
print("[Server] _spawn_initial_weapons - Connected peers: ", multiplayer.get_peers())
|
|
||||||
|
|
||||||
# Spawn a sword
|
|
||||||
_weapon_spawn_counter += 1
|
|
||||||
print("[Server] Calling RPC to spawn sword with ID: ", _weapon_spawn_counter)
|
|
||||||
rpc("spawn_world_weapon",
|
|
||||||
"res://level/resources/weapon_sword.tres",
|
|
||||||
Vector3(5, 1, 0),
|
|
||||||
Vector3.ZERO,
|
|
||||||
_weapon_spawn_counter
|
|
||||||
)
|
|
||||||
|
|
||||||
# Spawn a shield
|
|
||||||
_weapon_spawn_counter += 1
|
|
||||||
print("[Server] Calling RPC to spawn shield with ID: ", _weapon_spawn_counter)
|
|
||||||
rpc("spawn_world_weapon",
|
|
||||||
"res://level/resources/weapon_shield.tres",
|
|
||||||
Vector3(-5, 1, 0),
|
|
||||||
Vector3.ZERO,
|
|
||||||
_weapon_spawn_counter
|
|
||||||
)
|
|
||||||
|
|
||||||
func _on_player_connected(peer_id, player_info):
|
func _on_player_connected(peer_id, player_info):
|
||||||
print("[Server] _on_player_connected called for peer ", peer_id, " with info: ", player_info)
|
print("[Server] _on_player_connected called for peer ", peer_id, " with info: ", player_info)
|
||||||
@@ -287,6 +251,9 @@ func _on_player_connected(peer_id, player_info):
|
|||||||
for orb_id in _active_orbs.keys():
|
for orb_id in _active_orbs.keys():
|
||||||
rpc_id(peer_id, "_spawn_health_orb_local", orb_id, _active_orbs[orb_id])
|
rpc_id(peer_id, "_spawn_health_orb_local", orb_id, _active_orbs[orb_id])
|
||||||
|
|
||||||
|
# Sync the current wave number so the late joiner's HUD isn't stuck on "Prepare..."
|
||||||
|
GameState.rpc_id(peer_id, "sync_wave", GameState.current_wave)
|
||||||
|
|
||||||
# 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():
|
||||||
@@ -377,6 +344,13 @@ func _spawn_player_local(id: int, spawn_pos: Vector3):
|
|||||||
else:
|
else:
|
||||||
push_warning("[Level] HUD autoload not found! Make sure to restart Godot to register the new autoload.")
|
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"]
|
var skin_enum = player_info["skin"]
|
||||||
player.set_player_skin(skin_enum)
|
player.set_player_skin(skin_enum)
|
||||||
# rpc("sync_player_skin", id, skin_enum)
|
# rpc("sync_player_skin", id, skin_enum)
|
||||||
@@ -470,6 +444,40 @@ func _on_send_pressed() -> void:
|
|||||||
func msg_rpc(nick, msg):
|
func msg_rpc(nick, msg):
|
||||||
chat.text += str(nick, " : ", msg, "\n")
|
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 ----------
|
# ---------- PRESET BUTTONS ----------
|
||||||
func _create_preset_buttons():
|
func _create_preset_buttons():
|
||||||
# Create a container for preset buttons
|
# Create a container for preset buttons
|
||||||
@@ -795,6 +803,18 @@ func _spawn_armed_enemy_local(enemy_name: String, spawn_pos: Vector3, main_weapo
|
|||||||
enemies_container.add_child(enemy, true)
|
enemies_container.add_child(enemy, true)
|
||||||
print("[Peer ", multiplayer.get_unique_id(), "] Armed enemy ", enemy_name, " spawned successfully")
|
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
|
## Spawn initial armed enemies when server starts
|
||||||
func _spawn_armed_enemies():
|
func _spawn_armed_enemies():
|
||||||
if not multiplayer.is_server():
|
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)
|
var new_rotation_y = lerp_angle(rotation.y, atan2(_velocity.x, _velocity.z), LERP_VELOCITY)
|
||||||
rotation.y = new_rotation_y
|
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:
|
func animate(_velocity: Vector3) -> void:
|
||||||
if not animation_player:
|
if not animation_player:
|
||||||
return
|
return
|
||||||
|
|||||||
+53
-5
@@ -59,6 +59,9 @@ var _dash_cooldown_timer: float = 0.0
|
|||||||
var _is_dashing: bool = false
|
var _is_dashing: bool = false
|
||||||
var _dash_direction: Vector3 = Vector3.ZERO
|
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
|
# UI Signals
|
||||||
signal dash_cooldown_updated(remaining: float, total: float)
|
signal dash_cooldown_updated(remaining: float, total: float)
|
||||||
signal attack_cooldown_updated(remaining: float, total: float)
|
signal attack_cooldown_updated(remaining: float, total: float)
|
||||||
@@ -74,6 +77,9 @@ func _ready():
|
|||||||
health_regen = 3.0
|
health_regen = 3.0
|
||||||
regen_delay = 5.0
|
regen_delay = 5.0
|
||||||
|
|
||||||
|
# Gladiator arena: death ends your run - no respawning
|
||||||
|
can_respawn = false
|
||||||
|
|
||||||
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
|
||||||
@@ -240,6 +246,10 @@ func _process(delta):
|
|||||||
_attack_timer -= delta
|
_attack_timer -= delta
|
||||||
attack_cooldown_updated.emit(_attack_timer, attack_cooldown)
|
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
|
# Update dash timers
|
||||||
if _dash_timer > 0:
|
if _dash_timer > 0:
|
||||||
_dash_timer -= delta
|
_dash_timer -= delta
|
||||||
@@ -295,12 +305,10 @@ func freeze():
|
|||||||
_body.animate(Vector3.ZERO)
|
_body.animate(Vector3.ZERO)
|
||||||
|
|
||||||
func _move() -> void:
|
func _move() -> void:
|
||||||
# If dashing, use dash movement
|
# If dashing, use dash movement (facing already snapped and locked at dash start)
|
||||||
if _is_dashing:
|
if _is_dashing:
|
||||||
velocity.x = _dash_direction.x * _current_speed * dash_speed_multiplier
|
velocity.x = _dash_direction.x * _current_speed * dash_speed_multiplier
|
||||||
velocity.z = _dash_direction.z * _current_speed * dash_speed_multiplier
|
velocity.z = _dash_direction.z * _current_speed * dash_speed_multiplier
|
||||||
if _body:
|
|
||||||
_body.apply_rotation(velocity)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
var _input_direction: Vector2 = Vector2.ZERO
|
var _input_direction: Vector2 = Vector2.ZERO
|
||||||
@@ -319,13 +327,35 @@ func _move() -> void:
|
|||||||
if _direction:
|
if _direction:
|
||||||
velocity.x = _direction.x * _current_speed
|
velocity.x = _direction.x * _current_speed
|
||||||
velocity.z = _direction.z * _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)
|
_body.apply_rotation(velocity)
|
||||||
return
|
return
|
||||||
|
|
||||||
velocity.x = move_toward(velocity.x, 0, _current_speed)
|
velocity.x = move_toward(velocity.x, 0, _current_speed)
|
||||||
velocity.z = move_toward(velocity.z, 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:
|
func is_running() -> bool:
|
||||||
if Input.is_action_pressed("shift"):
|
if Input.is_action_pressed("shift"):
|
||||||
_current_speed = SPRINT_SPEED
|
_current_speed = SPRINT_SPEED
|
||||||
@@ -423,11 +453,14 @@ func _perform_attack():
|
|||||||
|
|
||||||
# Use main hand weapon if available
|
# Use main hand weapon if available
|
||||||
if equipped_weapon and equipped_weapon.can_attack():
|
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()
|
equipped_weapon.perform_attack()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Or use off-hand weapon if available
|
# Or use off-hand weapon if available
|
||||||
if equipped_offhand and equipped_offhand.can_attack():
|
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()
|
equipped_offhand.perform_attack()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -445,6 +478,9 @@ func _perform_attack():
|
|||||||
_attack_timer = cooldown
|
_attack_timer = cooldown
|
||||||
_is_unarmed_attacking = true
|
_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
|
# Play attack animation once
|
||||||
if _body:
|
if _body:
|
||||||
_body.play_attack("Attack_OneHand")
|
_body.play_attack("Attack_OneHand")
|
||||||
@@ -517,7 +553,7 @@ func _on_died(killer_id: int):
|
|||||||
|
|
||||||
# Show death message on UI
|
# Show death message on UI
|
||||||
if has_node("HealthUI/HealthText"):
|
if has_node("HealthUI/HealthText"):
|
||||||
get_node("HealthUI/HealthText").text = "DEAD - Respawning..."
|
get_node("HealthUI/HealthText").text = "DEAD - Run Over"
|
||||||
|
|
||||||
func _on_respawned():
|
func _on_respawned():
|
||||||
print("[Player ", name, "] _on_respawned called. Authority: ", is_multiplayer_authority(), " Position: ", global_position)
|
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_timer = dash_duration
|
||||||
_dash_cooldown_timer = dash_cooldown
|
_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)
|
# Animation is handled by the Body's animate function (Jump animation plays during dash)
|
||||||
|
|
||||||
## Sync attack animation to all clients
|
## Sync attack animation to all clients
|
||||||
@@ -715,6 +754,15 @@ func equip_weapon(data: WeaponData):
|
|||||||
# Determine which hand based on weapon type
|
# Determine which hand based on weapon type
|
||||||
var is_offhand = (data.hand_type == WeaponData.Hand.OFF_HAND)
|
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
|
# Unequip current weapon in that hand first
|
||||||
if is_offhand:
|
if is_offhand:
|
||||||
if equipped_offhand:
|
if equipped_offhand:
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ enum Hand { MAIN_HAND, OFF_HAND, TWO_HAND }
|
|||||||
@export_multiline var description: String = ""
|
@export_multiline var description: String = ""
|
||||||
@export var hand_type: Hand = Hand.MAIN_HAND
|
@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_category("Combat Stats")
|
||||||
@export var damage: float = 10.0
|
@export var damage: float = 10.0
|
||||||
@export var attack_range: float = 3.0
|
@export var attack_range: float = 3.0
|
||||||
|
|||||||
@@ -11,15 +11,40 @@ class_name CharacterSheet
|
|||||||
# Player reference
|
# Player reference
|
||||||
var player: Character = null
|
var player: Character = null
|
||||||
|
|
||||||
|
# Preview mode: renders from a loadout dictionary instead of a live player.
|
||||||
|
# Used by the outfitting screen at character creation.
|
||||||
|
# preview_data keys: "nick" (String), "main" (WeaponData or null), "offhand" (WeaponData or null)
|
||||||
|
var preview_mode: bool = false
|
||||||
|
var preview_data: Dictionary = {}
|
||||||
|
|
||||||
|
# Character defaults shown in preview (match player.gd export defaults)
|
||||||
|
const PREVIEW_MAX_HEALTH := 100.0
|
||||||
|
const PREVIEW_ATTACK_DAMAGE := 10.0
|
||||||
|
const PREVIEW_ATTACK_RANGE := 3.0
|
||||||
|
const PREVIEW_ATTACK_COOLDOWN := 0.5
|
||||||
|
const PREVIEW_DASH_COOLDOWN := 4.0
|
||||||
|
const PREVIEW_DASH_DURATION := 0.25
|
||||||
|
const PREVIEW_DASH_MULTIPLIER := 2.0
|
||||||
|
|
||||||
# Visibility
|
# Visibility
|
||||||
var is_visible: bool = false
|
var is_visible: bool = false
|
||||||
|
|
||||||
func _ready():
|
func _ready():
|
||||||
# Start hidden
|
if preview_mode:
|
||||||
hide()
|
_configure_preview_layout()
|
||||||
is_visible = false
|
show()
|
||||||
|
is_visible = true
|
||||||
|
refresh_all()
|
||||||
|
else:
|
||||||
|
# Start hidden (in-game Tab toggle behavior)
|
||||||
|
hide()
|
||||||
|
is_visible = false
|
||||||
|
|
||||||
func _input(event):
|
func _input(event):
|
||||||
|
# Embedded preview never toggles - it's part of the outfitting screen
|
||||||
|
if preview_mode:
|
||||||
|
return
|
||||||
|
|
||||||
# Close on Escape press if open
|
# Close on Escape press if open
|
||||||
if event.is_action_pressed("quit") and is_visible:
|
if event.is_action_pressed("quit") and is_visible:
|
||||||
toggle_sheet()
|
toggle_sheet()
|
||||||
@@ -37,6 +62,35 @@ func set_player(p: Character):
|
|||||||
if player:
|
if player:
|
||||||
refresh_all()
|
refresh_all()
|
||||||
|
|
||||||
|
## Feed the sheet a loadout to preview (outfitting screen).
|
||||||
|
## Safe to call before or after the sheet enters the tree.
|
||||||
|
func set_preview(data: Dictionary):
|
||||||
|
preview_mode = true
|
||||||
|
preview_data = data
|
||||||
|
if is_inside_tree():
|
||||||
|
refresh_all()
|
||||||
|
|
||||||
|
## Re-shape the fullscreen overlay into an embeddable panel:
|
||||||
|
## no dark backdrop, panel fills the space its parent gives it.
|
||||||
|
func _configure_preview_layout():
|
||||||
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
var backdrop = get_node_or_null("DarkBackground")
|
||||||
|
if backdrop:
|
||||||
|
backdrop.hide()
|
||||||
|
var panel = get_node_or_null("Panel")
|
||||||
|
if panel:
|
||||||
|
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
panel.offset_left = 0
|
||||||
|
panel.offset_top = 0
|
||||||
|
panel.offset_right = 0
|
||||||
|
panel.offset_bottom = 0
|
||||||
|
var title = get_node_or_null("Panel/MarginContainer/VBoxContainer/TitleLabel")
|
||||||
|
if title:
|
||||||
|
title.text = "YOUR GLADIATOR"
|
||||||
|
var close_hint = get_node_or_null("Panel/MarginContainer/VBoxContainer/CloseHint")
|
||||||
|
if close_hint:
|
||||||
|
close_hint.hide()
|
||||||
|
|
||||||
## Toggle character sheet visibility
|
## Toggle character sheet visibility
|
||||||
func toggle_sheet():
|
func toggle_sheet():
|
||||||
is_visible = !is_visible
|
is_visible = !is_visible
|
||||||
@@ -53,7 +107,7 @@ func toggle_sheet():
|
|||||||
|
|
||||||
## Refresh all data in the sheet
|
## Refresh all data in the sheet
|
||||||
func refresh_all():
|
func refresh_all():
|
||||||
if not player:
|
if not player and not preview_mode:
|
||||||
return
|
return
|
||||||
|
|
||||||
_refresh_stats()
|
_refresh_stats()
|
||||||
@@ -81,11 +135,40 @@ func _refresh_stats():
|
|||||||
spacer1.custom_minimum_size = Vector2(0, 10)
|
spacer1.custom_minimum_size = Vector2(0, 10)
|
||||||
stats_container.add_child(spacer1)
|
stats_container.add_child(spacer1)
|
||||||
|
|
||||||
# Get player name
|
# Resolve stats from either the live player or the preview loadout
|
||||||
var player_id = player.name.to_int()
|
var player_name: String
|
||||||
var player_name = "Player"
|
var current_hp: int
|
||||||
if Network.players.has(player_id):
|
var max_hp: int
|
||||||
player_name = Network.players[player_id]["nick"]
|
var atk_damage: float
|
||||||
|
var atk_range: float
|
||||||
|
var atk_cooldown: float
|
||||||
|
|
||||||
|
if preview_mode:
|
||||||
|
player_name = preview_data.get("nick", "Gladiator")
|
||||||
|
if player_name.strip_edges() == "":
|
||||||
|
player_name = "Gladiator"
|
||||||
|
current_hp = int(PREVIEW_MAX_HEALTH)
|
||||||
|
max_hp = int(PREVIEW_MAX_HEALTH)
|
||||||
|
# Combat stats come from the selected main weapon, or bare fists
|
||||||
|
var main_weapon = preview_data.get("main")
|
||||||
|
if main_weapon is WeaponData:
|
||||||
|
atk_damage = main_weapon.damage
|
||||||
|
atk_range = main_weapon.attack_range
|
||||||
|
atk_cooldown = main_weapon.attack_cooldown
|
||||||
|
else:
|
||||||
|
atk_damage = PREVIEW_ATTACK_DAMAGE
|
||||||
|
atk_range = PREVIEW_ATTACK_RANGE
|
||||||
|
atk_cooldown = PREVIEW_ATTACK_COOLDOWN
|
||||||
|
else:
|
||||||
|
var player_id = player.name.to_int()
|
||||||
|
player_name = "Player"
|
||||||
|
if Network.players.has(player_id):
|
||||||
|
player_name = Network.players[player_id]["nick"]
|
||||||
|
current_hp = int(player.current_health)
|
||||||
|
max_hp = int(player.max_health)
|
||||||
|
atk_damage = player.attack_damage
|
||||||
|
atk_range = player.attack_range
|
||||||
|
atk_cooldown = player.attack_cooldown
|
||||||
|
|
||||||
_add_stat_label("Name: " + player_name)
|
_add_stat_label("Name: " + player_name)
|
||||||
_add_stat_label("Level: 1") # Hardcoded for now
|
_add_stat_label("Level: 1") # Hardcoded for now
|
||||||
@@ -93,23 +176,35 @@ func _refresh_stats():
|
|||||||
|
|
||||||
# Health stats
|
# Health stats
|
||||||
_add_stat_label("=== HEALTH ===", Color(0.8, 0.8, 0.8))
|
_add_stat_label("=== HEALTH ===", Color(0.8, 0.8, 0.8))
|
||||||
_add_stat_label("Current HP: " + str(int(player.current_health)))
|
_add_stat_label("Current HP: " + str(current_hp))
|
||||||
_add_stat_label("Max HP: " + str(int(player.max_health)))
|
_add_stat_label("Max HP: " + str(max_hp))
|
||||||
_add_stat_label("Health: " + str(int(player.get_health_percent() * 100)) + "%")
|
var hp_percent = int(float(current_hp) / max_hp * 100) if max_hp > 0 else 0
|
||||||
|
_add_stat_label("Health: " + str(hp_percent) + "%")
|
||||||
_add_stat_label("")
|
_add_stat_label("")
|
||||||
|
|
||||||
# Movement stats
|
# Movement stats (class constants - same for live and preview)
|
||||||
_add_stat_label("=== MOVEMENT ===", Color(0.8, 0.8, 0.8))
|
_add_stat_label("=== MOVEMENT ===", Color(0.8, 0.8, 0.8))
|
||||||
_add_stat_label("Walk Speed: " + str(player.NORMAL_SPEED))
|
_add_stat_label("Walk Speed: " + str(Character.NORMAL_SPEED))
|
||||||
_add_stat_label("Sprint Speed: " + str(player.SPRINT_SPEED))
|
_add_stat_label("Sprint Speed: " + str(Character.SPRINT_SPEED))
|
||||||
_add_stat_label("Jump Power: " + str(player.JUMP_VELOCITY))
|
_add_stat_label("Jump Power: " + str(Character.JUMP_VELOCITY))
|
||||||
_add_stat_label("")
|
_add_stat_label("")
|
||||||
|
|
||||||
# Combat stats
|
# Combat stats
|
||||||
_add_stat_label("=== COMBAT ===", Color(0.8, 0.8, 0.8))
|
_add_stat_label("=== COMBAT ===", Color(0.8, 0.8, 0.8))
|
||||||
_add_stat_label("Base Damage: " + str(player.attack_damage))
|
if preview_mode and preview_data.get("main") is WeaponData:
|
||||||
_add_stat_label("Attack Range: " + str(player.attack_range))
|
_add_stat_label("Weapon Damage: " + str(atk_damage))
|
||||||
_add_stat_label("Attack Cooldown: " + str(player.attack_cooldown) + "s")
|
else:
|
||||||
|
_add_stat_label("Base Damage: " + str(atk_damage))
|
||||||
|
_add_stat_label("Attack Range: " + str(atk_range))
|
||||||
|
_add_stat_label("Attack Cooldown: " + str(atk_cooldown) + "s")
|
||||||
|
|
||||||
|
# Off-hand block bonus (preview only - handy when choosing a shield)
|
||||||
|
if preview_mode:
|
||||||
|
var offhand = preview_data.get("offhand")
|
||||||
|
if offhand is WeaponData and offhand.can_block:
|
||||||
|
_add_stat_label("")
|
||||||
|
_add_stat_label("=== DEFENSE ===", Color(0.8, 0.8, 0.8))
|
||||||
|
_add_stat_label("Block Reduction: " + str(int(offhand.block_reduction * 100)) + "%")
|
||||||
|
|
||||||
## Refresh equipped weapons
|
## Refresh equipped weapons
|
||||||
func _refresh_weapons():
|
func _refresh_weapons():
|
||||||
@@ -143,12 +238,26 @@ func _refresh_weapons():
|
|||||||
push_error("[CharacterSheet] Failed to load weapon_slot.tscn")
|
push_error("[CharacterSheet] Failed to load weapon_slot.tscn")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Resolve weapon data from preview loadout or live player
|
||||||
|
var main_data: WeaponData = null
|
||||||
|
var offhand_data: WeaponData = null
|
||||||
|
if preview_mode:
|
||||||
|
if preview_data.get("main") is WeaponData:
|
||||||
|
main_data = preview_data["main"]
|
||||||
|
if preview_data.get("offhand") is WeaponData:
|
||||||
|
offhand_data = preview_data["offhand"]
|
||||||
|
else:
|
||||||
|
if player.equipped_weapon and player.equipped_weapon.weapon_data:
|
||||||
|
main_data = player.equipped_weapon.weapon_data
|
||||||
|
if player.equipped_offhand and player.equipped_offhand.weapon_data:
|
||||||
|
offhand_data = player.equipped_offhand.weapon_data
|
||||||
|
|
||||||
# Create main hand slot
|
# Create main hand slot
|
||||||
var main_hand_slot = weapon_slot_scene.instantiate()
|
var main_hand_slot = weapon_slot_scene.instantiate()
|
||||||
slots_container.add_child(main_hand_slot)
|
slots_container.add_child(main_hand_slot)
|
||||||
|
|
||||||
if player.equipped_weapon and player.equipped_weapon.weapon_data:
|
if main_data:
|
||||||
main_hand_slot.set_weapon(player.equipped_weapon.weapon_data, "Main Hand")
|
main_hand_slot.set_weapon(main_data, "Main Hand")
|
||||||
else:
|
else:
|
||||||
main_hand_slot.clear_weapon()
|
main_hand_slot.clear_weapon()
|
||||||
|
|
||||||
@@ -156,8 +265,8 @@ func _refresh_weapons():
|
|||||||
var off_hand_slot = weapon_slot_scene.instantiate()
|
var off_hand_slot = weapon_slot_scene.instantiate()
|
||||||
slots_container.add_child(off_hand_slot)
|
slots_container.add_child(off_hand_slot)
|
||||||
|
|
||||||
if player.equipped_offhand and player.equipped_offhand.weapon_data:
|
if offhand_data:
|
||||||
off_hand_slot.set_weapon(player.equipped_offhand.weapon_data, "Off-Hand")
|
off_hand_slot.set_weapon(offhand_data, "Off-Hand")
|
||||||
else:
|
else:
|
||||||
off_hand_slot.clear_weapon()
|
off_hand_slot.clear_weapon()
|
||||||
|
|
||||||
@@ -182,17 +291,24 @@ func _refresh_abilities():
|
|||||||
spacer.custom_minimum_size = Vector2(0, 10)
|
spacer.custom_minimum_size = Vector2(0, 10)
|
||||||
abilities_container.add_child(spacer)
|
abilities_container.add_child(spacer)
|
||||||
|
|
||||||
# Dash ability
|
# Dash ability (defaults in preview, live values in game)
|
||||||
|
var dash_cd = PREVIEW_DASH_COOLDOWN if preview_mode else player.dash_cooldown
|
||||||
|
var dash_dur = PREVIEW_DASH_DURATION if preview_mode else player.dash_duration
|
||||||
|
var dash_mult = PREVIEW_DASH_MULTIPLIER if preview_mode else player.dash_speed_multiplier
|
||||||
|
|
||||||
_add_stat_label("--- DASH (F) ---", Color(0.8, 0.8, 0.8))
|
_add_stat_label("--- DASH (F) ---", Color(0.8, 0.8, 0.8))
|
||||||
_add_stat_label("Cooldown: " + str(player.dash_cooldown) + "s")
|
_add_stat_label("Cooldown: " + str(dash_cd) + "s")
|
||||||
_add_stat_label("Duration: " + str(player.dash_duration) + "s")
|
_add_stat_label("Duration: " + str(dash_dur) + "s")
|
||||||
_add_stat_label("Speed: " + str(player.dash_speed_multiplier) + "x")
|
_add_stat_label("Speed: " + str(dash_mult) + "x")
|
||||||
_add_stat_label("Description: Dash in movement direction")
|
_add_stat_label("Description: Dash in movement direction")
|
||||||
var dash_remaining = player._dash_cooldown_timer
|
if preview_mode:
|
||||||
if dash_remaining > 0:
|
|
||||||
_add_stat_label("Ready in: " + str(ceil(dash_remaining)) + "s", Color(1.0, 0.5, 0.5))
|
|
||||||
else:
|
|
||||||
_add_stat_label("Status: READY", Color(0.0, 1.0, 0.0))
|
_add_stat_label("Status: READY", Color(0.0, 1.0, 0.0))
|
||||||
|
else:
|
||||||
|
var dash_remaining = player._dash_cooldown_timer
|
||||||
|
if dash_remaining > 0:
|
||||||
|
_add_stat_label("Ready in: " + str(ceil(dash_remaining)) + "s", Color(1.0, 0.5, 0.5))
|
||||||
|
else:
|
||||||
|
_add_stat_label("Status: READY", Color(0.0, 1.0, 0.0))
|
||||||
|
|
||||||
# Spacer
|
# Spacer
|
||||||
var spacer2 = Control.new()
|
var spacer2 = Control.new()
|
||||||
@@ -201,7 +317,7 @@ func _refresh_abilities():
|
|||||||
|
|
||||||
# Jump ability
|
# Jump ability
|
||||||
_add_stat_label("--- JUMP (Space) ---", Color(0.8, 0.8, 0.8))
|
_add_stat_label("--- JUMP (Space) ---", Color(0.8, 0.8, 0.8))
|
||||||
_add_stat_label("Power: " + str(player.JUMP_VELOCITY))
|
_add_stat_label("Power: " + str(Character.JUMP_VELOCITY))
|
||||||
_add_stat_label("Description: Jump into the air")
|
_add_stat_label("Description: Jump into the air")
|
||||||
_add_stat_label("Status: ALWAYS READY", Color(0.0, 1.0, 0.0))
|
_add_stat_label("Status: ALWAYS READY", Color(0.0, 1.0, 0.0))
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,27 @@ var is_visible: bool = false
|
|||||||
# Player reference (to control mouse mode)
|
# Player reference (to control mouse mode)
|
||||||
var player: Character = null
|
var player: Character = null
|
||||||
|
|
||||||
|
var _retire_button: Button = null
|
||||||
|
|
||||||
func _ready():
|
func _ready():
|
||||||
# Start hidden
|
# Start hidden
|
||||||
hide()
|
hide()
|
||||||
is_visible = false
|
is_visible = false
|
||||||
|
|
||||||
|
# Insert "Retire to Camp" between Resume and Exit - leave alive, bank everything
|
||||||
|
var vbox = get_node_or_null("Panel/MarginContainer/VBoxContainer")
|
||||||
|
var exit_button = get_node_or_null("Panel/MarginContainer/VBoxContainer/ExitButton")
|
||||||
|
if vbox and exit_button:
|
||||||
|
_retire_button = Button.new()
|
||||||
|
_retire_button.name = "RetireButton"
|
||||||
|
_retire_button.custom_minimum_size = Vector2(0, 50)
|
||||||
|
_retire_button.add_theme_color_override("font_color", Color.WHITE)
|
||||||
|
_retire_button.add_theme_color_override("font_hover_color", Color(1, 0.8, 0))
|
||||||
|
_retire_button.add_theme_font_size_override("font_size", 20)
|
||||||
|
_retire_button.pressed.connect(_on_retire_pressed)
|
||||||
|
vbox.add_child(_retire_button)
|
||||||
|
vbox.move_child(_retire_button, exit_button.get_index())
|
||||||
|
|
||||||
func _input(event):
|
func _input(event):
|
||||||
# Toggle on Escape press
|
# Toggle on Escape press
|
||||||
if event.is_action_pressed("quit"):
|
if event.is_action_pressed("quit"):
|
||||||
@@ -29,6 +45,7 @@ func toggle_menu():
|
|||||||
is_visible = !is_visible
|
is_visible = !is_visible
|
||||||
if is_visible:
|
if is_visible:
|
||||||
show()
|
show()
|
||||||
|
_update_retire_button()
|
||||||
# Release mouse when opening menu
|
# Release mouse when opening menu
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
else:
|
else:
|
||||||
@@ -37,10 +54,40 @@ func toggle_menu():
|
|||||||
if player and player.is_multiplayer_authority():
|
if player and player.is_multiplayer_authority():
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||||
|
|
||||||
|
## Alive with an active run: retire (bank winnings).
|
||||||
|
## Dead but spectating a live session: plain trip home (settlement already done).
|
||||||
|
func _update_retire_button():
|
||||||
|
if not _retire_button:
|
||||||
|
return
|
||||||
|
if GameState.run_active and player and not player.is_dead:
|
||||||
|
_retire_button.visible = true
|
||||||
|
_retire_button.text = "Retire to Camp (+%dg)" % GameState.run_gold
|
||||||
|
elif multiplayer.multiplayer_peer != null and player and player.is_dead:
|
||||||
|
_retire_button.visible = true
|
||||||
|
_retire_button.text = "Back to Camp"
|
||||||
|
else:
|
||||||
|
_retire_button.visible = false
|
||||||
|
|
||||||
## Called when Resume button is clicked
|
## Called when Resume button is clicked
|
||||||
func _on_resume_pressed():
|
func _on_resume_pressed():
|
||||||
toggle_menu()
|
toggle_menu()
|
||||||
|
|
||||||
|
## Walk out alive: bank all run gold, keep gear, return to the menu
|
||||||
|
func _on_retire_pressed():
|
||||||
|
GameState.retire_run()
|
||||||
|
|
||||||
|
multiplayer.multiplayer_peer = null
|
||||||
|
Network.players.clear()
|
||||||
|
|
||||||
|
if has_node("/root/HUD"):
|
||||||
|
get_node("/root/HUD").reset()
|
||||||
|
|
||||||
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
|
get_tree().reload_current_scene()
|
||||||
|
|
||||||
## Called when Exit button is clicked
|
## Called when Exit button is clicked
|
||||||
func _on_exit_pressed():
|
func _on_exit_pressed():
|
||||||
|
# Leaving alive still banks your winnings (GameState also catches window close)
|
||||||
|
if GameState.run_active and player and not player.is_dead:
|
||||||
|
GameState.retire_run()
|
||||||
get_tree().quit()
|
get_tree().quit()
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ var character_sheet: Control = null
|
|||||||
var tab_hint: Control = null
|
var tab_hint: Control = null
|
||||||
var keybind_hint: Control = null
|
var keybind_hint: Control = null
|
||||||
var escape_menu: Control = null
|
var escape_menu: Control = null
|
||||||
|
var run_results: Control = null
|
||||||
|
var arena_status: Control = null
|
||||||
|
var _wave_label: Label = null
|
||||||
|
var _gold_label: Label = null
|
||||||
|
|
||||||
# Player reference
|
# Player reference
|
||||||
var local_player: Character = null
|
var local_player: Character = null
|
||||||
@@ -80,6 +84,7 @@ func _create_ui_components():
|
|||||||
_create_tab_hint()
|
_create_tab_hint()
|
||||||
_create_keybind_hint()
|
_create_keybind_hint()
|
||||||
_create_escape_menu()
|
_create_escape_menu()
|
||||||
|
_create_arena_status()
|
||||||
|
|
||||||
## Create action bar at bottom of screen
|
## Create action bar at bottom of screen
|
||||||
func _create_action_bar():
|
func _create_action_bar():
|
||||||
@@ -149,6 +154,57 @@ func _create_escape_menu():
|
|||||||
else:
|
else:
|
||||||
push_error("[HUD] Failed to load escape_menu.tscn")
|
push_error("[HUD] Failed to load escape_menu.tscn")
|
||||||
|
|
||||||
|
## Create the arena status readout (wave + run gold, top center)
|
||||||
|
func _create_arena_status():
|
||||||
|
arena_status = PanelContainer.new()
|
||||||
|
arena_status.name = "ArenaStatus"
|
||||||
|
arena_status.set_anchors_preset(Control.PRESET_CENTER_TOP)
|
||||||
|
arena_status.offset_top = 10
|
||||||
|
arena_status.grow_horizontal = Control.GROW_DIRECTION_BOTH
|
||||||
|
add_child(arena_status)
|
||||||
|
|
||||||
|
var margin = MarginContainer.new()
|
||||||
|
margin.add_theme_constant_override("margin_left", 18)
|
||||||
|
margin.add_theme_constant_override("margin_right", 18)
|
||||||
|
margin.add_theme_constant_override("margin_top", 6)
|
||||||
|
margin.add_theme_constant_override("margin_bottom", 6)
|
||||||
|
arena_status.add_child(margin)
|
||||||
|
|
||||||
|
var hbox = HBoxContainer.new()
|
||||||
|
hbox.add_theme_constant_override("separation", 24)
|
||||||
|
margin.add_child(hbox)
|
||||||
|
|
||||||
|
_wave_label = Label.new()
|
||||||
|
_wave_label.add_theme_font_size_override("font_size", 22)
|
||||||
|
_wave_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.0))
|
||||||
|
_wave_label.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||||
|
_wave_label.add_theme_constant_override("outline_size", 2)
|
||||||
|
hbox.add_child(_wave_label)
|
||||||
|
|
||||||
|
_gold_label = Label.new()
|
||||||
|
_gold_label.add_theme_font_size_override("font_size", 22)
|
||||||
|
_gold_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.2))
|
||||||
|
_gold_label.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||||
|
_gold_label.add_theme_constant_override("outline_size", 2)
|
||||||
|
hbox.add_child(_gold_label)
|
||||||
|
|
||||||
|
# Live updates from the economy
|
||||||
|
GameState.wave_changed.connect(_on_wave_changed)
|
||||||
|
GameState.run_gold_changed.connect(_on_run_gold_changed)
|
||||||
|
|
||||||
|
# Seed with current values (run may already be underway)
|
||||||
|
_on_wave_changed(GameState.current_wave)
|
||||||
|
_on_run_gold_changed(GameState.run_gold)
|
||||||
|
print("[HUD] Arena status created")
|
||||||
|
|
||||||
|
func _on_wave_changed(wave: int):
|
||||||
|
if _wave_label:
|
||||||
|
_wave_label.text = "Wave %d" % wave if wave > 0 else "Prepare..."
|
||||||
|
|
||||||
|
func _on_run_gold_changed(gold: int):
|
||||||
|
if _gold_label:
|
||||||
|
_gold_label.text = "Gold +%d" % gold
|
||||||
|
|
||||||
## Show all UI components
|
## Show all UI components
|
||||||
func show_hud():
|
func show_hud():
|
||||||
show()
|
show()
|
||||||
@@ -157,6 +213,36 @@ func show_hud():
|
|||||||
func hide_hud():
|
func hide_hud():
|
||||||
hide()
|
hide()
|
||||||
|
|
||||||
|
## Tear down all UI components (used when returning to the menu after a run).
|
||||||
|
## The HUD is an autoload, so its children survive scene reloads unless freed.
|
||||||
|
func reset():
|
||||||
|
if local_player and is_instance_valid(local_player):
|
||||||
|
_disconnect_player_signals()
|
||||||
|
local_player = null
|
||||||
|
|
||||||
|
# Drop GameState connections so they don't fire on freed labels
|
||||||
|
# (and don't double-connect when the next run creates a new status bar)
|
||||||
|
if GameState.wave_changed.is_connected(_on_wave_changed):
|
||||||
|
GameState.wave_changed.disconnect(_on_wave_changed)
|
||||||
|
if GameState.run_gold_changed.is_connected(_on_run_gold_changed):
|
||||||
|
GameState.run_gold_changed.disconnect(_on_run_gold_changed)
|
||||||
|
|
||||||
|
for child in get_children():
|
||||||
|
child.queue_free()
|
||||||
|
|
||||||
|
action_bar = null
|
||||||
|
unit_frame = null
|
||||||
|
target_frame = null
|
||||||
|
character_sheet = null
|
||||||
|
tab_hint = null
|
||||||
|
keybind_hint = null
|
||||||
|
escape_menu = null
|
||||||
|
run_results = null
|
||||||
|
arena_status = null
|
||||||
|
_wave_label = null
|
||||||
|
_gold_label = null
|
||||||
|
print("[HUD] Reset - all UI components removed")
|
||||||
|
|
||||||
## Player signal callbacks
|
## Player signal callbacks
|
||||||
func _on_player_health_changed(old_health: float, new_health: float):
|
func _on_player_health_changed(old_health: float, new_health: float):
|
||||||
# Update unit frame
|
# Update unit frame
|
||||||
@@ -164,8 +250,21 @@ func _on_player_health_changed(old_health: float, new_health: float):
|
|||||||
unit_frame.update_health(new_health, local_player.max_health)
|
unit_frame.update_health(new_health, local_player.max_health)
|
||||||
|
|
||||||
func _on_player_died(killer_id: int):
|
func _on_player_died(killer_id: int):
|
||||||
print("[HUD] Player died")
|
print("[HUD] Player died - ending run")
|
||||||
# Could show death screen or respawn timer here
|
_show_run_results()
|
||||||
|
|
||||||
|
## End the local player's run and show the results screen.
|
||||||
|
## GameState.end_run() is guarded against double-calls (died can fire twice on host).
|
||||||
|
func _show_run_results():
|
||||||
|
if run_results and is_instance_valid(run_results):
|
||||||
|
return # Already showing
|
||||||
|
|
||||||
|
var stats = GameState.end_run()
|
||||||
|
|
||||||
|
var results_script = load("res://level/ui/scripts/run_results.gd")
|
||||||
|
run_results = results_script.new()
|
||||||
|
run_results.setup(stats)
|
||||||
|
add_child(run_results)
|
||||||
|
|
||||||
func _on_player_respawned():
|
func _on_player_respawned():
|
||||||
print("[HUD] Player respawned")
|
print("[HUD] Player respawned")
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
extends Control
|
||||||
|
class_name OutfittingScreen
|
||||||
|
## Gladiator outfitting screen shown on the main menu.
|
||||||
|
## Left: the armory shop (buy and equip gear with banked gold).
|
||||||
|
## Right: the character sheet in preview mode, live-updating as you change loadout.
|
||||||
|
## Reads/writes GameState (banked_gold, owned_items, selected_loadout).
|
||||||
|
|
||||||
|
const WEAPONS_DIR := "res://level/resources/"
|
||||||
|
|
||||||
|
# Catalog of WeaponData found on disk, sorted by tier then cost
|
||||||
|
var _catalog: Array[WeaponData] = []
|
||||||
|
var _catalog_paths: Dictionary = {} # WeaponData -> resource path
|
||||||
|
|
||||||
|
var _gold_label: Label = null
|
||||||
|
var _items_container: VBoxContainer = null
|
||||||
|
var _sheet: Control = null # CharacterSheet in preview mode
|
||||||
|
var _nick_input: LineEdit = null # menu's nickname field (for preview name)
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
_load_catalog()
|
||||||
|
_build_ui()
|
||||||
|
|
||||||
|
GameState.gold_changed.connect(func(_g): _refresh())
|
||||||
|
GameState.loadout_changed.connect(func(_l): _refresh())
|
||||||
|
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
## The menu's nickname LineEdit, so the preview shows the entered name
|
||||||
|
func set_nick_input(nick_input: LineEdit):
|
||||||
|
_nick_input = nick_input
|
||||||
|
if _nick_input:
|
||||||
|
_nick_input.text_changed.connect(func(_t): _update_preview())
|
||||||
|
|
||||||
|
# --- Catalog --------------------------------------------------------------
|
||||||
|
|
||||||
|
func _load_catalog():
|
||||||
|
_catalog.clear()
|
||||||
|
_catalog_paths.clear()
|
||||||
|
|
||||||
|
var dir = DirAccess.open(WEAPONS_DIR)
|
||||||
|
if not dir:
|
||||||
|
push_error("[Outfitting] Cannot open " + WEAPONS_DIR)
|
||||||
|
return
|
||||||
|
|
||||||
|
for file in dir.get_files():
|
||||||
|
# Exported builds list resources as .tres.remap
|
||||||
|
var file_name = file.trim_suffix(".remap")
|
||||||
|
if not file_name.ends_with(".tres"):
|
||||||
|
continue
|
||||||
|
var path = WEAPONS_DIR + file_name
|
||||||
|
var res = load(path)
|
||||||
|
if res is WeaponData:
|
||||||
|
_catalog.append(res)
|
||||||
|
_catalog_paths[res] = path
|
||||||
|
|
||||||
|
_catalog.sort_custom(func(a, b):
|
||||||
|
if a.tier != b.tier:
|
||||||
|
return a.tier < b.tier
|
||||||
|
return a.cost < b.cost
|
||||||
|
)
|
||||||
|
print("[Outfitting] Loaded ", _catalog.size(), " shop items")
|
||||||
|
|
||||||
|
# --- UI construction --------------------------------------------------------
|
||||||
|
|
||||||
|
func _build_ui():
|
||||||
|
var hbox = HBoxContainer.new()
|
||||||
|
hbox.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
hbox.add_theme_constant_override("separation", 16)
|
||||||
|
add_child(hbox)
|
||||||
|
|
||||||
|
# ----- Shop panel (left) -----
|
||||||
|
var shop_panel = PanelContainer.new()
|
||||||
|
shop_panel.custom_minimum_size = Vector2(420, 0)
|
||||||
|
shop_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
shop_panel.size_flags_stretch_ratio = 0.85
|
||||||
|
hbox.add_child(shop_panel)
|
||||||
|
|
||||||
|
var shop_margin = MarginContainer.new()
|
||||||
|
shop_margin.add_theme_constant_override("margin_left", 16)
|
||||||
|
shop_margin.add_theme_constant_override("margin_right", 16)
|
||||||
|
shop_margin.add_theme_constant_override("margin_top", 12)
|
||||||
|
shop_margin.add_theme_constant_override("margin_bottom", 12)
|
||||||
|
shop_panel.add_child(shop_margin)
|
||||||
|
|
||||||
|
var shop_vbox = VBoxContainer.new()
|
||||||
|
shop_vbox.add_theme_constant_override("separation", 8)
|
||||||
|
shop_margin.add_child(shop_vbox)
|
||||||
|
|
||||||
|
var shop_title = Label.new()
|
||||||
|
shop_title.text = "THE ARMORY"
|
||||||
|
shop_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
shop_title.add_theme_font_size_override("font_size", 26)
|
||||||
|
shop_title.add_theme_color_override("font_color", Color(1.0, 0.8, 0.0))
|
||||||
|
shop_vbox.add_child(shop_title)
|
||||||
|
|
||||||
|
_gold_label = Label.new()
|
||||||
|
_gold_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_gold_label.add_theme_font_size_override("font_size", 20)
|
||||||
|
_gold_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.2))
|
||||||
|
shop_vbox.add_child(_gold_label)
|
||||||
|
|
||||||
|
var hint = Label.new()
|
||||||
|
hint.text = "Buy gear, equip it, then enter the arena.\nDie and the arena takes 90% of everything you're worth."
|
||||||
|
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
hint.add_theme_font_size_override("font_size", 13)
|
||||||
|
hint.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
|
||||||
|
shop_vbox.add_child(hint)
|
||||||
|
|
||||||
|
shop_vbox.add_child(HSeparator.new())
|
||||||
|
|
||||||
|
var scroll = ScrollContainer.new()
|
||||||
|
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||||
|
shop_vbox.add_child(scroll)
|
||||||
|
|
||||||
|
_items_container = VBoxContainer.new()
|
||||||
|
_items_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_items_container.add_theme_constant_override("separation", 6)
|
||||||
|
scroll.add_child(_items_container)
|
||||||
|
|
||||||
|
# ----- Character sheet preview (right) -----
|
||||||
|
var sheet_scene = load("res://level/ui/scenes/character_sheet.tscn")
|
||||||
|
if sheet_scene:
|
||||||
|
_sheet = sheet_scene.instantiate()
|
||||||
|
_sheet.preview_mode = true # Must be set before _ready runs
|
||||||
|
_sheet.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_sheet.size_flags_stretch_ratio = 1.0
|
||||||
|
_sheet.custom_minimum_size = Vector2(480, 0)
|
||||||
|
hbox.add_child(_sheet)
|
||||||
|
else:
|
||||||
|
push_error("[Outfitting] Failed to load character_sheet.tscn")
|
||||||
|
|
||||||
|
# --- Refresh ---------------------------------------------------------------
|
||||||
|
|
||||||
|
func _refresh():
|
||||||
|
if _gold_label:
|
||||||
|
_gold_label.text = "Gold: " + str(GameState.banked_gold)
|
||||||
|
_rebuild_items()
|
||||||
|
_update_preview()
|
||||||
|
|
||||||
|
func _rebuild_items():
|
||||||
|
if not _items_container:
|
||||||
|
return
|
||||||
|
for child in _items_container.get_children():
|
||||||
|
child.queue_free()
|
||||||
|
|
||||||
|
for weapon in _catalog:
|
||||||
|
_items_container.add_child(_make_item_row(weapon))
|
||||||
|
|
||||||
|
func _make_item_row(weapon: WeaponData) -> Control:
|
||||||
|
var path: String = _catalog_paths[weapon]
|
||||||
|
var owned := GameState.owns_item(path)
|
||||||
|
var selected := GameState.is_loadout_item_selected(path)
|
||||||
|
|
||||||
|
var row = PanelContainer.new()
|
||||||
|
var margin = MarginContainer.new()
|
||||||
|
margin.add_theme_constant_override("margin_left", 8)
|
||||||
|
margin.add_theme_constant_override("margin_right", 8)
|
||||||
|
margin.add_theme_constant_override("margin_top", 6)
|
||||||
|
margin.add_theme_constant_override("margin_bottom", 6)
|
||||||
|
row.add_child(margin)
|
||||||
|
|
||||||
|
var hbox = HBoxContainer.new()
|
||||||
|
hbox.add_theme_constant_override("separation", 10)
|
||||||
|
margin.add_child(hbox)
|
||||||
|
|
||||||
|
# Name + stat line
|
||||||
|
var info = VBoxContainer.new()
|
||||||
|
info.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
hbox.add_child(info)
|
||||||
|
|
||||||
|
var name_label = Label.new()
|
||||||
|
name_label.text = weapon.weapon_name + (" [" + _hand_label(weapon) + "]")
|
||||||
|
name_label.add_theme_font_size_override("font_size", 16)
|
||||||
|
name_label.add_theme_color_override("font_color",
|
||||||
|
Color(0.4, 1.0, 0.4) if selected else (Color(0.85, 0.85, 1.0) if owned else Color.WHITE))
|
||||||
|
info.add_child(name_label)
|
||||||
|
|
||||||
|
var stats_label = Label.new()
|
||||||
|
var stat_bits: Array[String] = ["DMG " + str(weapon.damage), "CD " + str(weapon.attack_cooldown) + "s"]
|
||||||
|
if weapon.can_block:
|
||||||
|
stat_bits.append("Block " + str(int(weapon.block_reduction * 100)) + "%")
|
||||||
|
stats_label.text = " | ".join(stat_bits)
|
||||||
|
stats_label.add_theme_font_size_override("font_size", 12)
|
||||||
|
stats_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
|
||||||
|
info.add_child(stats_label)
|
||||||
|
|
||||||
|
# Action button
|
||||||
|
var button = Button.new()
|
||||||
|
button.custom_minimum_size = Vector2(120, 36)
|
||||||
|
if not owned:
|
||||||
|
button.text = str(weapon.cost) + "g Buy"
|
||||||
|
button.disabled = not GameState.can_afford(weapon.cost)
|
||||||
|
button.pressed.connect(_on_buy_pressed.bind(path, weapon.cost))
|
||||||
|
elif selected:
|
||||||
|
button.text = "Equipped"
|
||||||
|
button.pressed.connect(_on_unequip_pressed.bind(weapon))
|
||||||
|
else:
|
||||||
|
button.text = "Equip"
|
||||||
|
button.pressed.connect(_on_equip_pressed.bind(path))
|
||||||
|
hbox.add_child(button)
|
||||||
|
|
||||||
|
return row
|
||||||
|
|
||||||
|
func _hand_label(weapon: WeaponData) -> String:
|
||||||
|
match weapon.hand_type:
|
||||||
|
WeaponData.Hand.OFF_HAND: return "Off-Hand"
|
||||||
|
WeaponData.Hand.TWO_HAND: return "Two-Hand"
|
||||||
|
_: return "Main Hand"
|
||||||
|
|
||||||
|
# --- Actions ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func _on_buy_pressed(path: String, cost: int):
|
||||||
|
if GameState.buy_item(path, cost):
|
||||||
|
# Auto-equip fresh purchases - you bought it to use it
|
||||||
|
GameState.equip_loadout_item(path)
|
||||||
|
# _refresh happens via gold_changed/loadout_changed signals
|
||||||
|
|
||||||
|
func _on_equip_pressed(path: String):
|
||||||
|
GameState.equip_loadout_item(path)
|
||||||
|
|
||||||
|
func _on_unequip_pressed(weapon: WeaponData):
|
||||||
|
var slot = "offhand" if weapon.hand_type == WeaponData.Hand.OFF_HAND else "main"
|
||||||
|
GameState.unequip_loadout_slot(slot)
|
||||||
|
|
||||||
|
# --- Preview ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func _update_preview():
|
||||||
|
if not _sheet:
|
||||||
|
return
|
||||||
|
|
||||||
|
var loadout = GameState.get_selected_loadout()
|
||||||
|
var main_data = null
|
||||||
|
var offhand_data = null
|
||||||
|
if loadout.get("main", "") != "":
|
||||||
|
main_data = load(loadout["main"])
|
||||||
|
if loadout.get("offhand", "") != "":
|
||||||
|
offhand_data = load(loadout["offhand"])
|
||||||
|
|
||||||
|
var nick = "Gladiator"
|
||||||
|
if _nick_input and _nick_input.text.strip_edges() != "":
|
||||||
|
nick = _nick_input.text.strip_edges()
|
||||||
|
|
||||||
|
_sheet.set_preview({
|
||||||
|
"nick": nick,
|
||||||
|
"main": main_data,
|
||||||
|
"offhand": offhand_data,
|
||||||
|
})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ngicph2qsf0m
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
extends Control
|
||||||
|
class_name RunResults
|
||||||
|
## End-of-run results screen for the gladiator arena.
|
||||||
|
## Shown by the HUD when the local player dies. Displays survival stats and
|
||||||
|
## the death settlement (keep 10% of total value, floored at the fresh-start
|
||||||
|
## default), then returns the player to the menu/camp.
|
||||||
|
|
||||||
|
var stats: Dictionary = {}
|
||||||
|
|
||||||
|
func setup(run_stats: Dictionary):
|
||||||
|
stats = run_stats
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
# Full-screen dark backdrop
|
||||||
|
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
|
||||||
|
var backdrop = ColorRect.new()
|
||||||
|
backdrop.color = Color(0.0, 0.0, 0.0, 0.75)
|
||||||
|
backdrop.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
add_child(backdrop)
|
||||||
|
|
||||||
|
# Centered panel
|
||||||
|
var center = CenterContainer.new()
|
||||||
|
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
add_child(center)
|
||||||
|
|
||||||
|
var panel = PanelContainer.new()
|
||||||
|
panel.custom_minimum_size = Vector2(460, 0)
|
||||||
|
center.add_child(panel)
|
||||||
|
|
||||||
|
var margin = MarginContainer.new()
|
||||||
|
margin.add_theme_constant_override("margin_left", 30)
|
||||||
|
margin.add_theme_constant_override("margin_right", 30)
|
||||||
|
margin.add_theme_constant_override("margin_top", 24)
|
||||||
|
margin.add_theme_constant_override("margin_bottom", 24)
|
||||||
|
panel.add_child(margin)
|
||||||
|
|
||||||
|
var vbox = VBoxContainer.new()
|
||||||
|
vbox.add_theme_constant_override("separation", 8)
|
||||||
|
margin.add_child(vbox)
|
||||||
|
|
||||||
|
# Title
|
||||||
|
var title = Label.new()
|
||||||
|
title.text = "YOU HAVE FALLEN"
|
||||||
|
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
title.add_theme_font_size_override("font_size", 36)
|
||||||
|
title.add_theme_color_override("font_color", Color(0.9, 0.2, 0.2))
|
||||||
|
vbox.add_child(title)
|
||||||
|
|
||||||
|
var subtitle = Label.new()
|
||||||
|
subtitle.text = "The arena claims another gladiator"
|
||||||
|
subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
subtitle.add_theme_font_size_override("font_size", 16)
|
||||||
|
subtitle.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
|
||||||
|
vbox.add_child(subtitle)
|
||||||
|
|
||||||
|
_add_spacer(vbox, 12)
|
||||||
|
|
||||||
|
# Survival stats
|
||||||
|
_add_header(vbox, "=== SURVIVAL ===")
|
||||||
|
_add_stat_row(vbox, "Waves Survived", str(int(stats.get("waves", 0))))
|
||||||
|
_add_stat_row(vbox, "Time in Arena", _format_time(stats.get("time", 0.0)))
|
||||||
|
_add_stat_row(vbox, "Kills", str(int(stats.get("kills", 0))))
|
||||||
|
|
||||||
|
_add_spacer(vbox, 12)
|
||||||
|
|
||||||
|
# Settlement
|
||||||
|
_add_header(vbox, "=== SETTLEMENT ===")
|
||||||
|
_add_stat_row(vbox, "Gold Earned This Run", str(int(stats.get("gold_earned", 0))))
|
||||||
|
_add_stat_row(vbox, "Total Value at Death", str(int(stats.get("total_value", 0))))
|
||||||
|
|
||||||
|
var kept = int(stats.get("kept", 0))
|
||||||
|
var kept_row = Label.new()
|
||||||
|
kept_row.text = "The arena takes its cut... you keep " + str(kept) + " gold"
|
||||||
|
kept_row.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
kept_row.add_theme_font_size_override("font_size", 18)
|
||||||
|
kept_row.add_theme_color_override("font_color", Color(1.0, 0.8, 0.0))
|
||||||
|
vbox.add_child(kept_row)
|
||||||
|
|
||||||
|
_add_spacer(vbox, 16)
|
||||||
|
|
||||||
|
# Spectate option - only when a teammate is still fighting
|
||||||
|
if _teammates_still_alive():
|
||||||
|
var spectate_button = Button.new()
|
||||||
|
spectate_button.text = "Spectate"
|
||||||
|
spectate_button.custom_minimum_size = Vector2(0, 44)
|
||||||
|
spectate_button.pressed.connect(_on_spectate_pressed)
|
||||||
|
vbox.add_child(spectate_button)
|
||||||
|
_add_spacer(vbox, 6)
|
||||||
|
|
||||||
|
# Return button
|
||||||
|
var return_button = Button.new()
|
||||||
|
return_button.text = "Return to Camp"
|
||||||
|
return_button.custom_minimum_size = Vector2(0, 44)
|
||||||
|
return_button.pressed.connect(_on_return_pressed)
|
||||||
|
vbox.add_child(return_button)
|
||||||
|
|
||||||
|
# Make sure the mouse is usable on the overlay
|
||||||
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
|
|
||||||
|
## Any other living player in the arena?
|
||||||
|
func _teammates_still_alive() -> bool:
|
||||||
|
if multiplayer.multiplayer_peer == null:
|
||||||
|
return false
|
||||||
|
var level = get_tree().get_current_scene()
|
||||||
|
if not level or not level.has_node("PlayersContainer"):
|
||||||
|
return false
|
||||||
|
var my_id = multiplayer.get_unique_id()
|
||||||
|
for p in level.get_node("PlayersContainer").get_children():
|
||||||
|
if p is Character and not p.is_dead and str(p.name).to_int() != my_id:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
## Close the overlay and watch the run play out (escape menu can leave later).
|
||||||
|
## For the host this also keeps the server alive for everyone else.
|
||||||
|
func _on_spectate_pressed():
|
||||||
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||||
|
queue_free()
|
||||||
|
|
||||||
|
func _add_header(parent: Control, text: String):
|
||||||
|
var label = Label.new()
|
||||||
|
label.text = text
|
||||||
|
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
label.add_theme_font_size_override("font_size", 16)
|
||||||
|
label.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8))
|
||||||
|
parent.add_child(label)
|
||||||
|
|
||||||
|
func _add_stat_row(parent: Control, label_text: String, value_text: String):
|
||||||
|
var row = HBoxContainer.new()
|
||||||
|
var label = Label.new()
|
||||||
|
label.text = label_text
|
||||||
|
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
label.add_theme_font_size_override("font_size", 16)
|
||||||
|
row.add_child(label)
|
||||||
|
|
||||||
|
var value = Label.new()
|
||||||
|
value.text = value_text
|
||||||
|
value.add_theme_font_size_override("font_size", 16)
|
||||||
|
value.add_theme_color_override("font_color", Color(1.0, 0.8, 0.0))
|
||||||
|
row.add_child(value)
|
||||||
|
parent.add_child(row)
|
||||||
|
|
||||||
|
func _add_spacer(parent: Control, height: int):
|
||||||
|
var spacer = Control.new()
|
||||||
|
spacer.custom_minimum_size = Vector2(0, height)
|
||||||
|
parent.add_child(spacer)
|
||||||
|
|
||||||
|
func _format_time(seconds: float) -> String:
|
||||||
|
var total = int(seconds)
|
||||||
|
return "%dm %02ds" % [total / 60, total % 60]
|
||||||
|
|
||||||
|
func _on_return_pressed():
|
||||||
|
# Tear down the multiplayer session and go back to the menu.
|
||||||
|
# (Host quitting ends the session for connected clients - acceptable for now.)
|
||||||
|
multiplayer.multiplayer_peer = null
|
||||||
|
Network.players.clear()
|
||||||
|
|
||||||
|
if has_node("/root/HUD"):
|
||||||
|
get_node("/root/HUD").reset()
|
||||||
|
|
||||||
|
get_tree().reload_current_scene()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://i1s8oc7snqnm
|
||||||
@@ -5,6 +5,8 @@ class_name WeaponSlot
|
|||||||
# References
|
# References
|
||||||
@onready var icon_rect: TextureRect = $SlotPanel/MarginContainer/IconRect
|
@onready var icon_rect: TextureRect = $SlotPanel/MarginContainer/IconRect
|
||||||
var tooltip: PanelContainer = null
|
var tooltip: PanelContainer = null
|
||||||
|
# Fallback name display for weapons without an icon texture
|
||||||
|
var _name_label: Label = null
|
||||||
|
|
||||||
# Weapon data
|
# Weapon data
|
||||||
var weapon_data: WeaponData = null
|
var weapon_data: WeaponData = null
|
||||||
@@ -53,9 +55,43 @@ func _update_display():
|
|||||||
if weapon_data and weapon_data.icon:
|
if weapon_data and weapon_data.icon:
|
||||||
icon_rect.texture = weapon_data.icon
|
icon_rect.texture = weapon_data.icon
|
||||||
icon_rect.modulate = Color.WHITE
|
icon_rect.modulate = Color.WHITE
|
||||||
|
_set_name_fallback("")
|
||||||
|
elif weapon_data:
|
||||||
|
# No icon on this weapon - show its name in the slot instead
|
||||||
|
icon_rect.texture = null
|
||||||
|
icon_rect.modulate = Color.WHITE
|
||||||
|
_set_name_fallback(weapon_data.weapon_name)
|
||||||
else:
|
else:
|
||||||
icon_rect.texture = null
|
icon_rect.texture = null
|
||||||
icon_rect.modulate = Color(0.3, 0.3, 0.3, 0.5) # Dim empty slot
|
icon_rect.modulate = Color(0.3, 0.3, 0.3, 0.5) # Dim empty slot
|
||||||
|
_set_name_fallback("")
|
||||||
|
|
||||||
|
## Show/hide the fallback name label ("" hides it)
|
||||||
|
func _set_name_fallback(text: String):
|
||||||
|
if text == "":
|
||||||
|
if _name_label:
|
||||||
|
_name_label.hide()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not _name_label:
|
||||||
|
_name_label = Label.new()
|
||||||
|
_name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
_name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
_name_label.add_theme_font_size_override("font_size", 12)
|
||||||
|
_name_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.4))
|
||||||
|
_name_label.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||||
|
_name_label.add_theme_constant_override("outline_size", 2)
|
||||||
|
_name_label.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
var container = get_node_or_null("SlotPanel/MarginContainer")
|
||||||
|
if container:
|
||||||
|
container.add_child(_name_label)
|
||||||
|
else:
|
||||||
|
add_child(_name_label)
|
||||||
|
|
||||||
|
_name_label.text = text
|
||||||
|
_name_label.show()
|
||||||
|
|
||||||
## Show tooltip on mouse enter
|
## Show tooltip on mouse enter
|
||||||
func _on_mouse_entered():
|
func _on_mouse_entered():
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[gd_resource type="Theme" load_steps=2 format=3]
|
[gd_resource type="Theme" load_steps=2 format=3 uid="uid://dvsh7tuhulnfm"]
|
||||||
|
|
||||||
[ext_resource type="FontFile" uid="uid://dh88edx2pf6ax" path="res://assets/fonts/Kurland.ttf" id="1_font"]
|
[ext_resource type="FontFile" uid="uid://wipqjhfqeuwd" path="res://assets/fonts/Kurland.ttf" id="1_font"]
|
||||||
|
|
||||||
[resource]
|
[resource]
|
||||||
default_font = ExtResource("1_font")
|
default_font = ExtResource("1_font")
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ config/icon="res://icon.png"
|
|||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
Network="*res://level/scripts/network.gd"
|
Network="*res://level/scripts/network.gd"
|
||||||
|
GameState="*res://level/scripts/game_state.gd"
|
||||||
HUD="*res://level/ui/scripts/hud_manager.gd"
|
HUD="*res://level/ui/scripts/hud_manager.gd"
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|||||||
Reference in New Issue
Block a user