Compare commits

...
22 Commits
Author SHA1 Message Date
Scottlg d4eb2e00be Fix all stale UID warnings
Scene files referenced assets by UIDs that no longer matched the
importer records. Rewrote 13 ext_resource references to the current
UIDs from the .import sidecars, and gave wow_style.tres the UID that
nine UI scenes were already referencing (its header had none).
Headless load is now warning-free.
2026-07-02 23:10:39 +01:00
Scottlg 4d2b177f7b Tune the Snippest down to a 2x lobster - 5x had too much reach and launched players skyward 2026-07-02 22:48:24 +01:00
Scottlg 8f28304d0f Wave escalation and the Snippest boss
Escalation (EnemySpawner exports, all tunable):
- Wave size grows +1 enemy per wave, capped at 20
- Enemy health +15% and damage +10% per wave, applied identically on
  every peer via the spawn RPC before _ready (so hitbox stats and
  current_health pick them up)

Boss milestone every 5th wave: the Snippest - the basic lobster at
5x scale and 5x combat stats (50 HP, 25 damage, 5x knockback and
reach). Movement deliberately not 5x (1.2x - it lumbers). Pays 250
gold and always drops a health orb. Wave escalation multipliers stack
on top, so later bosses keep scaling.

Boss scene instances basic_enemy.tscn with the BossLobster script;
visual/collision children are scaled uniformly in code (not the
CharacterBody3D root, not shared shape resources).
2026-07-02 22:27:14 +01:00
Scottlg fb10f7b042 Co-op pass: host-disconnect handling, spectate, late-join wave sync
- Clients now handle the host closing the arena: bank winnings if
  still alive (retire), reset the HUD, and return to the camp menu
  instead of being stranded in a dead world
- Death in co-op offers Spectate when a teammate is still fighting:
  closes the results overlay and watches the run; the escape menu
  gains a "Back to Camp" option for dead spectators to leave later
- Late joiners receive the current wave number on connect so their
  HUD doesn't sit on "Prepare..." until the next wave starts
2026-07-01 23:57:38 +01:00
Scottlg f5e0642b39 Arena polish: in-run HUD, no free loot, retire-with-winnings
- HUD arena status (top center): live wave number and run gold,
  driven by GameState signals, torn down cleanly on HUD.reset()
- Free floor weapons removed: the initial sword/shield spawn is gone
  and manually placed level.tscn weapons are deleted server-side at
  init. Gear now only enters play via the armory or enemy drops.
- Retire to Camp: new escape-menu button (shown only while alive in
  an active run) banks 100% of run gold and keeps all gear - the
  counterweight to death's 10% settlement. Exit Game and window close
  also bank winnings before quitting.
- CLAUDE.md rewritten for the gladiator arena era: core loop,
  autoloads, economy API, RPC patterns, verification workflow.
2026-07-01 23:47:03 +01:00
Scottlg 1562b37563 Gladiator arena: gold economy, outfitting shop, permadeath runs
Turns the sandbox into a survival roguelike loop: outfit your gladiator
with banked gold, enter the arena, earn gold from kills and wave clears,
die, keep 10% of your total value (floored at the 500 fresh-start).

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

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

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

Death is final: players no longer respawn; a results screen shows waves,
time, kills, and the settlement, then returns to camp. Armed enemies no
longer respawn (were an infinite gold farm and respawned invisible).
2026-07-01 23:38:59 +01:00
Scottlg 97ebbb1618 Made more enemies, easier to kill, more slashy. You get passive health regen, health orbs. Little key bindings tooltip. 2026-06-30 22:57:11 +01:00
Twirpytherobot 7fa2efabaf heheh 2026-02-17 22:51:38 +00:00
Twirpytherobot 7629342540 Armed guys! 2026-02-17 22:21:17 +00:00
Scottlg fce4c4a3e2 Added Lobster Axe 2025-11-29 23:40:19 +00:00
Twirpytherobot 689181ac30 eNEMIES!!!!!!!!!
Enemys and enemy spawner

- Press N to spawn wave
- Press H to togglt hitboxs
2025-11-29 20:39:30 +00:00
Twirpytherobot b606563f4d Hitbox Timing 2025-11-29 18:24:36 +00:00
Twirpytherobot fab47fb1e0 Colours and practise dummy 2025-11-29 18:10:23 +00:00
Twirpytherobot ddc1522174 weapon timings 2025-11-28 18:40:23 +00:00
Twirpytherobot 7d18de1621 hitbox changes 2025-11-28 18:33:51 +00:00
Twirpytherobot 10cc8720b7 Hithurtboxes 2025-11-21 23:53:28 +00:00
Twirpytherobot 2ec7d56511 Sword animations now sync 2025-11-21 22:59:41 +00:00
Twirpytherobot b1f1016475 Applecorer 2025-11-21 22:48:05 +00:00
Twirpytherobot e192802f01 Finally fixed the sync issue!
Late joining now syncs properly!!!!!!
2025-11-19 23:12:12 +00:00
Twirpytherobot 85e76d67ca Fixed yo shit 2025-11-17 19:26:56 +00:00
Scottlg 6e4e3a3585 Revert to using player.tscn (robot character) temporarily
- Changed level.tscn back to player.tscn instead of Player_Lilguy.tscn
- This ensures the Character script is properly attached
- Lilguy character integration will be done properly later in Godot editor
- Game now runs without type errors
2025-11-16 23:30:48 +00:00
Scottlg bdc507e852 Fix nickname access error on player instantiation
- Changed from player.nickname.text to get_node_or_null approach
- @onready variables aren't available immediately after instantiation
- Prevents 'Invalid access to property or key nickname' error
2025-11-16 23:29:30 +00:00
67 changed files with 4899 additions and 490 deletions
+102 -86
View File
@@ -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
```
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[gd_resource type="Resource" script_class="WeaponData" load_steps=3 format=3 uid="uid://b2q62xc0jw4w3"]
[ext_resource type="PackedScene" uid="uid://cehc5ckhq2byd" path="res://level/scenes/weapons/Applecoremesh.tscn" id="1_1ytxi"]
[ext_resource type="Script" uid="uid://d2homvlmrg6xs" path="res://level/scripts/weapon_data.gd" id="2_hfi3c"]
[resource]
script = ExtResource("2_hfi3c")
weapon_name = "Apple Sword"
description = "yum"
cost = 300
tier = 2
damage = 20.0
attack_range = 3.5
attack_cooldown = 0.6
knockback_force = 12.0
startup_time = 0.2
active_time = 1.0
mesh_scene = ExtResource("1_1ytxi")
weight = 2.0
+20
View File
@@ -0,0 +1,20 @@
[gd_resource type="Resource" script_class="WeaponData" load_steps=3 format=3 uid="uid://dyae861vxd8it"]
[ext_resource type="Script" uid="uid://d2homvlmrg6xs" path="res://level/scripts/weapon_data.gd" id="1"]
[ext_resource type="PackedScene" uid="uid://cq8r5mkn3wvxj" path="res://level/scenes/weapons/LobsterAxeMesh.tscn" id="2"]
[resource]
script = ExtResource("1")
weapon_name = "Lobster Axe"
description = "A heavy-hitting axe shaped like a lobster claw. Surprisingly quick for its size."
cost = 350
tier = 2
hand_type = 2
damage = 18.0
attack_cooldown = 0.7
attack_animation = "Attack_TwoHandSwing"
knockback_force = 14.0
startup_time = 0.2
active_time = 1.0
mesh_scene = ExtResource("2")
weight = 2.5
+4
View File
@@ -7,11 +7,15 @@
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
attack_cooldown = 0.8 attack_cooldown = 0.8
knockback_force = 15.0 knockback_force = 15.0
startup_time = 0.25
active_time = 0.15
can_block = true can_block = true
block_reduction = 0.7 block_reduction = 0.7
mesh_scene = ExtResource("2") mesh_scene = ExtResource("2")
+7 -5
View File
@@ -1,17 +1,19 @@
[gd_resource type="Resource" script_class="WeaponData" load_steps=3 format=3] [gd_resource type="Resource" script_class="WeaponData" load_steps=3 format=3 uid="uid://cuwjmtrp4silp"]
[ext_resource type="Script" path="res://level/scripts/weapon_data.gd" id="1"] [ext_resource type="Script" uid="uid://d2homvlmrg6xs" path="res://level/scripts/weapon_data.gd" id="1"]
[ext_resource type="PackedScene" path="res://level/scenes/weapons/sword_mesh.tscn" id="2"] [ext_resource type="PackedScene" uid="uid://dyjfaq654xne3" path="res://level/scenes/weapons/sword_mesh.tscn" id="2"]
[resource] [resource]
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
knockback_force = 12.0 knockback_force = 12.0
attack_animation = "Attack1" startup_time = 0.12
active_time = 1.0
mesh_scene = ExtResource("2") mesh_scene = ExtResource("2")
pickup_radius = 1.5
weight = 2.0 weight = 2.0
+4
View File
@@ -7,9 +7,13 @@
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
knockback_force = 12.0 knockback_force = 12.0
startup_time = 0.1
active_time = 1.0
mesh_scene = ExtResource("1_gdc1w") mesh_scene = ExtResource("1_gdc1w")
weight = 2.0 weight = 2.0
+1 -1
View File
@@ -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"]
+76 -46
View File
@@ -1,122 +1,152 @@
[gd_scene load_steps=6 format=3 uid="uid://db06e8q8f8bdq"] [gd_scene load_steps=9 format=3 uid="uid://db06e8q8f8bdq"]
[ext_resource type="PackedScene" uid="uid://byw3ig2bs1wgu" path="res://assets/characters/player/LilguyRigged.glb" id="1_e6qwr"] [ext_resource type="Script" uid="uid://c2si8gkbnde0c" path="res://level/scripts/player.gd" id="1_player"]
[ext_resource type="PackedScene" uid="uid://b22ou40sbkavj" path="res://assets/characters/player/LilguyRigged.glb" id="2_lilguy"]
[ext_resource type="Script" uid="uid://cf7jky1bcs560" path="res://level/scripts/lilguy_body.gd" id="3_body"] [ext_resource type="Script" uid="uid://cf7jky1bcs560" path="res://level/scripts/lilguy_body.gd" id="3_body"]
[ext_resource type="Script" uid="uid://bj7yrijm7bppq" path="res://level/scripts/spring_arm_offset.gd" id="9_dlyie"] [ext_resource type="Script" uid="uid://bj7yrijm7bppq" path="res://level/scripts/spring_arm_offset.gd" id="4_spring"]
[ext_resource type="Script" uid="uid://bj3uepduxvgju" path="res://level/scripts/hurt_box.gd" id="5_hurtbox"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_yxyay"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_yxyay"]
radius = 0.35796 radius = 0.35796
height = 1.73092 height = 1.73092
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_hurtbox"]
radius = 0.4
height = 1.8
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_xbohm"] [sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_xbohm"]
properties/0/path = NodePath(".:position") properties/0/path = NodePath(".:position")
properties/0/spawn = true properties/0/spawn = true
properties/0/replication_mode = 1 properties/0/replication_mode = 1
properties/1/path = NodePath("AnimationPlayer:current_animation") properties/1/path = NodePath("LilguyRigged/AnimationPlayer:current_animation")
properties/1/spawn = true properties/1/spawn = true
properties/1/replication_mode = 1 properties/1/replication_mode = 1
properties/2/path = NodePath("PlayerNick/Nickname:text") properties/2/path = NodePath("PlayerNick/Nickname:text")
properties/2/spawn = true properties/2/spawn = true
properties/2/replication_mode = 1 properties/2/replication_mode = 1
properties/3/path = NodePath("Armature:rotation") properties/3/path = NodePath("LilguyRigged/Armature:rotation")
properties/3/spawn = true properties/3/spawn = true
properties/3/replication_mode = 1 properties/3/replication_mode = 1
[node name="LilguyRigged" instance=ExtResource("1_e6qwr")] [node name="Player" type="CharacterBody3D" node_paths=PackedStringArray("_body", "_spring_arm_offset", "_weapon_attachment", "_weapon_container", "_offhand_attachment", "_offhand_container")]
collision_mask = 3 collision_mask = 3
script = ExtResource("1_player")
_body = NodePath("LilguyRigged/Armature")
_spring_arm_offset = NodePath("SpringArmOffset")
_weapon_attachment = NodePath("LilguyRigged/Armature/Skeleton3D/WeaponPoint")
_weapon_container = NodePath("LilguyRigged/Armature/Skeleton3D/WeaponPoint/WeaponContainer")
_offhand_attachment = NodePath("LilguyRigged/Armature/Skeleton3D/OffhandPoint")
_offhand_container = NodePath("LilguyRigged/Armature/Skeleton3D/OffhandPoint/OffhandContainer")
[node name="Armature" parent="." index="0" node_paths=PackedStringArray("_character", "animation_player")] [node name="LilguyRigged" parent="." instance=ExtResource("2_lilguy")]
[node name="Armature" parent="LilguyRigged" index="0" node_paths=PackedStringArray("_character", "animation_player")]
transform = Transform3D(0.003, 0, 0, 0, -1.3113416e-10, -0.003, 0, 0.003, -1.3113416e-10, 0, 0, 0) transform = Transform3D(0.003, 0, 0, 0, -1.3113416e-10, -0.003, 0, 0.003, -1.3113416e-10, 0, 0, 0)
script = ExtResource("3_body") script = ExtResource("3_body")
_character = NodePath("..") _character = NodePath("../..")
animation_player = NodePath("../AnimationPlayer") animation_player = NodePath("../AnimationPlayer")
[node name="Skeleton3D" parent="Armature" index="0"] [node name="Skeleton3D" parent="LilguyRigged/Armature" index="0"]
bones/0/position = Vector3(-0.32852697, 2.914154, -546.76843) bones/0/position = Vector3(-0.32859802, 2.9141626, -546.76843)
bones/0/rotation = Quaternion(-0.6608288, 0.28933647, -0.19178489, 0.6654384) bones/0/rotation = Quaternion(-0.6608289, 0.28933656, -0.19178493, 0.66543835)
bones/1/position = Vector3(0.054167695, 63.219894, -3.33786e-06) bones/1/position = Vector3(0.054167695, 63.219894, -3.33786e-06)
bones/1/rotation = Quaternion(0.015321622, 0.025352472, 0.09471857, 0.99506325) bones/1/rotation = Quaternion(0.015321612, 0.025352655, 0.0947179, 0.99506336)
bones/2/position = Vector3(-1.8112361e-05, 73.7566, -1.621247e-05) bones/2/position = Vector3(-1.8112361e-05, 73.7566, -1.621247e-05)
bones/2/rotation = Quaternion(0.03465983, 0.05047194, 0.051301006, 0.99680465) bones/2/rotation = Quaternion(0.034659874, 0.050472155, 0.051301125, 0.99680465)
bones/3/position = Vector3(-3.0510128e-05, 84.29319, 9.059899e-06) bones/3/position = Vector3(-3.0510128e-05, 84.29319, 9.059899e-06)
bones/3/rotation = Quaternion(0.029244617, 0.05379084, -0.051849358, 0.9967763) bones/3/rotation = Quaternion(0.029244598, 0.05379088, -0.051849354, 0.99677634)
bones/4/position = Vector3(3.8038404e-05, 94.83001, 1.9073414e-06) bones/4/position = Vector3(3.8038404e-05, 94.83001, 1.9073414e-06)
bones/4/rotation = Quaternion(0.0006216668, 0.08164933, 0.020402616, 0.99645215) bones/4/rotation = Quaternion(0.0006217413, 0.08164966, 0.020404326, 0.9964521)
bones/5/position = Vector3(-0.25257444, 72.84532, -7.644296e-06) bones/5/position = Vector3(-0.25257444, 72.84532, -7.644296e-06)
bones/5/rotation = Quaternion(0.03735582, 0.19943666, -0.04159315, 0.97831464) bones/5/rotation = Quaternion(0.037355006, 0.19943582, -0.04159446, 0.9783148)
bones/6/position = Vector3(-0.606337, 174.89494, 7.152558e-06) bones/6/position = Vector3(-0.606337, 174.89494, 7.152558e-06)
bones/7/position = Vector3(-0.19949026, 76.75483, 52.286175) bones/7/position = Vector3(-0.19949026, 76.75483, 52.286175)
bones/7/rotation = Quaternion(0.8036073, -0.09628729, 0.106725484, 0.57754105) bones/7/rotation = Quaternion(0.80360717, -0.09628771, 0.10672592, 0.57754105)
bones/8/position = Vector3(4.5403274e-05, 110.91907, 9.404198e-05) bones/8/position = Vector3(4.5403274e-05, 110.91907, 9.404198e-05)
bones/8/rotation = Quaternion(0.25522032, -0.08967137, 0.029357875, 0.962268) bones/8/rotation = Quaternion(0.25522023, -0.08967148, 0.029356971, 0.9622681)
bones/9/position = Vector3(2.3064584e-05, 173.66367, 5.063071e-05) bones/9/position = Vector3(2.3064584e-05, 173.66367, 5.063071e-05)
bones/9/rotation = Quaternion(0.0878422, -0.16096674, 0.2433837, 0.9524378) bones/9/rotation = Quaternion(0.08784258, -0.16096693, 0.24338366, 0.95243776)
bones/10/position = Vector3(-2.2947788e-05, 166.48767, -1.2734416e-05) bones/10/position = Vector3(-2.2947788e-05, 166.48767, -1.2734416e-05)
bones/11/position = Vector3(0.23053212, 76.75536, -52.28617) bones/11/position = Vector3(0.23053212, 76.75536, -52.28617)
bones/11/rotation = Quaternion(0.14271575, -0.5852634, 0.782287, 0.15851216) bones/11/rotation = Quaternion(0.14271267, -0.5852636, 0.7822879, 0.15851)
bones/12/position = Vector3(1.532285e-05, 110.91911, 4.0430357e-05) bones/12/position = Vector3(1.532285e-05, 110.91911, 4.0430357e-05)
bones/12/rotation = Quaternion(0.32196987, 0.13412467, 0.27112576, 0.8971269) bones/12/rotation = Quaternion(0.32197043, 0.13412262, 0.2711233, 0.89712787)
bones/13/position = Vector3(1.5523525e-05, 173.6661, 0.00010698747) bones/13/position = Vector3(1.5523525e-05, 173.6661, 0.00010698747)
bones/13/rotation = Quaternion(0.09037647, 0.101556264, -0.39819276, 0.90717196) bones/13/rotation = Quaternion(0.090376236, 0.10155637, -0.39819276, 0.90717196)
bones/14/position = Vector3(-2.0682812e-05, 166.48976, 3.939679e-05) bones/14/position = Vector3(-2.0682812e-05, 166.48976, 3.939679e-05)
bones/15/position = Vector3(0.6496186, -35.1185, 49.84838) bones/15/position = Vector3(0.6496186, -35.1185, 49.84838)
bones/15/rotation = Quaternion(0.38543195, 0.163806, 0.8217493, 0.3864424) bones/15/rotation = Quaternion(0.38543156, 0.16380574, 0.82174975, 0.38644233)
bones/16/position = Vector3(8.771768e-06, 312.91962, 7.4840264e-06) bones/16/position = Vector3(8.771768e-06, 312.91962, 7.4840264e-06)
bones/16/rotation = Quaternion(-0.05300442, 0.17209676, 0.3905684, 0.90278995) bones/16/rotation = Quaternion(-0.053004134, 0.17209636, 0.39056766, 0.90279037)
bones/17/position = Vector3(-1.8137518e-05, 301.05597, -2.1670077e-05) bones/17/position = Vector3(-1.8137518e-05, 301.05597, -2.1670077e-05)
bones/17/rotation = Quaternion(0.24982396, 0.64725155, -0.6711768, 0.2611037) bones/17/rotation = Quaternion(0.2498236, 0.64725155, -0.67117685, 0.26110402)
bones/18/position = Vector3(-3.026353e-05, 14.185886, -1.4917823e-06) bones/18/position = Vector3(-3.026353e-05, 14.185886, -1.4917823e-06)
bones/18/rotation = Quaternion(0.11539763, 0.017187234, -0.0100022685, 0.9931203) bones/18/rotation = Quaternion(0.11539694, 0.017187497, -0.010002339, 0.9931204)
bones/19/position = Vector3(-4.351055e-06, 11.391233, -2.5032205e-06) bones/19/position = Vector3(-4.351055e-06, 11.391233, -2.5032205e-06)
bones/20/position = Vector3(0.014209064, -35.118507, -49.848385) bones/20/position = Vector3(0.014209064, -35.118507, -49.848385)
bones/20/rotation = Quaternion(-0.07370265, -0.18747172, 0.9412239, 0.2711456) bones/20/rotation = Quaternion(-0.07370261, -0.18747209, 0.94122386, 0.27114522)
bones/21/position = Vector3(2.8756085e-05, 312.91974, 5.14377e-06) bones/21/position = Vector3(2.8756085e-05, 312.91974, 5.14377e-06)
bones/21/rotation = Quaternion(-0.03735361, -0.04220169, 0.46135134, 0.8854257) bones/21/rotation = Quaternion(-0.037353504, -0.04220154, 0.46134973, 0.88542664)
bones/22/position = Vector3(2.2092872e-05, 301.0575, 1.8114511e-05) bones/22/position = Vector3(2.2092872e-05, 301.0575, 1.8114511e-05)
bones/22/rotation = Quaternion(0.7933202, 0.12858748, -0.3622723, 0.47208822) bones/22/rotation = Quaternion(0.79332, 0.1285891, -0.36227074, 0.47208923)
bones/23/position = Vector3(1.3624241e-05, 15.034077, 9.790485e-06) bones/23/position = Vector3(1.3624241e-05, 15.034077, 9.790485e-06)
bones/23/rotation = Quaternion(0.11885707, 0.009521758, -0.0077993367, 0.9928351) bones/23/rotation = Quaternion(0.11885707, 0.009522018, -0.0077985795, 0.9928351)
bones/24/position = Vector3(-2.4847686e-06, 11.913359, -6.198885e-06) bones/24/position = Vector3(-2.4847686e-06, 11.913359, -6.198885e-06)
[node name="WeaponPoint" type="BoneAttachment3D" parent="Armature/Skeleton3D" index="1"] [node name="WeaponPoint" type="BoneAttachment3D" parent="LilguyRigged/Armature/Skeleton3D" index="1"]
transform = Transform3D(-0.43292555, -0.61284775, 0.6610542, 0.7782953, 0.11585887, 0.61711675, -0.45478758, 0.78166103, 0.42681834, -352.38528, -73.5694, -531.96124) transform = Transform3D(-0.4329258, -0.61284786, 0.6610537, 0.7782944, 0.11585927, 0.6171174, -0.45478824, 0.78166056, 0.42681772, -352.385, -73.56995, -531.9614)
bone_name = "mixamorig_RightHand" bone_name = "mixamorig_RightHand"
bone_idx = 14 bone_idx = 14
[node name="WeaponContainer" type="Node3D" parent="Armature/Skeleton3D/WeaponPoint" index="0"] [node name="WeaponContainer" type="Node3D" parent="LilguyRigged/Armature/Skeleton3D/WeaponPoint"]
transform = Transform3D(36.6912, 297.2667, 16.921356, 46.72698, 11.0892515, -296.13126, -294.05847, 38.85366, -44.94499, 24.08223, -7.4241333, 7.098694) transform = Transform3D(36.6912, 297.2667, 16.921356, 46.72698, 11.0892515, -296.13126, -294.05847, 38.85366, -44.94499, 24.08223, -7.4241333, 7.098694)
[node name="OffhandPoint" type="BoneAttachment3D" parent="Armature/Skeleton3D" index="2"] [node name="OffhandPoint" type="BoneAttachment3D" parent="LilguyRigged/Armature/Skeleton3D" index="2"]
transform = Transform3D(0.6212382, -0.004605584, -0.7836083, -0.620316, 0.60813713, -0.49535576, 0.47882265, 0.7938187, 0.37494114, 135.65903, 334.35764, -511.2708) transform = Transform3D(0.62123704, -0.004605159, -0.7836091, -0.62031674, 0.6081372, -0.49535444, 0.4788229, 0.79381835, 0.3749406, 135.65929, 334.35745, -511.27094)
bone_name = "mixamorig_LeftHand" bone_name = "mixamorig_LeftHand"
bone_idx = 10 bone_idx = 10
[node name="OffhandContainer" type="Node3D" parent="Armature/Skeleton3D/OffhandPoint" index="0"] [node name="OffhandContainer" type="Node3D" parent="LilguyRigged/Armature/Skeleton3D/OffhandPoint"]
transform = Transform3D(-17.74905, -295.46814, -48.82108, 21.019196, -50.01525, 295.05362, -298.73593, 14.035805, 23.660797, 0.005859375, 0.39337158, 0.06616211) transform = Transform3D(-17.74905, -295.46814, -48.82108, 21.019196, -50.01525, 295.05362, -298.73593, 14.035805, 23.660797, 0.005859375, 0.39337158, 0.06616211)
[node name="CollisionShape3D" type="CollisionShape3D" parent="." index="2"] [node name="AnimationPlayer" parent="LilguyRigged" index="1"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.066, 0.828, 0.01) speed_scale = 2.0
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
transform = Transform3D(2, 0, 0, 0, 2, 0, 0, 0, 2, -0.066, 1.647685, 0.01)
shape = SubResource("CapsuleShape3D_yxyay") shape = SubResource("CapsuleShape3D_yxyay")
[node name="SpringArmOffset" type="Node3D" parent="." index="3" node_paths=PackedStringArray("_spring_arm")] [node name="HurtBox" type="Area3D" parent="." node_paths=PackedStringArray("owner_entity")]
collision_layer = 16
collision_mask = 0
script = ExtResource("5_hurtbox")
owner_entity = NodePath("..")
[node name="HurtBoxShape" type="CollisionShape3D" parent="HurtBox"]
transform = Transform3D(1.9228287, 0, 0, 0, 1.4454772, 0, 0, 0, 1.4906956, -0.066, 2.0836046, 0.01)
shape = SubResource("CapsuleShape3D_hurtbox")
[node name="SpringArmOffset" type="Node3D" parent="." node_paths=PackedStringArray("_spring_arm")]
transform = Transform3D(-1, 0, -8.74228e-08, 0, 1, 0, 8.74228e-08, 0, -1, 0, 0, 0) transform = Transform3D(-1, 0, -8.74228e-08, 0, 1, 0, 8.74228e-08, 0, -1, 0, 0, 0)
script = ExtResource("9_dlyie") script = ExtResource("4_spring")
_spring_arm = NodePath("SpringArm3D") _spring_arm = NodePath("SpringArm3D")
[node name="SpringArm3D" type="SpringArm3D" parent="SpringArmOffset" index="0"] [node name="SpringArm3D" type="SpringArm3D" parent="SpringArmOffset"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
spring_length = 5.0 spring_length = 10.0
[node name="Camera3D" type="Camera3D" parent="SpringArmOffset/SpringArm3D" index="0"] [node name="Camera3D" type="Camera3D" parent="SpringArmOffset/SpringArm3D"]
current = true current = true
[node name="PlayerNick" type="Node3D" parent="." index="4"] [node name="PlayerNick" type="Node3D" parent="."]
[node name="Nickname" type="Label3D" parent="PlayerNick" index="0"] [node name="Nickname" type="Label3D" parent="PlayerNick"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.97037, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.97037, 0)
billboard = 1 billboard = 1
outline_modulate = Color(0, 0, 0, 0.301961) outline_modulate = Color(0, 0, 0, 0.301961)
text = "player name test" text = "player name test"
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="." index="5"] [node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="."]
replication_config = SubResource("SceneReplicationConfig_xbohm") replication_config = SubResource("SceneReplicationConfig_xbohm")
[editable path="LilguyRigged"]
+124
View File
@@ -0,0 +1,124 @@
[gd_scene load_steps=5 format=3]
[ext_resource type="Script" path="res://level/scripts/armed_enemy.gd" id="1_armed_enemy"]
[ext_resource type="PackedScene" uid="uid://b22ou40sbkavj" path="res://assets/characters/player/LilguyRigged.glb" id="2_lilguy"]
[ext_resource type="Script" uid="uid://cf7jky1bcs560" path="res://level/scripts/lilguy_body.gd" id="3_body"]
[ext_resource type="Script" uid="uid://bj3uepduxvgju" path="res://level/scripts/hurt_box.gd" id="4_hurtbox"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"]
radius = 0.35796
height = 1.73092
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_hurtbox"]
radius = 0.4
height = 1.8
[node name="ArmedEnemy" type="CharacterBody3D" node_paths=PackedStringArray("_body", "_weapon_attachment", "_weapon_container", "_offhand_attachment", "_offhand_container")]
collision_mask = 3
script = ExtResource("1_armed_enemy")
_body = NodePath("LilguyRigged/Armature")
_weapon_attachment = NodePath("LilguyRigged/Armature/Skeleton3D/WeaponPoint")
_weapon_container = NodePath("LilguyRigged/Armature/Skeleton3D/WeaponPoint/WeaponContainer")
_offhand_attachment = NodePath("LilguyRigged/Armature/Skeleton3D/OffhandPoint")
_offhand_container = NodePath("LilguyRigged/Armature/Skeleton3D/OffhandPoint/OffhandContainer")
move_speed = 4.0
detection_range = 100.0
max_health = 50.0
respawn_delay = 10.0
[node name="LilguyRigged" parent="." instance=ExtResource("2_lilguy")]
[node name="Armature" parent="LilguyRigged" index="0" node_paths=PackedStringArray("_character", "animation_player")]
transform = Transform3D(0.003, 0, 0, 0, -1.3113416e-10, -0.003, 0, 0.003, -1.3113416e-10, 0, 0, 0)
script = ExtResource("3_body")
_character = NodePath("../..")
animation_player = NodePath("../AnimationPlayer")
[node name="Skeleton3D" parent="LilguyRigged/Armature" index="0"]
bones/0/position = Vector3(-0.32859802, 2.9141626, -546.76843)
bones/0/rotation = Quaternion(-0.6608289, 0.28933656, -0.19178493, 0.66543835)
bones/1/position = Vector3(0.054167695, 63.219894, -3.33786e-06)
bones/1/rotation = Quaternion(0.015321612, 0.025352655, 0.0947179, 0.99506336)
bones/2/position = Vector3(-1.8112361e-05, 73.7566, -1.621247e-05)
bones/2/rotation = Quaternion(0.034659874, 0.050472155, 0.051301125, 0.99680465)
bones/3/position = Vector3(-3.0510128e-05, 84.29319, 9.059899e-06)
bones/3/rotation = Quaternion(0.029244598, 0.05379088, -0.051849354, 0.99677634)
bones/4/position = Vector3(3.8038404e-05, 94.83001, 1.9073414e-06)
bones/4/rotation = Quaternion(0.0006217413, 0.08164966, 0.020404326, 0.9964521)
bones/5/position = Vector3(-0.25257444, 72.84532, -7.644296e-06)
bones/5/rotation = Quaternion(0.037355006, 0.19943582, -0.04159446, 0.9783148)
bones/6/position = Vector3(-0.606337, 174.89494, 7.152558e-06)
bones/7/position = Vector3(-0.19949026, 76.75483, 52.286175)
bones/7/rotation = Quaternion(0.80360717, -0.09628771, 0.10672592, 0.57754105)
bones/8/position = Vector3(4.5403274e-05, 110.91907, 9.404198e-05)
bones/8/rotation = Quaternion(0.25522023, -0.08967148, 0.029356971, 0.9622681)
bones/9/position = Vector3(2.3064584e-05, 173.66367, 5.063071e-05)
bones/9/rotation = Quaternion(0.08784258, -0.16096693, 0.24338366, 0.95243776)
bones/10/position = Vector3(-2.2947788e-05, 166.48767, -1.2734416e-05)
bones/11/position = Vector3(0.23053212, 76.75536, -52.28617)
bones/11/rotation = Quaternion(0.14271267, -0.5852636, 0.7822879, 0.15851)
bones/12/position = Vector3(1.532285e-05, 110.91911, 4.0430357e-05)
bones/12/rotation = Quaternion(0.32197043, 0.13412262, 0.2711233, 0.89712787)
bones/13/position = Vector3(1.5523525e-05, 173.6661, 0.00010698747)
bones/13/rotation = Quaternion(0.090376236, 0.10155637, -0.39819276, 0.90717196)
bones/14/position = Vector3(-2.0682812e-05, 166.48976, 3.939679e-05)
bones/15/position = Vector3(0.6496186, -35.1185, 49.84838)
bones/15/rotation = Quaternion(0.38543156, 0.16380574, 0.82174975, 0.38644233)
bones/16/position = Vector3(8.771768e-06, 312.91962, 7.4840264e-06)
bones/16/rotation = Quaternion(-0.053004134, 0.17209636, 0.39056766, 0.90279037)
bones/17/position = Vector3(-1.8137518e-05, 301.05597, -2.1670077e-05)
bones/17/rotation = Quaternion(0.2498236, 0.64725155, -0.67117685, 0.26110402)
bones/18/position = Vector3(-3.026353e-05, 14.185886, -1.4917823e-06)
bones/18/rotation = Quaternion(0.11539694, 0.017187497, -0.010002339, 0.9931204)
bones/19/position = Vector3(-4.351055e-06, 11.391233, -2.5032205e-06)
bones/20/position = Vector3(0.014209064, -35.118507, -49.848385)
bones/20/rotation = Quaternion(-0.07370261, -0.18747209, 0.94122386, 0.27114522)
bones/21/position = Vector3(2.8756085e-05, 312.91974, 5.14377e-06)
bones/21/rotation = Quaternion(-0.037353504, -0.04220154, 0.46134973, 0.88542664)
bones/22/position = Vector3(2.2092872e-05, 301.0575, 1.8114511e-05)
bones/22/rotation = Quaternion(0.79332, 0.1285891, -0.36227074, 0.47208923)
bones/23/position = Vector3(1.3624241e-05, 15.034077, 9.790485e-06)
bones/23/rotation = Quaternion(0.11885707, 0.009522018, -0.0077985795, 0.9928351)
bones/24/position = Vector3(-2.4847686e-06, 11.913359, -6.198885e-06)
[node name="WeaponPoint" type="BoneAttachment3D" parent="LilguyRigged/Armature/Skeleton3D" index="1"]
transform = Transform3D(-0.4329258, -0.61284786, 0.6610537, 0.7782944, 0.11585927, 0.6171174, -0.45478824, 0.78166056, 0.42681772, -352.385, -73.56995, -531.9614)
bone_name = "mixamorig_RightHand"
bone_idx = 14
[node name="WeaponContainer" type="Node3D" parent="LilguyRigged/Armature/Skeleton3D/WeaponPoint"]
transform = Transform3D(36.6912, 297.2667, 16.921356, 46.72698, 11.0892515, -296.13126, -294.05847, 38.85366, -44.94499, 24.08223, -7.4241333, 7.098694)
[node name="OffhandPoint" type="BoneAttachment3D" parent="LilguyRigged/Armature/Skeleton3D" index="2"]
transform = Transform3D(0.62123704, -0.004605159, -0.7836091, -0.62031674, 0.6081372, -0.49535444, 0.4788229, 0.79381835, 0.3749406, 135.65929, 334.35745, -511.27094)
bone_name = "mixamorig_LeftHand"
bone_idx = 10
[node name="OffhandContainer" type="Node3D" parent="LilguyRigged/Armature/Skeleton3D/OffhandPoint"]
transform = Transform3D(-17.74905, -295.46814, -48.82108, 21.019196, -50.01525, 295.05362, -298.73593, 14.035805, 23.660797, 0.005859375, 0.39337158, 0.06616211)
[node name="AnimationPlayer" parent="LilguyRigged" index="1"]
speed_scale = 2.0
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
transform = Transform3D(2, 0, 0, 0, 2, 0, 0, 0, 2, -0.066, 1.647685, 0.01)
shape = SubResource("CapsuleShape3D_body")
[node name="HurtBox" type="Area3D" parent="." node_paths=PackedStringArray("owner_entity")]
collision_layer = 16
collision_mask = 0
script = ExtResource("4_hurtbox")
owner_entity = NodePath("..")
[node name="HurtBoxShape" type="CollisionShape3D" parent="HurtBox"]
transform = Transform3D(1.9228287, 0, 0, 0, 1.4454772, 0, 0, 0, 1.4906956, -0.066, 2.0836046, 0.01)
shape = SubResource("CapsuleShape3D_hurtbox")
[node name="EnemyLabel" type="Label3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 4.2, 0)
billboard = 1
modulate = Color(1, 0.3, 0.3, 1)
outline_modulate = Color(0, 0, 0, 0.4)
text = "Armed Enemy"
[editable path="LilguyRigged"]
+63
View File
@@ -0,0 +1,63 @@
[gd_scene load_steps=8 format=3 uid="uid://byknup31d2b53"]
[ext_resource type="Script" uid="uid://cd87rsuiqhdav" path="res://level/scripts/basic_enemy.gd" id="1_basic_enemy"]
[ext_resource type="Script" uid="uid://bj3uepduxvgju" path="res://level/scripts/hurt_box.gd" id="2_hurtbox"]
[ext_resource type="ArrayMesh" uid="uid://dy0xld0fpulmk" path="res://assets/characters/Lobster/10029_Lobster_v1_iterations-2.obj" id="2_obxet"]
[ext_resource type="Script" uid="uid://jyas86y3f0jp" path="res://level/scripts/hit_box.gd" id="3_hitbox"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"]
height = 1.869873
[sub_resource type="SphereShape3D" id="SphereShape3D_hitbox"]
radius = 2.0
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_enemy"]
properties/0/path = NodePath(".:position")
properties/0/spawn = true
properties/0/replication_mode = 1
properties/1/path = NodePath(".:rotation")
properties/1/spawn = true
properties/1/replication_mode = 1
[node name="BasicEnemy" type="CharacterBody3D"]
collision_mask = 3
script = ExtResource("1_basic_enemy")
move_speed = 3.5
attack_damage = 5.0
detection_range = 500.0
max_health = 10.0
[node name="Mesh" type="MeshInstance3D" parent="."]
transform = Transform3D(-0.29981995, -0.01038597, -0.00033419454, 0.00636219, -0.19110087, 0.23117083, -0.008215994, 0.23102501, 0.19120643, 0, 0.41641736, 0)
mesh = ExtResource("2_obxet")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
transform = Transform3D(1.5, 0, 0, 0, 1.5, 0, 0, 0, 1.5, 0, 1, 0)
shape = SubResource("CapsuleShape3D_body")
[node name="HurtBox" type="Area3D" parent="." node_paths=PackedStringArray("owner_entity")]
transform = Transform3D(1.5, 0, 0, 0, 1.5, 0, 0, 0, 1.5, 0, -0.47265148, 0)
collision_layer = 16
collision_mask = 0
script = ExtResource("2_hurtbox")
owner_entity = NodePath("..")
[node name="HurtBoxShape" type="CollisionShape3D" parent="HurtBox"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9349365, 0)
shape = SubResource("CapsuleShape3D_body")
[node name="HitBox" type="Area3D" parent="." node_paths=PackedStringArray("owner_entity")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.8, -1.2)
collision_layer = 0
collision_mask = 16
script = ExtResource("3_hitbox")
damage = 15.0
knockback = 10.0
owner_entity = NodePath("..")
[node name="HitBoxShape" type="CollisionShape3D" parent="HitBox"]
transform = Transform3D(0.8, 0, 0, 0, 0.8, 0, 0, 0, 0.8, 0, 0.13992286, 0)
shape = SubResource("SphereShape3D_hitbox")
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="."]
replication_config = SubResource("SceneReplicationConfig_enemy")
+7
View File
@@ -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")
+51
View File
@@ -0,0 +1,51 @@
[gd_scene load_steps=7 format=3 uid="uid://dif4t1y3c07ax"]
[ext_resource type="Script" path="res://level/scripts/practice_dummy.gd" id="1_dummy"]
[ext_resource type="Script" uid="uid://bj3uepduxvgju" path="res://level/scripts/hurt_box.gd" id="2_hurtbox"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_dummy"]
albedo_color = Color(0.8, 0.6, 0.4, 1)
metallic = 0.2
roughness = 0.8
[sub_resource type="CapsuleMesh" id="CapsuleMesh_dummy"]
material = SubResource("StandardMaterial3D_dummy")
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_hurtbox"]
radius = 0.6
height = 2.2
[node name="PracticeDummy" type="CharacterBody3D"]
transform = Transform3D(1.5, 0, 0, 0, 1.5, 0, 0, 0, 1.5, 0, 0, 0)
collision_mask = 2
script = ExtResource("1_dummy")
detection_range = 0.0
is_aggressive = false
respawn_delay = 5.0
[node name="Mesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
mesh = SubResource("CapsuleMesh_dummy")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
shape = SubResource("CapsuleShape3D_body")
[node name="HurtBox" type="Area3D" parent="." node_paths=PackedStringArray("owner_entity")]
collision_layer = 16
collision_mask = 0
script = ExtResource("2_hurtbox")
owner_entity = NodePath("..")
[node name="HurtBoxShape" type="CollisionShape3D" parent="HurtBox"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
shape = SubResource("CapsuleShape3D_hurtbox")
[node name="HealthLabel" type="Label3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.5, 0)
billboard = 1
text = "HP: 100/100"
font_size = 24
outline_size = 8
+29
View File
@@ -0,0 +1,29 @@
[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="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"]
albedo_color = Color(1, 0.5, 0, 0.3)
transparency = 1
cull_mode = 2
shading_mode = 0
[node name="EnemySpawner" type="Node3D"]
script = ExtResource("1_spawner")
spawn_radius = 20.0
spawn_height = 0.5
enemies_per_wave = 10
auto_start_next_wave = false
wave_delay = 5.0
enemy_scenes = Array[PackedScene]([ExtResource("2_basic_enemy")])
boss_scene = ExtResource("3_boss")
[node name="SpawnRadiusIndicator" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.1, 0)
visible = false
material_override = SubResource("StandardMaterial3D_indicator")
[node name="CenterMarker" type="MeshInstance3D" parent="."]
transform = Transform3D(0.5, 0, 0, 0, 2, 0, 0, 0, 0.5, 0, 1, 0)
+31
View File
@@ -0,0 +1,31 @@
[gd_scene load_steps=5 format=3]
[ext_resource type="Script" path="res://level/scripts/health_orb.gd" id="1_orb"]
[sub_resource type="SphereMesh" id="SphereMesh_orb"]
radius = 0.35
height = 0.7
[sub_resource type="StandardMaterial3D" id="Mat_orb"]
albedo_color = Color(0.2, 1, 0.35, 1)
emission_enabled = true
emission = Color(0.2, 1, 0.35, 1)
emission_energy_multiplier = 2.0
[sub_resource type="SphereShape3D" id="Shape_orb"]
radius = 0.8
[node name="HealthOrb" type="Area3D"]
script = ExtResource("1_orb")
[node name="Mesh" type="MeshInstance3D" parent="."]
mesh = SubResource("SphereMesh_orb")
surface_material_override/0 = SubResource("Mat_orb")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("Shape_orb")
[node name="Glow" type="OmniLight3D" parent="."]
light_color = Color(0.4, 1, 0.5, 1)
light_energy = 1.5
omni_range = 3.0
+46 -78
View File
@@ -1,10 +1,15 @@
[gd_scene load_steps=19 format=3 uid="uid://dugaivbj1o66n"] [gd_scene load_steps=16 format=3 uid="uid://dugaivbj1o66n"]
[ext_resource type="Script" uid="uid://d0dgljwwl463n" path="res://level/scripts/level.gd" id="1_e1sh7"] [ext_resource type="Script" uid="uid://d0dgljwwl463n" path="res://level/scripts/level.gd" id="1_e1sh7"]
[ext_resource type="PackedScene" uid="uid://db06e8q8f8bdq" path="res://level/scenes/Player_Lilguy.tscn" id="1_uvcbi"] [ext_resource type="PackedScene" uid="uid://db06e8q8f8bdq" path="res://level/scenes/Player_Lilguy.tscn" id="1_uvcbi"]
[ext_resource type="FontFile" uid="uid://diapabmalpcrj" path="res://assets/fonts/Kurland.ttf" id="3_icc4p"] [ext_resource type="PackedScene" uid="uid://dif4t1y3c07ax" path="res://level/scenes/enemies/practice_dummy.tscn" id="3_i7s07"]
[ext_resource type="PackedScene" uid="uid://b48oxbcgxu3d8" path="res://assets/Objects/Colosseum_10.fbx" id="4_u750a"] [ext_resource type="FontFile" uid="uid://wipqjhfqeuwd" path="res://assets/fonts/Kurland.ttf" id="3_icc4p"]
[ext_resource type="PackedScene" uid="uid://blm8lav3xh2yw" path="res://level/scenes/enemy_spawner.tscn" id="3_spawner"]
[ext_resource type="PackedScene" path="res://level/scenes/enemies/armed_enemy.tscn" id="4_armed"]
[ext_resource type="PackedScene" uid="uid://chkrcwlprbn88" path="res://assets/Objects/Colosseum_10.fbx" id="4_u750a"]
[ext_resource type="PackedScene" uid="uid://hd6pq287rgye" path="res://level/scenes/weapons/world_weapon_testsword.tscn" id="5_cwx4m"] [ext_resource type="PackedScene" uid="uid://hd6pq287rgye" path="res://level/scenes/weapons/world_weapon_testsword.tscn" id="5_cwx4m"]
[ext_resource type="PackedScene" uid="uid://8c4l6s6x67vh" path="res://level/scenes/weapons/world_weapon_applecorer.tscn" id="6_xerh7"]
[ext_resource type="PackedScene" uid="uid://dpk7n3q8mwx2r" path="res://level/scenes/weapons/world_weapon_lobsteraxe.tscn" id="7_lobster"]
[sub_resource type="PlaneMesh" id="PlaneMesh_r5xs5"] [sub_resource type="PlaneMesh" id="PlaneMesh_r5xs5"]
size = Vector2(90, 90) size = Vector2(90, 90)
@@ -13,29 +18,7 @@ size = Vector2(90, 90)
albedo_color = Color(0, 0.321569, 0.172549, 1) albedo_color = Color(0, 0.321569, 0.172549, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_x3h1o"] [sub_resource type="BoxShape3D" id="BoxShape3D_x3h1o"]
size = Vector3(90, 0.05, 90) size = Vector3(288.0893, 0.05, 350.08374)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_lc35d"]
albedo_color = Color(0, 0, 0, 1)
[sub_resource type="BoxMesh" id="BoxMesh_8pl0k"]
[sub_resource type="BoxShape3D" id="BoxShape3D_f43m5"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_womqi"]
albedo_color = Color(0, 0, 0, 1)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_taagp"]
albedo_color = Color(0, 0, 0, 1)
[sub_resource type="BoxMesh" id="BoxMesh_q5fs2"]
size = Vector3(25, 1, 1.5)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_fs7ud"]
albedo_color = Color(0, 0, 0, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_epsao"]
size = Vector3(25, 1, 1.5)
[sub_resource type="Environment" id="Environment_qb4jd"] [sub_resource type="Environment" id="Environment_qb4jd"]
fog_enabled = true fog_enabled = true
@@ -46,6 +29,8 @@ color = Color(0, 0, 0, 0)
[node name="Level" type="Node3D"] [node name="Level" type="Node3D"]
script = ExtResource("1_e1sh7") script = ExtResource("1_e1sh7")
player_scene = ExtResource("1_uvcbi") player_scene = ExtResource("1_uvcbi")
practice_dummy_scene = ExtResource("3_i7s07")
armed_enemy_scene = ExtResource("4_armed")
[node name="Environment" type="Node3D" parent="."] [node name="Environment" type="Node3D" parent="."]
@@ -53,67 +38,19 @@ player_scene = ExtResource("1_uvcbi")
collision_layer = 2 collision_layer = 2
[node name="MeshInstance3D" type="MeshInstance3D" parent="Environment/Floor"] [node name="MeshInstance3D" type="MeshInstance3D" parent="Environment/Floor"]
transform = Transform3D(3.140456, 0, 0, 0, 1, 0, 0, 0, 3.8321724, 0, 0, 0)
mesh = SubResource("PlaneMesh_r5xs5") mesh = SubResource("PlaneMesh_r5xs5")
surface_material_override/0 = SubResource("StandardMaterial3D_o02aj") surface_material_override/0 = SubResource("StandardMaterial3D_o02aj")
[node name="CollisionShape3D" type="CollisionShape3D" parent="Environment/Floor"] [node name="CollisionShape3D" type="CollisionShape3D" parent="Environment/Floor"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.6853943, 0, 1.6468506)
shape = SubResource("BoxShape3D_x3h1o") shape = SubResource("BoxShape3D_x3h1o")
[node name="Box_1" type="StaticBody3D" parent="Environment"]
collision_layer = 2
[node name="MeshInstance3D" type="MeshInstance3D" parent="Environment/Box_1"]
transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, 2.91206, 0.456, 6.91607)
material_override = SubResource("StandardMaterial3D_lc35d")
mesh = SubResource("BoxMesh_8pl0k")
[node name="CollisionShape3D" type="CollisionShape3D" parent="Environment/Box_1"]
transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, 2.91167, 0.456274, 6.91607)
shape = SubResource("BoxShape3D_f43m5")
[node name="Box_2" type="StaticBody3D" parent="Environment"]
collision_layer = 2
[node name="MeshInstance3D" type="MeshInstance3D" parent="Environment/Box_2"]
transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, 2.91206, 2.456, 9.916)
mesh = SubResource("BoxMesh_8pl0k")
surface_material_override/0 = SubResource("StandardMaterial3D_womqi")
[node name="CollisionShape3D" type="CollisionShape3D" parent="Environment/Box_2"]
transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, 2.91167, 2.456, 9.916)
shape = SubResource("BoxShape3D_f43m5")
[node name="Box_3" type="StaticBody3D" parent="Environment"]
collision_layer = 2
[node name="MeshInstance3D" type="MeshInstance3D" parent="Environment/Box_3"]
transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, 2.91206, 4.456, 12.916)
mesh = SubResource("BoxMesh_8pl0k")
surface_material_override/0 = SubResource("StandardMaterial3D_taagp")
[node name="CollisionShape3D" type="CollisionShape3D" parent="Environment/Box_3"]
transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, 2.91167, 4.456, 12.916)
shape = SubResource("BoxShape3D_f43m5")
[node name="Box_4" type="StaticBody3D" parent="Environment"]
transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, 0, 0, 0)
collision_layer = 2
[node name="MeshInstance3D" type="MeshInstance3D" parent="Environment/Box_4"]
transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, -3.52997, 5.947, 17.398)
layers = 2
mesh = SubResource("BoxMesh_q5fs2")
surface_material_override/0 = SubResource("StandardMaterial3D_fs7ud")
[node name="CollisionShape3D" type="CollisionShape3D" parent="Environment/Box_4"]
transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, -3.52997, 5.947, 17.398)
shape = SubResource("BoxShape3D_epsao")
[node name="WorldEnvironment" type="WorldEnvironment" parent="Environment"] [node name="WorldEnvironment" type="WorldEnvironment" parent="Environment"]
environment = SubResource("Environment_qb4jd") environment = SubResource("Environment_qb4jd")
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="Environment"] [node name="DirectionalLight3D" type="DirectionalLight3D" parent="Environment"]
transform = Transform3D(1, 0, 0, 0, -0.5, 0.866025, 0, -0.866025, -0.5, 0, 4, 0) transform = Transform3D(1, 0, 0, 0, -0.50000024, 0.8660253, 0, -0.8660253, -0.50000024, 0, 18.907759, 0)
shadow_enabled = true shadow_enabled = true
shadow_blur = 0.5 shadow_blur = 0.5
@@ -345,13 +282,44 @@ offset_right = 40.0
offset_bottom = 40.0 offset_bottom = 40.0
[node name="Colosseum_10" parent="." instance=ExtResource("4_u750a")] [node name="Colosseum_10" parent="." instance=ExtResource("4_u750a")]
transform = Transform3D(15, 0, 0, 0, 15, 0, 0, 0, 15, 1.301034, -1.2294581, 2.0630608) transform = Transform3D(30, 0, 0, 0, 30, 0, 0, 0, 30, 1.301034, -2.3844016, 2.0630608)
[node name="WeaponsContainer" type="Node3D" parent="."] [node name="WeaponsContainer" type="Node3D" parent="."]
[node name="WorldWeaponSword" parent="WeaponsContainer" instance=ExtResource("5_cwx4m")] [node name="WorldWeaponSword" parent="WeaponsContainer" instance=ExtResource("5_cwx4m")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.0268106, 2.6057472, 8.836907) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.0268106, 2.6057472, 8.836907)
[node name="WorldWeaponSword2" parent="WeaponsContainer" instance=ExtResource("6_xerh7")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.32151043, 5.2709904)
[node name="WorldWeaponLobsterAxe" parent="WeaponsContainer" instance=ExtResource("7_lobster")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3, 0.5, 5)
[node name="EnemiesContainer" type="Node3D" parent="."]
[node name="EnemySpawner" parent="." instance=ExtResource("3_spawner")]
spawn_radius = 100.0
[node name="EnemySpawnPoint1" type="Node3D" parent="EnemySpawner"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 25.138117)
[node name="EnemySpawnPoint2" type="Node3D" parent="EnemySpawner"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -23.835087)
[node name="PlayerSpawnPoints" type="Node3D" parent="."]
[node name="PlayerSpawn1" type="Node3D" parent="PlayerSpawnPoints"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 21.496109, 0, 12.60405)
[node name="PlayerSpawn2" type="Node3D" parent="PlayerSpawnPoints"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 21.496109, 0, -13.144485)
[node name="PlayerSpawn3" type="Node3D" parent="PlayerSpawnPoints"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -16.29047, 0, -13.144485)
[node name="PlayerSpawn4" type="Node3D" parent="PlayerSpawnPoints"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -16.29047, 0, 13.986315)
[connection signal="pressed" from="Menu/MainContainer/MainMenu/Buttons/Host" to="." method="_on_host_pressed"] [connection signal="pressed" from="Menu/MainContainer/MainMenu/Buttons/Host" to="." method="_on_host_pressed"]
[connection signal="pressed" from="Menu/MainContainer/MainMenu/Buttons/Join" to="." method="_on_join_pressed"] [connection signal="pressed" from="Menu/MainContainer/MainMenu/Buttons/Join" to="." method="_on_join_pressed"]
[connection signal="pressed" from="Menu/MainContainer/MainMenu/Option4/Quit" to="." method="_on_quit_pressed"] [connection signal="pressed" from="Menu/MainContainer/MainMenu/Option4/Quit" to="." method="_on_quit_pressed"]
+7 -7
View File
@@ -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
@@ -5055,7 +5055,7 @@ _spring_arm = NodePath("SpringArm3D")
[node name="SpringArm3D" type="SpringArm3D" parent="SpringArmOffset"] [node name="SpringArm3D" type="SpringArm3D" parent="SpringArmOffset"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
spring_length = 5.0 spring_length = 10.0
[node name="Camera3D" type="Camera3D" parent="SpringArmOffset/SpringArm3D"] [node name="Camera3D" type="Camera3D" parent="SpringArmOffset/SpringArm3D"]
current = true current = true
+19
View File
@@ -0,0 +1,19 @@
[gd_scene load_steps=4 format=3 uid="uid://cehc5ckhq2byd"]
[ext_resource type="PackedScene" uid="uid://c3e6e3s2q0uro" path="res://assets/Objects/Applecorer.glb" id="1_yadub"]
[ext_resource type="Script" uid="uid://jyas86y3f0jp" path="res://level/scripts/hit_box.gd" id="2_lq7hu"]
[sub_resource type="BoxShape3D" id="BoxShape3D_ei1hh"]
size = Vector3(0.19293976, 2.9722443, 0.5605469)
[node name="TestSwordMesh" type="Node3D"]
[node name="Applecorer" parent="." instance=ExtResource("1_yadub")]
transform = Transform3D(-0.3, 0, -2.6226834e-08, 0, 0.3, 0, 2.6226834e-08, 0, -0.3, 0, 0, 0)
[node name="HitBox" type="Area3D" parent="."]
script = ExtResource("2_lq7hu")
[node name="CollisionShape3D" type="CollisionShape3D" parent="HitBox"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.0034065247, 2.15522, 0.0234375)
shape = SubResource("BoxShape3D_ei1hh")
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
[gd_scene load_steps=4 format=3 uid="uid://cq8r5mkn3wvxj"]
[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"]
[sub_resource type="BoxShape3D" id="BoxShape3D_lobster"]
size = Vector3(2, 3.2, 0.6)
[node name="LobsterAxeMesh" type="Node3D"]
[node name="LobsterAxe" parent="." instance=ExtResource("1_lobster")]
transform = Transform3D(-1.3113416e-08, 0, 0.29999998, 0, 0.29999998, 0, -0.29999998, 0, -1.3113416e-08, 0, 0.72785115, 0)
[node name="HitBox" type="Area3D" parent="."]
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, 0, 2.1950727, 0)
script = ExtResource("2_hitbox")
[node name="CollisionShape3D" type="CollisionShape3D" parent="HitBox"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.1840072, 0)
shape = SubResource("BoxShape3D_lobster")
+12 -1
View File
@@ -1,8 +1,19 @@
[gd_scene load_steps=2 format=3 uid="uid://rkvkbxlweo60"] [gd_scene load_steps=4 format=3 uid="uid://rkvkbxlweo60"]
[ext_resource type="PackedScene" uid="uid://culurukxpqswh" 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"]
[sub_resource type="BoxShape3D" id="BoxShape3D_5u25i"]
size = Vector3(0.19293976, 2.2154922, 0.5605469)
[node name="TestSwordMesh" type="Node3D"] [node name="TestSwordMesh" type="Node3D"]
[node name="TestSword" parent="." instance=ExtResource("1_4fdvi")] [node name="TestSword" parent="." instance=ExtResource("1_4fdvi")]
transform = Transform3D(0.1, 0, 0, 0, 0.1, 0, 0, 0, 0.1, 0, 0.104662895, 0) transform = Transform3D(0.1, 0, 0, 0, 0.1, 0, 0, 0, 0.1, 0, 0.104662895, 0)
[node name="HitBox" type="Area3D" parent="."]
script = ExtResource("2_3qhqw")
[node name="CollisionShape3D" type="CollisionShape3D" parent="HitBox"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.0034065247, 1.3828986, 0.0234375)
shape = SubResource("BoxShape3D_5u25i")
+2 -2
View File
@@ -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"]
+13 -2
View File
@@ -1,9 +1,20 @@
[gd_scene load_steps=2 format=3 uid="uid://dyjfaq654xne3"] [gd_scene load_steps=4 format=3 uid="uid://dyjfaq654xne3"]
[ext_resource type="ArrayMesh" uid="uid://cc1kxfbkvpo2d" 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"]
[sub_resource type="BoxShape3D" id="BoxShape3D_mhdau"]
size = Vector3(0.19293976, 1.0232315, 0.5605469)
[node name="SwordMesh" type="Node3D"] [node name="SwordMesh" type="Node3D"]
[node name="MeshInstance3D" type="MeshInstance3D" parent="."] [node name="MeshInstance3D" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4.4839063, -2.9492104, -3.4711354) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4.4839063, -2.9492104, -3.4711354)
mesh = ExtResource("1") mesh = ExtResource("1")
[node name="HitBox" type="Area3D" parent="."]
script = ExtResource("2_wyi6r")
[node name="CollisionShape3D" type="CollisionShape3D" parent="HitBox"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.0034065247, 0.6794083, 0.0234375)
shape = SubResource("BoxShape3D_mhdau")
@@ -0,0 +1,17 @@
[gd_scene load_steps=4 format=3 uid="uid://8c4l6s6x67vh"]
[ext_resource type="Script" uid="uid://ccnnd0y4jqiot" path="res://level/scripts/world_weapon.gd" id="1_7688s"]
[ext_resource type="Resource" uid="uid://b2q62xc0jw4w3" path="res://level/resources/weapon_applecorer.tres" id="2_7688s"]
[sub_resource type="BoxShape3D" id="1"]
size = Vector3(0.3, 0.3, 1.2)
[node name="WorldWeaponSword" type="RigidBody3D"]
collision_layer = 4
collision_mask = 2
mass = 2.0
script = ExtResource("1_7688s")
weapon_data = ExtResource("2_7688s")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("1")
@@ -0,0 +1,17 @@
[gd_scene load_steps=4 format=3 uid="uid://dpk7n3q8mwx2r"]
[ext_resource type="Script" uid="uid://ccnnd0y4jqiot" path="res://level/scripts/world_weapon.gd" id="1"]
[ext_resource type="Resource" path="res://level/resources/weapon_lobsteraxe.tres" id="2"]
[sub_resource type="BoxShape3D" id="1"]
size = Vector3(0.4, 0.4, 0.8)
[node name="WorldWeaponLobsterAxe" type="RigidBody3D"]
collision_layer = 4
collision_mask = 2
mass = 2.5
script = ExtResource("1")
weapon_data = ExtResource("2")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("1")
+2 -2
View File
@@ -1,6 +1,6 @@
[gd_scene load_steps=4 format=3] [gd_scene load_steps=4 format=3 uid="uid://byxqw8bg5da2c"]
[ext_resource type="Script" path="res://level/scripts/world_weapon.gd" id="1"] [ext_resource type="Script" uid="uid://ccnnd0y4jqiot" path="res://level/scripts/world_weapon.gd" id="1"]
[ext_resource type="Resource" path="res://level/resources/weapon_sword.tres" id="2"] [ext_resource type="Resource" path="res://level/resources/weapon_sword.tres" id="2"]
[sub_resource type="BoxShape3D" id="1"] [sub_resource type="BoxShape3D" id="1"]
+4
View File
@@ -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":
+735
View File
@@ -0,0 +1,735 @@
extends BaseEnemy
class_name ArmedEnemy
## An enemy that uses the player model, animations, and can equip weapons
## Drops equipped weapons on death using the existing world weapon spawn system
## Movement
@export var move_speed: float = 4.0
@export var chase_range: float = 20.0
@export var attack_range: float = 2.5
## Combat (unarmed fallback)
@export var unarmed_damage: float = 10.0
@export var unarmed_knockback: float = 5.0
@export var attack_cooldown: float = 1.0
@export_category("Unarmed Attack Timing")
@export var unarmed_startup: float = 0.15
@export var unarmed_active: float = 0.2
## Weapon seeking (when unarmed)
@export_category("Weapon Seeking")
@export var seek_weapons_when_unarmed: bool = true ## If true, unarmed enemies will seek nearby weapons
@export var weapon_seek_range: float = 30.0 ## How far to look for weapons
@export var weapon_pickup_range: float = 1.5 ## How close to get before picking up
## Weapon system
@export_category("Weapons")
@export var starting_weapon: WeaponData = null ## Weapon to equip on spawn
@export var starting_offhand: WeaponData = null ## Off-hand weapon to equip on spawn
## Body reference (LilguyBody for animations)
@export var _body: Node3D = null
@export var _weapon_attachment: BoneAttachment3D = null
@export var _weapon_container: Node3D = null
@export var _offhand_attachment: BoneAttachment3D = null
@export var _offhand_container: Node3D = null
## Runtime weapon state
var equipped_weapon: BaseWeapon = null
var equipped_offhand: BaseWeapon = null
## AI State
var _attack_timer: float = 0.0
var _is_attacking: bool = false
var _unarmed_hitbox: HitBox = null
## Visual feedback
var _hit_flash_timer: float = 0.0
const HIT_FLASH_DURATION: float = 0.2
## Position sync (manual sync instead of MultiplayerSynchronizer for dynamic spawning)
var _sync_timer: float = 0.0
const SYNC_INTERVAL: float = 0.05 # 20 times per second
func _enter_tree():
# Enemies are always server-authoritative
set_multiplayer_authority(1)
func _ready():
super._ready()
# Armed gladiators are worth more than basic enemies (arena economy)
gold_reward = 25
# Gladiators die for good - respawning would make them a repeatable gold farm
# (they also don't re-show their body on respawn, so this avoids invisible enemies)
can_respawn = false
# Auto-find body if not set
if _body == null:
if has_node("LilguyRigged/Armature"):
_body = get_node("LilguyRigged/Armature")
# Auto-find weapon attachments
if _weapon_attachment == null:
_weapon_attachment = get_node_or_null("LilguyRigged/Armature/Skeleton3D/WeaponPoint")
if _weapon_container == null and _weapon_attachment:
_weapon_container = _weapon_attachment.get_node_or_null("WeaponContainer")
if _offhand_attachment == null:
_offhand_attachment = get_node_or_null("LilguyRigged/Armature/Skeleton3D/OffhandPoint")
if _offhand_container == null and _offhand_attachment:
_offhand_container = _offhand_attachment.get_node_or_null("OffhandContainer")
# Setup unarmed hitbox
call_deferred("_setup_unarmed_hitbox")
# Equip starting weapons
# Server will equip and send RPC to sync; clients also equip directly to handle late-join
call_deferred("_equip_starting_weapons_local")
## Equip starting weapons - server uses RPC to sync, clients equip directly
func _equip_starting_weapons_local():
# Wait a frame to ensure everything is ready
await get_tree().process_frame
# Check if multiplayer peer is assigned
if multiplayer.multiplayer_peer == null:
# No multiplayer yet, just equip locally
if starting_weapon:
_equip_weapon(starting_weapon, false)
if starting_offhand:
_equip_weapon(starting_offhand, true)
return
if multiplayer.is_server():
# Server equips via RPC to sync to all clients
if starting_weapon:
print("[ArmedEnemy ", name, "] Server equipping starting weapon: ", starting_weapon.resource_path)
rpc("_equip_weapon_sync", starting_weapon.resource_path, false)
if starting_offhand:
print("[ArmedEnemy ", name, "] Server equipping starting offhand: ", starting_offhand.resource_path)
rpc("_equip_weapon_sync", starting_offhand.resource_path, true)
else:
# Client equips directly (for late-join clients who won't receive server's initial RPC)
# Skip if already equipped (from server RPC)
if starting_weapon and not equipped_weapon:
print("[ArmedEnemy ", name, "] Client equipping starting weapon directly")
_equip_weapon(starting_weapon, false)
if starting_offhand and not equipped_offhand:
print("[ArmedEnemy ", name, "] Client equipping starting offhand directly")
_equip_weapon(starting_offhand, true)
## Equip weapon on all clients
@rpc("any_peer", "call_local", "reliable")
func _equip_weapon_sync(weapon_data_path: String, is_offhand: bool):
print("[ArmedEnemy ", name, "] _equip_weapon_sync called on peer ", multiplayer.get_unique_id(), " path: ", weapon_data_path)
if weapon_data_path == "":
push_error("[ArmedEnemy] Empty weapon path!")
return
var data = load(weapon_data_path) as WeaponData
if data:
_equip_weapon(data, is_offhand)
else:
push_error("[ArmedEnemy] Failed to load weapon data from: ", weapon_data_path)
func _equip_weapon(data: WeaponData, is_offhand: bool = false):
# Unequip current weapon in that hand first
if is_offhand:
if equipped_offhand:
_unequip_weapon(true)
else:
if equipped_weapon:
_unequip_weapon(false)
# Determine attachment point
var attach_point: Node3D
if is_offhand:
attach_point = _offhand_container if _offhand_container else _offhand_attachment
else:
attach_point = _weapon_container if _weapon_container else _weapon_attachment
if not attach_point:
push_error("[ArmedEnemy] No weapon attachment point found")
return
# Create weapon instance
var weapon = BaseWeapon.new()
weapon.weapon_data = data
weapon.name = "EquippedOffHand" if is_offhand else "EquippedWeapon"
# Add to scene first (so _ready is called and hitbox is set up)
attach_point.add_child(weapon)
# Set owner for damage routing (must be after add_child so hitbox exists)
weapon.set_owner_character(self)
# Store reference
if is_offhand:
equipped_offhand = weapon
else:
equipped_weapon = weapon
print("[ArmedEnemy ", name, "] Equipped: ", data.weapon_name)
func _unequip_weapon(is_offhand: bool = false):
if is_offhand:
if equipped_offhand:
equipped_offhand.queue_free()
equipped_offhand = null
else:
if equipped_weapon:
equipped_weapon.queue_free()
equipped_weapon = null
func _process(delta):
# Countdown timers
if _attack_timer > 0:
_attack_timer -= delta
# Handle hit flash
if _hit_flash_timer > 0:
_hit_flash_timer -= delta
if _hit_flash_timer <= 0:
_reset_material()
func _physics_process(delta):
super._physics_process(delta)
# Only server runs AI and movement (check peer is assigned first)
if multiplayer.multiplayer_peer == null or not multiplayer.is_server():
return
if is_dead:
return
# Apply gravity
if not is_on_floor():
velocity.y -= ProjectSettings.get_setting("physics/3d/default_gravity") * delta
# AI behavior - prioritize weapon seeking when unarmed
var seeking_weapon = false
if seek_weapons_when_unarmed and equipped_weapon == null:
seeking_weapon = _ai_seek_weapon(delta)
# If not seeking a weapon (or armed), do normal combat
if not seeking_weapon:
if current_target and is_instance_valid(current_target):
_ai_combat(delta)
else:
# Stop moving if no target
velocity.x = 0
velocity.z = 0
move_and_slide()
# Rotate body to face movement direction (like player does)
var body_rotation_y: float = 0.0
if _body and _body.has_method("apply_rotation") and velocity.length() > 0.1:
_body.apply_rotation(velocity)
if _body:
body_rotation_y = _body.rotation.y
# Animate body
if _body and _body.has_method("animate"):
_body.animate(velocity)
# Sync position, rotation, and animation to clients periodically
_sync_timer -= delta
if _sync_timer <= 0:
_sync_timer = SYNC_INTERVAL
var current_anim = ""
if _body:
var anim_player = _body.get_node_or_null("../AnimationPlayer") as AnimationPlayer
if anim_player:
current_anim = anim_player.current_animation
rpc("_sync_transform", global_position, body_rotation_y, current_anim)
## Sync position, body rotation, and animation from server to clients
@rpc("authority", "call_remote", "unreliable")
func _sync_transform(pos: Vector3, body_rot_y: float, anim_name: String = ""):
# Only apply on clients (server is authoritative)
if multiplayer.is_server():
return
global_position = pos
if _body:
_body.rotation.y = body_rot_y
# Sync animation
if anim_name != "":
var anim_player = _body.get_node_or_null("../AnimationPlayer") as AnimationPlayer
if anim_player and anim_player.has_animation(anim_name):
if anim_player.current_animation != anim_name:
anim_player.play(anim_name)
## Override to find nearest player
func get_nearest_player() -> Node:
var players = get_players_in_range(1000.0) # Essentially unlimited
if players.is_empty():
return null
var nearest_player = null
var nearest_distance = INF
for player in players:
var distance = global_position.distance_to(player.global_position)
if distance < nearest_distance:
nearest_distance = distance
nearest_player = player
return nearest_player
## Find the nearest WorldWeapon within range
func get_nearest_world_weapon() -> WorldWeapon:
var level = get_tree().get_current_scene()
if not level:
return null
var weapons_container = level.get_node_or_null("WeaponsContainer")
if not weapons_container:
return null
var nearest_weapon: WorldWeapon = null
var nearest_distance = weapon_seek_range
for child in weapons_container.get_children():
if child is WorldWeapon:
var distance = global_position.distance_to(child.global_position)
if distance < nearest_distance:
nearest_distance = distance
nearest_weapon = child
return nearest_weapon
## Update target
func _update_target():
if not is_aggressive:
return
var nearest = get_nearest_player()
if nearest:
if current_target != nearest:
current_target = nearest
target_changed.emit(nearest)
else:
if current_target != null:
current_target = null
target_changed.emit(null)
## Combat AI
func _ai_combat(delta):
if not current_target or not is_instance_valid(current_target):
return
var target_pos = current_target.global_position
var direction = (target_pos - global_position).normalized()
var distance = global_position.distance_to(target_pos)
# Get attack range - use a close fixed range to ensure hits connect
# The hitbox is on the weapon in the enemy's hand, so we need to be close
var current_attack_range = 2.0 # Fixed close range for melee
# If in attack range, attack
if distance <= current_attack_range:
velocity.x = 0
velocity.z = 0
# Face target while attacking (use body rotation like player does)
if _body and _body.has_method("apply_rotation"):
var face_dir = Vector3(direction.x, 0, direction.z) * move_speed
_body.apply_rotation(face_dir)
if _attack_timer <= 0 and not _is_attacking:
_perform_attack()
else:
# Chase target - velocity direction will be used for body rotation
velocity.x = direction.x * move_speed
velocity.z = direction.z * move_speed
## Weapon seeking AI - returns true if actively seeking a weapon
func _ai_seek_weapon(delta) -> bool:
var nearest_weapon = get_nearest_world_weapon()
if not nearest_weapon or not is_instance_valid(nearest_weapon):
return false
var weapon_pos = nearest_weapon.global_position
var direction = (weapon_pos - global_position).normalized()
var distance = global_position.distance_to(weapon_pos)
# If close enough, pick up the weapon
if distance <= weapon_pickup_range:
velocity.x = 0
velocity.z = 0
_pickup_world_weapon(nearest_weapon)
return true
# Move towards the weapon
velocity.x = direction.x * move_speed
velocity.z = direction.z * move_speed
return true
## Pick up a world weapon (server only)
func _pickup_world_weapon(world_weapon: WorldWeapon):
if not multiplayer.is_server():
return
if not world_weapon or not is_instance_valid(world_weapon):
return
var weapon_data = world_weapon.weapon_data
if not weapon_data:
return
var resource_path = weapon_data.resource_path
if resource_path == "":
push_error("[ArmedEnemy] WorldWeapon has no resource path!")
return
var weapon_id = world_weapon.weapon_id
if weapon_id == -1:
push_error("[ArmedEnemy] WorldWeapon has invalid weapon_id!")
return
print("[ArmedEnemy ", name, "] Picking up weapon: ", weapon_data.weapon_name)
# Equip the weapon on all clients
rpc("_equip_weapon_sync", resource_path, false)
# Remove the world weapon from all clients using level's system
var level = get_tree().get_current_scene()
if level and level.has_method("remove_world_weapon"):
level.remove_world_weapon(weapon_id)
else:
push_error("[ArmedEnemy] Level doesn't have remove_world_weapon method!")
## Perform attack
func _perform_attack():
if _is_attacking or not multiplayer.is_server():
return
# Use weapon if equipped
if equipped_weapon and equipped_weapon.weapon_data:
_perform_weapon_attack()
else:
_perform_unarmed_attack()
func _perform_weapon_attack():
var weapon = equipped_weapon
var data = weapon.weapon_data
var total_duration = data.startup_time + data.active_time
var cooldown = max(data.attack_cooldown, total_duration)
_attack_timer = cooldown
_is_attacking = true
# Play animation on all clients
var anim_name = data.attack_animation if data.attack_animation else "Attack_OneHand"
rpc("_sync_attack_animation", anim_name)
# Use weapon's built-in attack activation
_activate_weapon_hitbox_direct(weapon)
func _perform_unarmed_attack():
var total_duration = unarmed_startup + unarmed_active
var cooldown = max(attack_cooldown, total_duration)
_attack_timer = cooldown
_is_attacking = true
# Play animation
rpc("_sync_attack_animation", "Attack_OneHand")
# Activate unarmed hitbox
_activate_unarmed_hitbox()
## Activate weapon hitbox for attack (direct access to weapon's internal hitbox)
func _activate_weapon_hitbox_direct(weapon: BaseWeapon):
if not weapon or not multiplayer.is_server():
_is_attacking = false
return
var data = weapon.weapon_data
# Access weapon's internal hitbox
var hitbox = weapon._hitbox
if not hitbox:
print("[ArmedEnemy] No hitbox found on weapon, trying to find it")
hitbox = weapon.get_node_or_null("HitBox") as HitBox
if not hitbox:
push_error("[ArmedEnemy] Cannot find hitbox on weapon!")
_is_attacking = false
return
# Make sure hitbox has correct owner
hitbox.owner_entity = self
# STARTUP PHASE
if data.startup_time > 0:
await get_tree().create_timer(data.startup_time).timeout
if not is_instance_valid(hitbox) or is_dead:
_is_attacking = false
return
# ACTIVE PHASE
hitbox.activate()
await get_tree().create_timer(data.active_time).timeout
# RECOVERY PHASE
if hitbox and is_instance_valid(hitbox):
hitbox.deactivate()
_is_attacking = false
## Setup unarmed hitbox
func _setup_unarmed_hitbox():
_unarmed_hitbox = HitBox.new()
_unarmed_hitbox.name = "UnarmedHitBox"
_unarmed_hitbox.owner_entity = self
_unarmed_hitbox.set_stats(unarmed_damage, unarmed_knockback)
# Add collision shape BEFORE adding hitbox to tree (so _ready can find it)
var collision = CollisionShape3D.new()
var sphere = SphereShape3D.new()
sphere.radius = attack_range
collision.shape = sphere
collision.position = Vector3(0, 0.8, -attack_range * 0.75)
_unarmed_hitbox.add_child(collision)
# Now attach the fully configured hitbox to body
if _body:
_body.add_child(_unarmed_hitbox)
else:
add_child(_unarmed_hitbox)
# Connect hit signal
_unarmed_hitbox.hit_landed.connect(_on_hitbox_hit)
func _activate_unarmed_hitbox():
if not _unarmed_hitbox or not multiplayer.is_server():
_is_attacking = false
return
# STARTUP PHASE
if unarmed_startup > 0:
await get_tree().create_timer(unarmed_startup).timeout
if not _unarmed_hitbox or not is_instance_valid(_unarmed_hitbox) or is_dead:
_is_attacking = false
return
# ACTIVE PHASE
_unarmed_hitbox.activate()
await get_tree().create_timer(unarmed_active).timeout
# RECOVERY PHASE
if _unarmed_hitbox and is_instance_valid(_unarmed_hitbox):
_unarmed_hitbox.deactivate()
_is_attacking = false
## Called when hitbox hits something
func _on_hitbox_hit(target: Node, damage_amount: float, knockback_amount: float, attacker_pos: Vector3):
if not target or not multiplayer.is_server():
return
# Flash target's hurtbox
if target is Node:
var hurtbox = target.find_child("HurtBox", true, false)
if hurtbox and hurtbox.has_method("flash_hit"):
hurtbox.flash_hit()
# Apply damage directly (we're server)
if target is BaseUnit:
target.take_damage(damage_amount, 1, knockback_amount, global_position)
## Sync attack animation
@rpc("any_peer", "call_local", "reliable")
func _sync_attack_animation(anim_name: String):
if _body and _body.has_method("play_attack"):
_body.play_attack(anim_name)
## Override hurt animation
@rpc("any_peer", "call_local", "reliable")
func _play_hurt_animation():
_flash_red()
## Flash red when hit
func _flash_red():
_hit_flash_timer = HIT_FLASH_DURATION
# Flash all mesh instances in body
if _body:
var meshes = _find_mesh_instances(_body)
for mesh in meshes:
_apply_red_flash(mesh)
func _find_mesh_instances(node: Node) -> Array[MeshInstance3D]:
var meshes: Array[MeshInstance3D] = []
if node is MeshInstance3D:
meshes.append(node)
for child in node.get_children():
meshes.append_array(_find_mesh_instances(child))
return meshes
func _apply_red_flash(mesh: MeshInstance3D):
if not mesh:
return
for i in range(mesh.get_surface_override_material_count()):
var material = mesh.get_surface_override_material(i)
if not material:
material = mesh.mesh.surface_get_material(i)
if material:
material = material.duplicate()
mesh.set_surface_override_material(i, material)
if material and material is StandardMaterial3D:
material.albedo_color = Color(1.5, 0.3, 0.3)
func _reset_material():
# Reset to a neutral color after flash
if _body:
var meshes = _find_mesh_instances(_body)
for mesh in meshes:
_reset_mesh_material(mesh)
func _reset_mesh_material(mesh: MeshInstance3D):
if not mesh:
return
for i in range(mesh.get_surface_override_material_count()):
var material = mesh.get_surface_override_material(i)
if material and material is StandardMaterial3D:
# Reset to a default enemy color (reddish)
material.albedo_color = Color(0.8, 0.3, 0.3)
## Death callback - drop weapons
func _on_enemy_died(killer_id: int):
super._on_enemy_died(killer_id)
# Hide body
if _body:
_body.visible = false
# Disable collision so players can walk through
var collision_shape = get_node_or_null("CollisionShape3D")
if collision_shape:
collision_shape.disabled = true
# Disable hurtbox
var hurtbox = get_node_or_null("HurtBox")
if hurtbox:
hurtbox.monitoring = false
hurtbox.monitorable = false
# Deactivate hitboxes
if _unarmed_hitbox:
_unarmed_hitbox.deactivate()
# Drop equipped weapons (server only)
if multiplayer.is_server():
_drop_all_weapons()
print("[ArmedEnemy ", name, "] killed by ", killer_id)
## Drop all equipped weapons
func _drop_all_weapons():
if not multiplayer.is_server():
return
# Drop main hand weapon
if equipped_weapon and equipped_weapon.weapon_data:
_spawn_dropped_weapon(equipped_weapon.weapon_data, false)
# Drop off-hand weapon
if equipped_offhand and equipped_offhand.weapon_data:
_spawn_dropped_weapon(equipped_offhand.weapon_data, true)
# Clear equipped weapons on all clients
rpc("_clear_equipped_weapons")
## Spawn a dropped weapon in the world
func _spawn_dropped_weapon(data: WeaponData, is_offhand: bool):
if not multiplayer.is_server():
return
var resource_path = data.resource_path
if resource_path == "":
push_error("[ArmedEnemy] WeaponData has no resource path!")
return
# Calculate spawn position with slight offset and upward velocity
var offset = Vector3.ZERO
if is_offhand:
offset = transform.basis.x * -0.5 # Left side
else:
offset = transform.basis.x * 0.5 # Right side
var spawn_pos = global_position + offset
spawn_pos.y += 1.5 # Spawn above death position
# Random velocity to scatter weapons
var velocity = Vector3(
randf_range(-2.0, 2.0),
randf_range(3.0, 5.0), # Upward
randf_range(-2.0, 2.0)
)
# Use level's weapon spawning system
var level = get_tree().get_current_scene()
if level and level.has_method("spawn_world_weapon"):
level._weapon_spawn_counter += 1
level.rpc("spawn_world_weapon", resource_path, spawn_pos, velocity, level._weapon_spawn_counter)
print("[ArmedEnemy ", name, "] Dropped weapon: ", data.weapon_name)
## Clear equipped weapons on all clients
@rpc("any_peer", "call_local", "reliable")
func _clear_equipped_weapons():
_unequip_weapon(false)
_unequip_weapon(true)
## Respawn callback
func _on_enemy_respawned():
super._on_enemy_respawned()
# Show body
if _body:
_body.visible = true
_reset_material()
# Re-enable collision
var collision_shape = get_node_or_null("CollisionShape3D")
if collision_shape:
collision_shape.disabled = false
# Re-enable hurtbox
var hurtbox = get_node_or_null("HurtBox")
if hurtbox:
hurtbox.monitoring = false # Hurtbox doesn't monitor, it's monitored
hurtbox.monitorable = true
# Reset state
_attack_timer = 0.0
_is_attacking = false
# Re-equip starting weapons
call_deferred("_equip_starting_weapons_local")
print("[ArmedEnemy ", name, "] respawned")
## Set enemy color (hue-based like player)
func set_enemy_color(hue: float):
if _body and _body.has_method("set_character_color"):
_body.set_character_color(hue)
+1
View File
@@ -0,0 +1 @@
uid://deefoag762nvc
+110
View File
@@ -0,0 +1,110 @@
extends BaseUnit
class_name BaseEnemy
## Base class for all enemies in the game
## Provides common enemy functionality like AI, pathfinding, and targeting
signal target_changed(new_target: Node)
## Current target (usually a player)
var current_target: Node = null
## Enemy detection/aggro range
@export var detection_range: float = 10.0
## Whether this enemy is aggressive (will attack players)
@export var is_aggressive: bool = true
## Gold credited to the killing player (arena economy)
@export var gold_reward: int = 10
func _ready():
super._ready()
# Enemies should respawn by default
can_respawn = true
# Connect to health signals for AI reactions
health_changed.connect(_on_enemy_health_changed)
died.connect(_on_enemy_died)
respawned.connect(_on_enemy_respawned)
func _physics_process(delta):
# Only server handles enemy AI (check peer is assigned first)
if multiplayer.multiplayer_peer == null or not multiplayer.is_server():
return
if is_dead:
return
# Update target if needed
_update_target()
## Find and update current target
func _update_target():
# Subclasses can override this to implement custom targeting logic
pass
## Get all players in range
func get_players_in_range(range_dist: float) -> Array[Node]:
var players_in_range: Array[Node] = []
# Find the players container
var level = get_tree().get_current_scene()
if not level or not level.has_node("PlayersContainer"):
return players_in_range
var players_container = level.get_node("PlayersContainer")
for player in players_container.get_children():
if player is Character and not player.is_dead:
var distance = global_position.distance_to(player.global_position)
if distance <= range_dist:
players_in_range.append(player)
return players_in_range
## Get nearest player
func get_nearest_player() -> Node:
var players = get_players_in_range(detection_range)
if players.is_empty():
return null
var nearest_player = null
var nearest_distance = INF
for player in players:
var distance = global_position.distance_to(player.global_position)
if distance < nearest_distance:
nearest_distance = distance
nearest_player = player
return nearest_player
## Health changed callback
func _on_enemy_health_changed(old_health: float, new_health: float):
# Subclasses can override to react to damage
pass
## Death callback
func _on_enemy_died(killer_id: int):
print("[Enemy ", name, "] died. Killer ID: ", killer_id)
# Subclasses can override for death effects
pass
## Respawn callback
func _on_enemy_respawned():
print("[Enemy ", name, "] respawned at ", global_position)
# Clear target on respawn
current_target = null
target_changed.emit(null)
# Subclasses can override for respawn effects
pass
## Override hurt animation
@rpc("any_peer", "call_local", "reliable")
func _play_hurt_animation():
# Flash red or play hurt animation
# Subclasses should implement this with their specific animations
pass
+1
View File
@@ -0,0 +1 @@
uid://base_enemy_script
+29
View File
@@ -11,15 +11,29 @@ signal respawned()
@export var max_health: float = 100.0 @export var max_health: float = 100.0
@export var can_respawn: bool = true @export var can_respawn: bool = true
@export var respawn_delay: float = 3.0 @export var respawn_delay: float = 3.0
@export var health_regen: float = 0.0 ## HP restored per second by passive regen (server-applied). 0 = disabled.
@export var regen_delay: float = 4.0 ## Seconds to wait after taking damage before regen resumes.
const REGEN_TICK := 0.5 # Seconds between regen applications
var current_health: float = 100.0 var current_health: float = 100.0
var is_dead: bool = false var is_dead: bool = false
var _respawn_point: Vector3 = Vector3.ZERO var _respawn_point: Vector3 = Vector3.ZERO
var _last_damage_time: float = -1000.0
func _ready(): func _ready():
current_health = max_health current_health = max_health
_respawn_point = global_position _respawn_point = global_position
# Server drives a passive regen tick for any unit with health_regen > 0
if multiplayer.is_server() and health_regen > 0.0:
var regen_timer := Timer.new()
regen_timer.name = "RegenTimer"
regen_timer.wait_time = REGEN_TICK
regen_timer.autostart = true
add_child(regen_timer)
regen_timer.timeout.connect(_on_regen_tick)
func _enter_tree(): func _enter_tree():
set_multiplayer_authority(str(name).to_int()) set_multiplayer_authority(str(name).to_int())
@@ -34,6 +48,9 @@ func take_damage(amount: float, attacker_id: int = -1, knockback: float = 0.0, a
if is_dead: if is_dead:
return return
# Record when we were last hit so passive regen can pause briefly
_last_damage_time = Time.get_ticks_msec() / 1000.0
# Apply blocking reduction if applicable (duck typing - check if method exists) # Apply blocking reduction if applicable (duck typing - check if method exists)
var final_damage = amount var final_damage = amount
var final_knockback = knockback var final_knockback = knockback
@@ -68,6 +85,18 @@ func take_damage(amount: float, attacker_id: int = -1, knockback: float = 0.0, a
if current_health <= 0: if current_health <= 0:
_die(attacker_id) _die(attacker_id)
## Server-side passive regeneration tick (driven by RegenTimer)
func _on_regen_tick():
if not multiplayer.is_server():
return
if is_dead or health_regen <= 0.0 or current_health >= max_health:
return
# Hold off regen for a short window after taking damage
var now := Time.get_ticks_msec() / 1000.0
if now - _last_damage_time < regen_delay:
return
heal(health_regen * REGEN_TICK)
## Heal the unit ## Heal the unit
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
func heal(amount: float): func heal(amount: float):
+157 -54
View File
@@ -3,20 +3,24 @@ class_name BaseWeapon
## Base class for equipped weapons ## Base class for equipped weapons
## Attached to player's hand via BoneAttachment3D ## Attached to player's hand via BoneAttachment3D
## Provides common weapon functionality and stats ## Uses HitBox/HurtBox system for damage detection
signal attack_performed() signal attack_performed()
signal hit_connected(target: Node)
@export var weapon_data: WeaponData @export var weapon_data: WeaponData
# Runtime references # Runtime references
var owner_character: Character = null var owner_character: Node = null # Can be Character or ArmedEnemy
var _mesh_instance: Node3D = null var _mesh_instance: Node3D = null
var _attack_timer: float = 0.0 var _attack_timer: float = 0.0
var _hitbox: HitBox = null
var _is_attacking: bool = false # Prevents overlapping attacks
func _ready(): func _ready():
if weapon_data and weapon_data.mesh_scene: if weapon_data and weapon_data.mesh_scene:
_spawn_mesh() _spawn_mesh()
_setup_hitbox()
func _process(delta): func _process(delta):
if _attack_timer > 0: if _attack_timer > 0:
@@ -32,85 +36,184 @@ func _spawn_mesh():
_mesh_instance = weapon_data.mesh_scene.instantiate() _mesh_instance = weapon_data.mesh_scene.instantiate()
add_child(_mesh_instance) add_child(_mesh_instance)
# Check if mesh has a HitBox child, use it instead of auto-generated one
var mesh_hitbox = _mesh_instance.get_node_or_null("HitBox")
if mesh_hitbox and mesh_hitbox is HitBox:
# Use the hitbox from the mesh scene
print("[BaseWeapon] Found manual HitBox in mesh scene")
if _hitbox:
_hitbox.queue_free()
_hitbox = mesh_hitbox
_configure_hitbox()
else:
print("[BaseWeapon] No manual HitBox found, will auto-generate")
## Setup the hitbox for this weapon
func _setup_hitbox():
# Skip if we already have a hitbox (e.g., from mesh scene)
if _hitbox:
return
# Create hitbox dynamically based on weapon range
_hitbox = HitBox.new()
_hitbox.name = "HitBox"
# Add collision shape BEFORE adding hitbox to tree (so _ready can find it)
var collision = CollisionShape3D.new()
var sphere = SphereShape3D.new()
var range_val = weapon_data.attack_range if weapon_data else 1.5
sphere.radius = range_val
collision.shape = sphere
_hitbox.add_child(collision)
# Now add the fully configured hitbox to the scene
add_child(_hitbox)
_configure_hitbox()
## Configure hitbox with weapon stats and owner
func _configure_hitbox():
if not _hitbox:
return
# Set damage stats from weapon
if weapon_data:
_hitbox.set_stats(weapon_data.damage, weapon_data.knockback_force)
# Set owner to prevent self-damage
_hitbox.owner_entity = owner_character
# Connect to hit signal
if not _hitbox.hit_landed.is_connected(_on_hitbox_hit):
_hitbox.hit_landed.connect(_on_hitbox_hit)
## Called when hitbox connects with a hurtbox
func _on_hitbox_hit(target: Node, damage_amount: float, knockback_amount: float, attacker_pos: Vector3):
if not target or not owner_character:
return
# Flash the target's hurtbox red for visual feedback
if target is Node:
var hurtbox = target.find_child("HurtBox", true, false)
if hurtbox and hurtbox.has_method("flash_hit"):
hurtbox.flash_hit()
hit_connected.emit(target)
# Route damage through server
var attacker_id = multiplayer.get_unique_id()
# Check if owner has _server_apply_damage (Character has it, ArmedEnemy doesn't)
if owner_character.has_method("_server_apply_damage"):
if multiplayer.is_server():
# We are server, apply directly
owner_character._server_apply_damage(
target.name,
damage_amount,
attacker_id,
knockback_amount,
attacker_pos
)
else:
# Send to server
owner_character.rpc_id(1, "_server_apply_damage",
target.name,
damage_amount,
attacker_id,
knockback_amount,
attacker_pos
)
else:
# ArmedEnemy or other entity - apply damage directly if we're server
if multiplayer.is_server() and target is BaseUnit:
target.take_damage(damage_amount, 1, knockback_amount, attacker_pos)
## Perform an attack with this weapon ## Perform an attack with this weapon
## Called by the character who owns this weapon ## Called by the character who owns this weapon
func perform_attack() -> bool: func perform_attack() -> bool:
if not weapon_data or not owner_character: if not weapon_data or not owner_character:
return false return false
# Check cooldown # Check cooldown and if already attacking
if _attack_timer > 0: if _attack_timer > 0 or _is_attacking:
return false return false
_attack_timer = weapon_data.attack_cooldown # Calculate total attack duration (startup + active)
var startup = weapon_data.startup_time if weapon_data else 0.15
var active = weapon_data.active_time if weapon_data else 0.2
var total_duration = startup + active
# Notify owner character of attack cooldown (for UI) # Set cooldown to at least cover the full attack duration
if owner_character and owner_character.is_multiplayer_authority(): var cooldown = max(weapon_data.attack_cooldown, total_duration)
owner_character._attack_timer = weapon_data.attack_cooldown _attack_timer = cooldown
_is_attacking = true
# Play attack animation on owner # Notify owner character of attack cooldown (for UI) - only for Characters
if owner_character._body: if owner_character.is_multiplayer_authority() and "_attack_timer" in owner_character:
owner_character._body.play_attack() owner_character._attack_timer = cooldown
# Delay damage until animation hits (roughly 70% through the animation) # Play attack animation on owner (use weapon's animation)
# This makes the damage apply when the swing actually connects if "_body" in owner_character and owner_character._body:
var damage_delay = weapon_data.attack_cooldown * 0.4 # Adjust this multiplier to change when damage happens var anim_name = weapon_data.attack_animation if weapon_data.attack_animation else "Attack_OneHand"
get_tree().create_timer(damage_delay).timeout.connect(_find_and_damage_targets) if owner_character._body.has_method("play_attack"):
owner_character._body.play_attack(anim_name)
# Sync animation to other clients if method exists
if owner_character.has_method("_sync_attack_animation"):
owner_character._sync_attack_animation.rpc(anim_name)
# Activate hitbox for the attack duration
# For players: only activate on authority
# For enemies: they are server-authoritative, so check if we're server
var should_activate = owner_character.is_multiplayer_authority() or multiplayer.is_server()
if should_activate:
_activate_hitbox()
attack_performed.emit() attack_performed.emit()
return true return true
## Find targets in range and apply damage ## Activate the hitbox for attack detection - waits for startup, then activates
func _find_and_damage_targets(): func _activate_hitbox():
if not owner_character: if not _hitbox:
_is_attacking = false
return return
# Check if the owner character has authority (not this node) # Update owner reference in case it changed
if not owner_character.is_multiplayer_authority(): _hitbox.owner_entity = owner_character
# STARTUP PHASE - Wait before activating (wind-up animation)
var startup = weapon_data.startup_time if weapon_data else 0.15
if startup > 0:
await get_tree().create_timer(startup).timeout
# Check if weapon/hitbox is still valid after await
if not _hitbox or not is_instance_valid(_hitbox):
_is_attacking = false
return return
var space_state = get_world_3d().direct_space_state # ACTIVE PHASE - Hitbox on, can deal damage
var query = PhysicsShapeQueryParameters3D.new() _hitbox.activate()
var sphere = SphereShape3D.new()
sphere.radius = weapon_data.attack_range
query.shape = sphere
query.transform = global_transform
query.collision_mask = 1 # Player layer
var results = space_state.intersect_shape(query) # Wait for active duration
var active = weapon_data.active_time if weapon_data else 0.2
await get_tree().create_timer(active).timeout
for result in results: # RECOVERY PHASE - Hitbox off
var hit_body = result["collider"] if _hitbox and is_instance_valid(_hitbox):
if hit_body != owner_character and hit_body is BaseUnit: _hitbox.deactivate()
var attacker_id = multiplayer.get_unique_id()
# If we're the server, apply damage directly # Attack complete
if multiplayer.is_server(): _is_attacking = false
owner_character._server_apply_damage(
hit_body.name,
weapon_data.damage,
attacker_id,
weapon_data.knockback_force,
owner_character.global_position
)
else:
# Otherwise, request server to apply damage
owner_character.rpc_id(1, "_server_apply_damage",
hit_body.name,
weapon_data.damage,
attacker_id,
weapon_data.knockback_force,
owner_character.global_position
)
break # Only hit one target per attack
## Check if weapon can attack ## Check if weapon can attack
func can_attack() -> bool: func can_attack() -> bool:
return _attack_timer <= 0 return _attack_timer <= 0 and not _is_attacking
## Set the character who owns this weapon ## Set the character who owns this weapon (can be Character or ArmedEnemy)
func set_owner_character(character: Character): func set_owner_character(character: Node):
owner_character = character owner_character = character
# Update hitbox owner
if _hitbox:
_hitbox.owner_entity = character
## Get weapon stats ## Get weapon stats
func get_damage() -> float: func get_damage() -> float:
+337
View File
@@ -0,0 +1,337 @@
extends BaseEnemy
class_name BasicEnemy
## A basic melee enemy that chases and attacks players
## Server-authoritative AI with multiplayer support
## Movement
@export var move_speed: float = 3.0
@export var chase_range: float = 15.0
@export var attack_range: float = 2.5
## Combat
@export var attack_damage: float = 15.0
@export var attack_knockback: float = 10.0
@export var attack_cooldown: float = 1.5
@export var health_orb_drop_chance: float = 0.4 ## Chance (0-1) to drop a health orb on death
@export_category("Attack Timing")
@export var attack_startup: float = 0.3 # Wind-up before hit
@export var attack_active: float = 0.4 # Hit window duration
## References
var _hitbox: HitBox = null
var _hurtbox: HurtBox = null
var _mesh: MeshInstance3D = null
## AI State
var _attack_timer: float = 0.0
var _is_attacking: bool = false
var _original_material: Material = null
var _hit_flash_timer: float = 0.0
const HIT_FLASH_DURATION: float = 0.2
func _enter_tree():
# Enemies are always server-authoritative
set_multiplayer_authority(1)
func _ready():
super._ready()
# Wave lobsters die for good — no reviving (BaseEnemy defaults this to true)
can_respawn = false
# Find mesh, hitbox, and hurtbox
_mesh = get_node_or_null("Mesh")
_hitbox = get_node_or_null("HitBox")
_hurtbox = get_node_or_null("HurtBox")
# Store original material for hit flash
if _mesh:
_original_material = _mesh.get_surface_override_material(0)
if not _original_material and _mesh.mesh:
_original_material = _mesh.mesh.surface_get_material(0)
# Setup hitbox
if _hitbox:
_hitbox.owner_entity = self
_hitbox.set_stats(attack_damage, attack_knockback)
_hitbox.hit_landed.connect(_on_hitbox_hit)
# Setup hurtbox
if _hurtbox:
_hurtbox.owner_entity = self
func _process(delta):
# Countdown timers
if _attack_timer > 0:
_attack_timer -= delta
# Handle hit flash
if _hit_flash_timer > 0:
_hit_flash_timer -= delta
if _hit_flash_timer <= 0:
_reset_material()
func _physics_process(delta):
super._physics_process(delta)
# Only server runs AI
if not multiplayer.is_server():
return
if is_dead:
return
# Apply gravity
if not is_on_floor():
velocity.y -= ProjectSettings.get_setting("physics/3d/default_gravity") * delta
# AI behavior
if current_target and is_instance_valid(current_target):
_ai_combat(delta)
else:
# Stop moving if no target
velocity.x = 0
velocity.z = 0
move_and_slide()
## Override to find nearest player without range limit
func get_nearest_player() -> Node:
# Find all players in a very large range (essentially unlimited)
var players = get_players_in_range(1000.0) # 1000m range - basically unlimited
if players.is_empty():
return null
var nearest_player = null
var nearest_distance = INF
for player in players:
var distance = global_position.distance_to(player.global_position)
if distance < nearest_distance:
nearest_distance = distance
nearest_player = player
return nearest_player
## Update target - called by BaseEnemy
func _update_target():
if not is_aggressive:
return
# Always find and target the nearest player (no range limit)
var nearest = get_nearest_player()
if nearest:
# Update target if it changed
if current_target != nearest:
current_target = nearest
target_changed.emit(nearest)
else:
# No players exist
if current_target != null:
current_target = null
target_changed.emit(null)
## Combat AI behavior
func _ai_combat(delta):
if not current_target or not is_instance_valid(current_target):
return
var target_pos = current_target.global_position
var direction = (target_pos - global_position).normalized()
var distance = global_position.distance_to(target_pos)
# Face the target
if direction.length() > 0.01:
var look_dir = Vector3(direction.x, 0, direction.z)
if look_dir.length() > 0.01:
look_at(global_position + look_dir, Vector3.UP)
# If in attack range, attack
if distance <= attack_range:
# Stop moving when attacking
velocity.x = 0
velocity.z = 0
# Try to attack
if _attack_timer <= 0 and not _is_attacking:
_perform_attack()
else:
# Always chase the target (no range limit)
velocity.x = direction.x * move_speed
velocity.z = direction.z * move_speed
## Perform melee attack
func _perform_attack():
if _is_attacking or not multiplayer.is_server():
return
# Calculate total attack duration
var total_duration = attack_startup + attack_active
var cooldown = max(attack_cooldown, total_duration)
_attack_timer = cooldown
_is_attacking = true
# Play attack animation on all clients
rpc("_sync_attack_animation")
# Activate hitbox
_activate_hitbox()
## Activate hitbox for attack
func _activate_hitbox():
if not _hitbox or not multiplayer.is_server():
_is_attacking = false
return
# STARTUP PHASE - Wait before activating
if attack_startup > 0:
await get_tree().create_timer(attack_startup).timeout
if not _hitbox or not is_instance_valid(_hitbox) or is_dead:
_is_attacking = false
return
# ACTIVE PHASE - Hitbox on
_hitbox.activate()
await get_tree().create_timer(attack_active).timeout
# RECOVERY PHASE - Hitbox off
if _hitbox and is_instance_valid(_hitbox):
_hitbox.deactivate()
_is_attacking = false
## Called when hitbox hits something
func _on_hitbox_hit(target: Node, damage_amount: float, knockback_amount: float, attacker_pos: Vector3):
if not target or not multiplayer.is_server():
return
# Flash target's hurtbox
if target is Node:
var hurtbox = target.find_child("HurtBox", true, false)
if hurtbox and hurtbox.has_method("flash_hit"):
hurtbox.flash_hit()
# Server applies damage directly
if target is BaseUnit:
target.take_damage(damage_amount, 1, knockback_amount, global_position)
## Play attack animation (synced to all clients)
@rpc("any_peer", "call_local", "reliable")
func _sync_attack_animation():
# Visual feedback for attack
# Could play animation here if you have an AnimationPlayer
pass
## Override hurt animation to flash red
@rpc("any_peer", "call_local", "reliable")
func _play_hurt_animation():
if _mesh:
_flash_red()
## Flash red when hit
func _flash_red():
if not _mesh:
return
_hit_flash_timer = HIT_FLASH_DURATION
# Create red material
var red_material = StandardMaterial3D.new()
red_material.albedo_color = Color(1.5, 0.3, 0.3)
# Copy properties from original if exists
if _original_material and _original_material is StandardMaterial3D:
var orig = _original_material as StandardMaterial3D
red_material.metallic = orig.metallic
red_material.roughness = orig.roughness
red_material.albedo_texture = orig.albedo_texture
_mesh.set_surface_override_material(0, red_material)
## Reset material
func _reset_material():
if _mesh and _original_material:
_mesh.set_surface_override_material(0, _original_material.duplicate())
## Handle death: drop loot, then despawn (server-authoritative, fires once per death)
func _die(killer_id: int):
var already_dead = is_dead
super._die(killer_id)
if not multiplayer.is_server() or already_dead:
return
# Chance to drop a health orb
if randf() < health_orb_drop_chance:
var level = get_tree().get_current_scene()
if level and level.has_method("spawn_health_orb"):
level.spawn_health_orb(global_position + Vector3.UP)
# Non-reviving enemies remove their corpse on every client after a short beat
if not can_respawn:
_schedule_despawn()
## Free this enemy on all peers a couple seconds after death
func _schedule_despawn():
await get_tree().create_timer(2.0).timeout
if is_instance_valid(self):
rpc("_despawn")
@rpc("any_peer", "call_local", "reliable")
func _despawn():
queue_free()
## Death callback
func _on_enemy_died(killer_id: int):
super._on_enemy_died(killer_id)
# Hide when dead
if _mesh:
_mesh.visible = false
# Disable collision so players can walk through
var collision_shape = get_node_or_null("CollisionShape3D")
if collision_shape:
collision_shape.disabled = true
# Disable hurtbox
var hurtbox = get_node_or_null("HurtBox")
if hurtbox:
hurtbox.monitorable = false
# Deactivate hitbox
if _hitbox:
_hitbox.deactivate()
print("[BasicEnemy ", name, "] killed by ", killer_id)
## Respawn callback
func _on_enemy_respawned():
super._on_enemy_respawned()
# Show mesh
if _mesh:
_mesh.visible = true
_reset_material()
# Re-enable collision
var collision_shape = get_node_or_null("CollisionShape3D")
if collision_shape:
collision_shape.disabled = false
# Re-enable hurtbox
var hurtbox = get_node_or_null("HurtBox")
if hurtbox:
hurtbox.monitorable = true
# Reset state
_attack_timer = 0.0
_is_attacking = false
print("[BasicEnemy ", name, "] respawned")
+1
View File
@@ -0,0 +1 @@
uid://cd87rsuiqhdav
+34
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://b7mbsg83olhh4
+344
View File
@@ -0,0 +1,344 @@
extends Node3D
class_name EnemySpawner
## Spawns enemies in waves around the arena edge
## Server-authoritative spawning with multiplayer sync
signal wave_started(wave_number: int)
signal wave_completed(wave_number: int)
signal enemy_spawned(enemy: Node)
signal all_enemies_defeated()
## Spawn configuration
@export_category("Spawn Settings")
@export var spawn_radius: float = 20.0 ## Distance from spawner center to spawn enemies
@export var spawn_height: float = 0.5 ## Height above ground to spawn
@export var enemies_per_wave: int = 3 ## How many enemies spawn per wave
@export var auto_start_next_wave: bool = false ## Auto-start next wave when all defeated
@export var wave_delay: float = 5.0 ## Delay before next wave auto-starts
@export_category("Timed Waves")
@export var auto_waves: bool = true ## Spawn a fresh wave on a repeating timer
@export var wave_interval: float = 15.0 ## Seconds between timed waves
@export var first_wave_delay: float = 3.0 ## Delay before the very first timed wave
@export_category("Enemy Pool")
@export var enemy_scenes: Array[PackedScene] = [] ## List of enemy scenes to spawn from
@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
var current_wave: int = 0
var active_enemies: Array[Node] = []
var _wave_delay_timer: float = 0.0
var _waiting_for_next_wave: bool = false
var _enemy_id_counter: int = 0
var _auto_waves_active: bool = false
var _auto_wave_timer: float = 0.0
## Debug visualization
@export var show_spawn_radius: bool = false ## Show spawn radius circle in game
var _debug_circle: MeshInstance3D = null
func _ready():
print("[EnemySpawner] Ready. Server: ", multiplayer.is_server())
_create_debug_circle()
## Begin spawning waves automatically on a timer (server only)
func start_auto_waves():
if not multiplayer.is_server() or not auto_waves:
return
_auto_waves_active = true
_auto_wave_timer = first_wave_delay
print("[EnemySpawner] Auto waves enabled - first wave in ", first_wave_delay, "s, then every ", wave_interval, "s")
## Stop the timed-wave loop
func stop_auto_waves():
_auto_waves_active = false
## Start a new wave
func start_wave():
if not multiplayer.is_server():
return
if not is_inside_tree():
push_error("[EnemySpawner] Cannot start wave - spawner not in scene tree!")
return
if enemy_scenes.is_empty():
push_error("[EnemySpawner] No enemy scenes configured!")
return
current_wave += 1
print("[EnemySpawner] Starting wave ", current_wave)
wave_started.emit(current_wave)
# Broadcast wave number to every peer's GameState (for HUD + run stats)
GameState.server_sync_wave(current_wave)
# 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
func _spawn_enemy(index: int, total: int):
if not multiplayer.is_server():
return
if not is_inside_tree():
push_error("[EnemySpawner] Cannot spawn enemy - spawner not in scene tree!")
return
if enemy_scenes.is_empty():
return
# Pick a random enemy scene from the pool
var enemy_scene = enemy_scenes[randi() % enemy_scenes.size()]
# Calculate spawn position in a circle
var angle = (TAU / total) * index # Evenly distribute around circle
var offset = Vector3(
cos(angle) * spawn_radius,
spawn_height,
sin(angle) * spawn_radius
)
var spawn_pos = global_position + offset
# Generate unique name for multiplayer sync
_enemy_id_counter += 1
var enemy_name = "Enemy_" + str(current_wave) + "_" + str(_enemy_id_counter)
# Spawn on all clients via RPC (call_local will spawn on server too)
var enemy_scene_path = enemy_scene.resource_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("any_peer", "call_local", "reliable")
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
var enemy_scene = load(scene_path)
if not enemy_scene:
push_error("[EnemySpawner] Failed to load enemy scene: ", scene_path)
return
# Instantiate enemy
var enemy = enemy_scene.instantiate()
enemy.name = enemy_name
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
var level = get_tree().get_current_scene()
var enemies_container = null
if level and level.has_node("EnemiesContainer"):
enemies_container = level.get_node("EnemiesContainer")
else:
push_error("[EnemySpawner] EnemiesContainer not found!")
return
# Add to scene
enemies_container.add_child(enemy, true)
# Only server tracks active enemies and connects signals
if multiplayer.is_server():
active_enemies.append(enemy)
# Connect death signal if enemy is BaseEnemy
if enemy is BaseEnemy:
enemy.died.connect(_on_enemy_died.bind(enemy))
enemy_spawned.emit(enemy)
print("[EnemySpawner] Spawned ", enemy.name, " at ", enemy.global_position, " on peer ", multiplayer.get_unique_id())
## Called when an enemy dies
func _on_enemy_died(killer_id: int, enemy: Node):
if not multiplayer.is_server():
return
print("[EnemySpawner] Enemy ", enemy.name, " defeated by ", killer_id)
# Credit kill gold to the killing player (server decides, credits their peer)
if killer_id > 0 and Network.players.has(killer_id) and enemy is BaseEnemy:
GameState.server_credit_kill(killer_id, enemy.gold_reward)
# Will be cleaned up in _update_active_enemies
## Update list of active enemies and check if wave complete
func _update_active_enemies():
if not multiplayer.is_server():
return
# Remove dead/invalid enemies from active list
var alive_count = 0
for enemy in active_enemies:
if is_instance_valid(enemy) and enemy is BaseEnemy and not enemy.is_dead:
alive_count += 1
# Check if wave is complete
if active_enemies.size() > 0 and alive_count == 0:
_on_wave_completed()
## Called when all enemies in wave are defeated
func _on_wave_completed():
if not multiplayer.is_server():
return
print("[EnemySpawner] Wave ", current_wave, " completed!")
wave_completed.emit(current_wave)
# Wave-clear bonus for every living player (scales with wave number)
var wave_bonus: int = 25 + current_wave * 5
for peer_id in Network.players.keys():
GameState.server_credit_gold(peer_id, wave_bonus)
# Clean up dead enemies after a delay
await get_tree().create_timer(2.0).timeout
_cleanup_dead_enemies()
all_enemies_defeated.emit()
# Auto-start next wave if enabled
if auto_start_next_wave:
_waiting_for_next_wave = true
_wave_delay_timer = wave_delay
print("[EnemySpawner] Next wave starts in ", wave_delay, " seconds")
## Remove dead enemies from the scene
func _cleanup_dead_enemies():
var cleaned = 0
for enemy in active_enemies:
if is_instance_valid(enemy) and enemy is BaseEnemy and enemy.is_dead:
enemy.queue_free()
cleaned += 1
active_enemies.clear()
print("[EnemySpawner] Cleaned up ", cleaned, " defeated enemies")
## Manual wave control
func start_next_wave():
if not multiplayer.is_server():
return
start_wave()
func stop_waves():
_waiting_for_next_wave = false
## Get current wave info
func get_alive_enemy_count() -> int:
var count = 0
for enemy in active_enemies:
if is_instance_valid(enemy) and enemy is BaseEnemy and not enemy.is_dead:
count += 1
return count
func is_wave_active() -> bool:
return get_alive_enemy_count() > 0
## Create debug visualization circle
func _create_debug_circle():
# Create a circle mesh to show spawn radius
_debug_circle = MeshInstance3D.new()
_debug_circle.name = "DebugSpawnCircle"
# Create circle mesh using ImmediateMesh
var immediate_mesh = ImmediateMesh.new()
var material = StandardMaterial3D.new()
material.albedo_color = Color(1, 0.5, 0, 0.6) # Orange
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.cull_mode = BaseMaterial3D.CULL_DISABLED
# Draw circle
immediate_mesh.surface_begin(Mesh.PRIMITIVE_LINE_STRIP)
var segments = 64
for i in range(segments + 1):
var angle = (TAU / segments) * i
var x = cos(angle) * spawn_radius
var z = sin(angle) * spawn_radius
immediate_mesh.surface_add_vertex(Vector3(x, spawn_height, z))
immediate_mesh.surface_end()
_debug_circle.mesh = immediate_mesh
_debug_circle.material_override = material
_debug_circle.visible = show_spawn_radius
add_child(_debug_circle)
## Update debug visualization (useful when changing spawn_radius in editor)
func _process(_delta):
# Update circle visibility
if _debug_circle:
_debug_circle.visible = show_spawn_radius
# Server spawning logic (check peer is assigned first)
if multiplayer.multiplayer_peer == null or not multiplayer.is_server():
return
# Timed waves - spawn a fresh wave on a repeating interval
if _auto_waves_active:
_auto_wave_timer -= _delta
if _auto_wave_timer <= 0:
_auto_wave_timer = wave_interval
start_wave()
# Handle wave delay timer
if _waiting_for_next_wave:
_wave_delay_timer -= _delta
if _wave_delay_timer <= 0:
_waiting_for_next_wave = false
start_wave()
# Check if all enemies defeated
_update_active_enemies()
+1
View File
@@ -0,0 +1 @@
uid://dim3dvik1fd27
+286
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dlcyyjtlb8s7x
+51
View File
@@ -0,0 +1,51 @@
extends Area3D
class_name HealthOrb
## Floating pickup that restores health when a player walks into it.
## Server-authoritative pickup; the bob/spin visuals run locally on every peer.
@export var heal_amount: float = 25.0
@export var bob_height: float = 0.3
@export var bob_speed: float = 2.0
@export var spin_speed: float = 2.0
var orb_id: int = -1 # Assigned by Level when spawned
var _base_y: float = 0.0
var _time: float = 0.0
var _collected: bool = false
@onready var _mesh: Node3D = get_node_or_null("Mesh")
func _ready():
_base_y = position.y
_time = randf() * TAU # Random phase so multiple orbs don't bob in unison
# Detect players (physics layer 1) without colliding with anything
collision_layer = 0
collision_mask = 1
# Only the server resolves pickups
if multiplayer.is_server():
body_entered.connect(_on_body_entered)
func _process(delta):
_time += delta
position.y = _base_y + sin(_time * bob_speed) * bob_height
if _mesh:
_mesh.rotate_y(spin_speed * delta)
func _on_body_entered(body: Node3D):
if _collected or not multiplayer.is_server():
return
if not (body is Character) or body.is_dead:
return
# Leave the orb in place if the player is already at full health
if body.current_health >= body.max_health:
return
_collected = true
body.heal(heal_amount)
# Remove the orb from every client via the level's centralized system
var level = get_tree().get_current_scene()
if level and level.has_method("remove_health_orb"):
level.remove_health_orb(orb_id)
+1
View File
@@ -0,0 +1 @@
uid://bbekeah8iio6t
+149
View File
@@ -0,0 +1,149 @@
extends Area3D
class_name HitBox
## A component that deals damage to HurtBoxes
## Attach to weapons or attack effects
## Uses direct physics queries for reliable hit detection
signal hit_landed(target: Node, damage: float, knockback: float, attacker_pos: Vector3)
## Damage dealt on hit
@export var damage: float = 10.0
## Knockback force applied
@export var knockback: float = 5.0
## Owner entity (used to prevent self-damage and identify attacker)
@export var owner_entity: Node = null
## Global debug visibility toggle (press H in-game to toggle)
static var debug_visible: bool = false
## Whether hitbox is currently active (only deals damage when active)
var is_active: bool = false
## Tracks entities hit this attack (prevents multi-hit)
var _hits_this_attack: Array[Node] = []
## Shape for queries (extracted from child CollisionShape3D)
var _query_shape: Shape3D = null
## Debug mesh for visualization
var _debug_mesh: MeshInstance3D = null
var _debug_material: StandardMaterial3D = null
func _ready():
# Find the collision shape for queries
for child in get_children():
if child is CollisionShape3D and child.shape:
_query_shape = child.shape
_create_debug_visualization(child)
break
func _create_debug_visualization(collision_shape: CollisionShape3D):
# Create a semi-transparent red mesh to visualize the hitbox
_debug_mesh = MeshInstance3D.new()
_debug_material = StandardMaterial3D.new()
_debug_material.albedo_color = Color(1.0, 0.0, 0.0, 0.4) # Red, semi-transparent
_debug_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
_debug_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
_debug_material.cull_mode = BaseMaterial3D.CULL_DISABLED # Visible from both sides
# Create mesh matching the collision shape
var mesh: Mesh = null
if collision_shape.shape is BoxShape3D:
var box_mesh = BoxMesh.new()
box_mesh.size = collision_shape.shape.size
mesh = box_mesh
elif collision_shape.shape is SphereShape3D:
var sphere_mesh = SphereMesh.new()
sphere_mesh.radius = collision_shape.shape.radius
sphere_mesh.height = collision_shape.shape.radius * 2
mesh = sphere_mesh
elif collision_shape.shape is CapsuleShape3D:
var capsule_mesh = CapsuleMesh.new()
capsule_mesh.radius = collision_shape.shape.radius
capsule_mesh.height = collision_shape.shape.height
mesh = capsule_mesh
if mesh:
_debug_mesh.mesh = mesh
_debug_mesh.material_override = _debug_material
_debug_mesh.visible = HitBox.debug_visible
# Don't set transform - it inherits from parent CollisionShape3D
collision_shape.add_child(_debug_mesh)
func _physics_process(_delta):
# Update debug visibility
if _debug_mesh:
_debug_mesh.visible = HitBox.debug_visible
if not is_active:
return
_check_hits()
func _check_hits():
if not _query_shape:
# Fallback: create a default sphere
var sphere = SphereShape3D.new()
sphere.radius = 2.0
_query_shape = sphere
# Use physics server for reliable queries
var space_state = get_world_3d().direct_space_state
var query = PhysicsShapeQueryParameters3D.new()
query.shape = _query_shape
query.transform = global_transform
query.collision_mask = 16 # Layer 5 (hurtbox)
query.collide_with_areas = true
query.collide_with_bodies = false
var results = space_state.intersect_shape(query, 32)
for result in results:
var collider = result["collider"]
if collider is HurtBox:
_process_hit(collider)
func _process_hit(hurtbox: HurtBox):
# Don't hit our own hurtbox
if hurtbox.owner_entity == owner_entity:
return
# Don't hit same entity twice in one attack
if hurtbox.owner_entity in _hits_this_attack:
return
# Enemies don't damage other enemies (only players)
if owner_entity is BaseEnemy and hurtbox.owner_entity is BaseEnemy:
return
# Register this hit
var target = hurtbox.owner_entity
if target:
_hits_this_attack.append(target)
# Get attacker position for knockback direction
var attacker_pos = global_position
if owner_entity and owner_entity is Node3D:
attacker_pos = owner_entity.global_position
# Emit signal - let the weapon/owner handle damage routing to server
hit_landed.emit(target, damage, knockback, attacker_pos)
## Activate hitbox (call when attack starts)
func activate():
is_active = true
_hits_this_attack.clear()
# Change to yellow when active
if _debug_material:
_debug_material.albedo_color = Color(1.0, 1.0, 0.0, 0.5) # Yellow, semi-transparent
## Deactivate hitbox (call when attack ends)
func deactivate():
is_active = false
_hits_this_attack.clear()
# Change back to red when inactive
if _debug_material:
_debug_material.albedo_color = Color(1.0, 0.0, 0.0, 0.4) # Red, semi-transparent
## Set damage stats (usually from weapon data)
func set_stats(new_damage: float, new_knockback: float):
damage = new_damage
knockback = new_knockback
+1
View File
@@ -0,0 +1 @@
uid://jyas86y3f0jp
+94
View File
@@ -0,0 +1,94 @@
extends Area3D
class_name HurtBox
## A component that receives damage from HitBoxes
## Attach to any entity that can be damaged (players, enemies, destructibles)
## NOTE: This is a passive detection zone - HitBox handles the collision detection
## Global debug visibility toggle (press H in-game to toggle)
static var debug_visible: bool = false
## The entity that owns this hurtbox (should be a BaseUnit or similar)
@export var owner_entity: Node = null
## Debug mesh for visualization
var _debug_mesh: MeshInstance3D = null
var _debug_material: StandardMaterial3D = null
var _hit_flash_timer: float = 0.0
const HIT_FLASH_DURATION: float = 0.3 # seconds
func _ready():
# Auto-find owner if not set (traverse up to find BaseUnit)
if owner_entity == null:
var parent = get_parent()
while parent:
if parent is BaseUnit:
owner_entity = parent
break
parent = parent.get_parent()
# Configure collision - hurtboxes are on layer 5, detect nothing (passive)
collision_layer = 16 # Layer 5 (hurtbox)
collision_mask = 0 # Don't detect anything - hitboxes detect us
# Ensure we can be detected but don't detect others
monitorable = true
monitoring = false
# Add debug visualization
_create_debug_visualization()
func _process(delta):
# Update debug visibility
if _debug_mesh:
_debug_mesh.visible = HurtBox.debug_visible
# Handle hit flash timer
if _hit_flash_timer > 0.0:
_hit_flash_timer -= delta
if _hit_flash_timer <= 0.0:
# Flash finished, return to green
if _debug_material:
_debug_material.albedo_color = Color(0.0, 1.0, 0.0, 0.3) # Green
func _create_debug_visualization():
# Find the collision shape to visualize
for child in get_children():
if child is CollisionShape3D and child.shape:
# Create a semi-transparent green mesh to visualize the hurtbox
_debug_mesh = MeshInstance3D.new()
_debug_material = StandardMaterial3D.new()
_debug_material.albedo_color = Color(0.0, 1.0, 0.0, 0.3) # Green, semi-transparent
_debug_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
_debug_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
_debug_material.cull_mode = BaseMaterial3D.CULL_DISABLED # Visible from both sides
# Create mesh matching the collision shape
var mesh: Mesh = null
if child.shape is BoxShape3D:
var box_mesh = BoxMesh.new()
box_mesh.size = child.shape.size
mesh = box_mesh
elif child.shape is SphereShape3D:
var sphere_mesh = SphereMesh.new()
sphere_mesh.radius = child.shape.radius
sphere_mesh.height = child.shape.radius * 2
mesh = sphere_mesh
elif child.shape is CapsuleShape3D:
var capsule_mesh = CapsuleMesh.new()
capsule_mesh.radius = child.shape.radius
capsule_mesh.height = child.shape.height
mesh = capsule_mesh
if mesh:
_debug_mesh.mesh = mesh
_debug_mesh.material_override = _debug_material
_debug_mesh.visible = HurtBox.debug_visible
# Don't set transform - it inherits from parent CollisionShape3D
child.add_child(_debug_mesh)
break
## Call this when the hurtbox is hit to flash red
func flash_hit():
_hit_flash_timer = HIT_FLASH_DURATION
if _debug_material:
_debug_material.albedo_color = Color(1.0, 0.0, 0.0, 0.5) # Red, semi-transparent
+1
View File
@@ -0,0 +1 @@
uid://bj3uepduxvgju
+538 -80
View File
@@ -4,15 +4,25 @@ extends Node3D
@onready var nick_input: LineEdit = $Menu/MainContainer/MainMenu/Option1/NickInput @onready var nick_input: LineEdit = $Menu/MainContainer/MainMenu/Option1/NickInput
@onready var address_input: LineEdit = $Menu/MainContainer/MainMenu/Option3/AddressInput @onready var address_input: LineEdit = $Menu/MainContainer/MainMenu/Option3/AddressInput
@onready var players_container: Node3D = $PlayersContainer @onready var players_container: Node3D = $PlayersContainer
@onready var weapons_container: Node3D = null # Will be created if doesn't exist @onready var weapons_container: Node3D = $WeaponsContainer
@onready var enemies_container: Node3D = $EnemiesContainer
var orbs_container: Node3D = null
@onready var enemy_spawner: EnemySpawner = $EnemySpawner
@onready var player_spawn_points: Node3D = $PlayerSpawnPoints
@onready var menu: Control = $Menu @onready var menu: Control = $Menu
@onready var main_menu: VBoxContainer = $Menu/MainContainer/MainMenu @onready var main_menu: VBoxContainer = $Menu/MainContainer/MainMenu
@export var player_scene: PackedScene @export var player_scene: PackedScene
@export var practice_dummy_scene: PackedScene
@export var armed_enemy_scene: PackedScene
# Weapon spawning counter (server-side only) # Weapon spawning counter (server-side only)
var _weapon_spawn_counter: int = 0 var _weapon_spawn_counter: int = 0
# Track active weapons for late-join sync (server-side only) # Track active weapons for late-join sync (server-side only)
var _active_weapons: Dictionary = {} # weapon_id -> WorldWeapon reference var _active_weapons: Dictionary = {} # weapon_id -> WorldWeapon reference
# Track if we've already initialized to prevent double-spawning
var _multiplayer_initialized: bool = false
# Track next spawn point for round-robin spawning (server-side only)
var _next_spawn_index: int = 0
# multiplayer chat # multiplayer chat
@onready var message: LineEdit = $MultiplayerChat/VBoxContainer/HBoxContainer/Message @onready var message: LineEdit = $MultiplayerChat/VBoxContainer/HBoxContainer/Message
@@ -23,6 +33,8 @@ var _active_weapons: Dictionary = {} # weapon_id -> WorldWeapon reference
var chat_visible = false var chat_visible = false
func _ready(): func _ready():
print("[Level] _ready() called. Peer ID: ", multiplayer.get_unique_id(), " Is server: ", multiplayer.is_server())
multiplayer_chat.hide() multiplayer_chat.hide()
menu.show() menu.show()
multiplayer_chat.set_process_input(true) multiplayer_chat.set_process_input(true)
@@ -30,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")
@@ -39,121 +57,161 @@ func _ready():
add_child(weapons_container) add_child(weapons_container)
print("Created WeaponsContainer") print("Created WeaponsContainer")
# Clients: Remove manually placed weapons (server will sync them via RPC) # Create or find enemies container
if not multiplayer.is_server(): if has_node("EnemiesContainer"):
enemies_container = get_node("EnemiesContainer")
else:
enemies_container = Node3D.new()
enemies_container.name = "EnemiesContainer"
add_child(enemies_container)
print("Created EnemiesContainer")
# Create or find health orbs container
if has_node("OrbsContainer"):
orbs_container = get_node("OrbsContainer")
else:
orbs_container = Node3D.new()
orbs_container.name = "OrbsContainer"
add_child(orbs_container)
print("Created OrbsContainer")
# Don't initialize weapons in _ready() - wait for Host/Join to be pressed
# This is handled in initialize_multiplayer() which is called after the
# multiplayer peer is properly set up
print("[Level] _ready() complete - waiting for Host/Join")
func _on_connected_to_server():
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
func initialize_multiplayer():
print("[Level] initialize_multiplayer called. is_server: ", multiplayer.is_server())
# Prevent double initialization
if _multiplayer_initialized:
print("[Level] Already initialized, skipping")
return return
_multiplayer_initialized = true
# Begin this player's arena run (starts the survival clock + gold tracking)
GameState.start_run()
if multiplayer.is_server():
print("[Level] Running server initialization")
Network.connect("player_connected", Callable(self, "_on_player_connected")) 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 practice dummies
_spawn_initial_weapons() _spawn_practice_dummies()
# Spawn armed enemies
_spawn_armed_enemies()
# Scatter a few health orbs so the pickup is visible from the start
_spawn_initial_health_orbs()
# Begin automatic timed enemy waves
if enemy_spawner:
enemy_spawner.start_auto_waves()
# Spawn the host player (peer ID 1)
print("[Level] Spawning host player")
var host_info = Network.players.get(1, {"nick": "Host", "skin": "blue"})
_add_player(1, host_info)
else:
# Client initialization - clean up manual weapons immediately
print("[Level] Running client initialization - cleaning up manual weapons")
_cleanup_manual_weapons_on_client()
func _cleanup_manual_weapons_on_client(): func _cleanup_manual_weapons_on_client():
"""Remove manually placed weapons on clients (server will sync them via RPC)""" """Remove manually placed weapons on clients (server will sync them via RPC)"""
print("[Client ", multiplayer.get_unique_id(), "] _cleanup_manual_weapons_on_client called")
if not weapons_container: if not weapons_container:
print("[Client] No weapons_container found!")
return return
print("[Client] WeaponsContainer has ", weapons_container.get_child_count(), " children")
var weapons_to_remove = [] var weapons_to_remove = []
for child in weapons_container.get_children(): for child in weapons_container.get_children():
if child is WorldWeapon and child.weapon_id == -1: print("[Client] Checking child: ", child.name, " (type: ", child.get_class(), ")")
if child is WorldWeapon:
print("[Client] - Is WorldWeapon with weapon_id: ", child.weapon_id)
if child.weapon_id == -1:
weapons_to_remove.append(child) weapons_to_remove.append(child)
print("[Client] - Marked for removal")
print("[Client] Found ", weapons_to_remove.size(), " weapons to remove")
for weapon in weapons_to_remove: for weapon in weapons_to_remove:
print("[Client] Removing manually placed weapon: ", weapon.name) print("[Client] Removing manually placed weapon: ", weapon.name)
weapon.queue_free() # Use immediate removal to prevent RPC errors
weapons_container.remove_child(weapon)
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)
_add_player(peer_id, player_info) _add_player(peer_id, player_info)
# Sync existing players to the newly joined player
if multiplayer.is_server() and peer_id != 1:
print("[Server] Syncing existing players to newly connected peer: ", peer_id)
# Wait a frame to ensure new player is fully initialized
await get_tree().process_frame
for existing_player in players_container.get_children():
var existing_id = int(existing_player.name)
if existing_id != peer_id: # Don't sync the player to themselves
print("[Server] Syncing existing player ", existing_id, " to peer ", peer_id)
rpc_id(peer_id, "_spawn_player_local", existing_id, existing_player.position)
# Sync existing weapons to the newly joined player (but not to server itself) # Sync existing weapons to the newly joined player (but not to server itself)
if multiplayer.is_server() and peer_id != 1: if multiplayer.is_server() and peer_id != 1:
print("[Server] Syncing weapons to newly connected peer: ", peer_id) print("[Server] Syncing weapons to newly connected peer: ", peer_id)
print("[Server] Active weapons in _active_weapons: ", _active_weapons.keys())
print("[Server] Active weapons count: ", _active_weapons.size()) print("[Server] Active weapons count: ", _active_weapons.size())
for weapon_id in _active_weapons.keys(): for weapon_id in _active_weapons.keys():
var weapon = _active_weapons[weapon_id] var weapon = _active_weapons[weapon_id]
if is_instance_valid(weapon) and weapon.weapon_data: if is_instance_valid(weapon) and weapon.weapon_data:
print("[Server] Sending weapon ", weapon_id, " to peer ", peer_id) print("[Server] Sending weapon ", weapon_id, " (", weapon.weapon_data.weapon_name, ") at position ", weapon.global_position, " to peer ", peer_id)
# Send current position and zero velocity for syncing # Send current position and zero velocity for syncing
rpc_id(peer_id, "_client_spawn_weapon", rpc_id(peer_id, "_client_spawn_weapon",
weapon.weapon_data.resource_path, weapon.weapon_data.resource_path,
@@ -161,25 +219,119 @@ func _on_player_connected(peer_id, player_info):
Vector3.ZERO, Vector3.ZERO,
weapon_id weapon_id
) )
else:
print("[Server] Skipping invalid weapon ", weapon_id)
# Sync existing enemies to the newly joined player
print("[Server] Syncing enemies to newly connected peer: ", peer_id)
if enemies_container:
for enemy in enemies_container.get_children():
if enemy is BaseEnemy:
# Check if it's an ArmedEnemy or PracticeDummy
if enemy.name.begins_with("ArmedEnemy_"):
# Sync armed enemy with its weapons
var main_weapon_path = ""
var offhand_weapon_path = ""
if "equipped_weapon" in enemy and enemy.equipped_weapon and enemy.equipped_weapon.weapon_data:
main_weapon_path = enemy.equipped_weapon.weapon_data.resource_path
if "equipped_offhand" in enemy and enemy.equipped_offhand and enemy.equipped_offhand.weapon_data:
offhand_weapon_path = enemy.equipped_offhand.weapon_data.resource_path
print("[Server] Syncing armed enemy ", enemy.name, " to peer ", peer_id)
rpc_id(peer_id, "_spawn_armed_enemy_local", enemy.name, enemy.global_position, main_weapon_path, offhand_weapon_path)
elif enemy.name.begins_with("PracticeDummy_"):
# Extract ID from name (e.g., "PracticeDummy_1" -> 1)
var enemy_name_parts = enemy.name.split("_")
if enemy_name_parts.size() >= 2:
var enemy_id = enemy_name_parts[-1].to_int()
print("[Server] Syncing enemy ", enemy.name, " at position ", enemy.global_position, " to peer ", peer_id)
rpc_id(peer_id, "_spawn_dummy_local", enemy_id, enemy.global_position)
# Sync existing health orbs to the newly joined player
print("[Server] Syncing health orbs to newly connected peer: ", peer_id)
for orb_id in _active_orbs.keys():
rpc_id(peer_id, "_spawn_health_orb_local", orb_id, _active_orbs[orb_id])
# Sync 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
print("[Server] Syncing equipped weapons to newly connected peer: ", peer_id)
for player_node in players_container.get_children():
var player = player_node as Character
if player and is_instance_valid(player):
# Skip the newly joined player (they don't have weapons yet)
if int(player.name) == peer_id:
continue
# Sync main hand weapon
if player.equipped_weapon and player.equipped_weapon.weapon_data:
print("[Server] Syncing main hand weapon for player ", player.name, " to peer ", peer_id)
player.rpc_id(peer_id, "equip_weapon_from_world",
player.equipped_weapon.weapon_data.resource_path)
# Sync off-hand weapon
if player.equipped_offhand and player.equipped_offhand.weapon_data:
print("[Server] Syncing off-hand weapon for player ", player.name, " to peer ", peer_id)
player.rpc_id(peer_id, "equip_weapon_from_world",
player.equipped_offhand.weapon_data.resource_path)
func _on_host_pressed(): func _on_host_pressed():
print("[Level] Host button pressed")
menu.hide() menu.hide()
print("[Level] Calling Network.start_host()")
Network.start_host(nick_input.text.strip_edges(), skin_input.text.strip_edges().to_lower()) Network.start_host(nick_input.text.strip_edges(), skin_input.text.strip_edges().to_lower())
print("[Level] Waiting one frame...")
await get_tree().process_frame
print("[Level] Calling initialize_multiplayer()")
initialize_multiplayer()
func _on_join_pressed(): func _on_join_pressed():
print("[Level] Join button pressed")
menu.hide() menu.hide()
print("[Level] Calling Network.join_game()")
Network.join_game(nick_input.text.strip_edges(), skin_input.text.strip_edges().to_lower(), address_input.text.strip_edges()) Network.join_game(nick_input.text.strip_edges(), skin_input.text.strip_edges().to_lower(), address_input.text.strip_edges())
print("[Level] Waiting one frame...")
await get_tree().process_frame
print("[Level] Calling initialize_multiplayer()")
initialize_multiplayer()
func _add_player(id: int, player_info : Dictionary): func _add_player(id: int, player_info : Dictionary):
print("[Level] _add_player called for peer ", id, " with info: ", player_info)
# Server spawns player and replicates to all clients via RPC
if multiplayer.is_server():
if players_container.has_node(str(id)): if players_container.has_node(str(id)):
print("[Level] Player ", id, " already exists, skipping")
return return
var spawn_pos = get_spawn_point()
print("[Level] Server spawning player ", id, " at ", spawn_pos)
# Spawn on server and all clients (call_local does both)
rpc("_spawn_player_local", id, spawn_pos)
@rpc("any_peer", "call_local", "reliable")
func _spawn_player_local(id: int, spawn_pos: Vector3):
if players_container.has_node(str(id)):
print("[Peer ", multiplayer.get_unique_id(), "] Player ", id, " already exists, skipping")
return
print("[Peer ", multiplayer.get_unique_id(), "] Creating player instance for peer ", id)
var player = player_scene.instantiate() var player = player_scene.instantiate()
player.name = str(id) player.name = str(id)
player.position = get_spawn_point() player.position = spawn_pos
print("[Peer ", multiplayer.get_unique_id(), "] Adding player to PlayersContainer")
players_container.add_child(player, true) players_container.add_child(player, true)
print("[Peer ", multiplayer.get_unique_id(), "] Player ", id, " spawned at ", player.position)
var nick = Network.players[id]["nick"] # Get player info from Network
player.nickname.text = nick var player_info = Network.players.get(id, {"nick": "Player", "skin": "blue"})
var nick = player_info["nick"]
# Access nickname directly via node path since @onready hasn't loaded yet
var nickname_label = player.get_node_or_null("PlayerNick/Nickname")
if nickname_label:
nickname_label.text = nick
# player.rpc("change_nick", nick) # player.rpc("change_nick", nick)
# Set up HUD for local player # Set up HUD for local player
@@ -192,6 +344,13 @@ func _add_player(id: int, player_info : Dictionary):
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)
@@ -199,6 +358,20 @@ func _add_player(id: int, player_info : Dictionary):
# rpc("sync_player_position", id, player.position) # rpc("sync_player_position", id, player.position)
func get_spawn_point() -> Vector3: func get_spawn_point() -> Vector3:
# Use PlayerSpawnPoint container if available
if player_spawn_points and player_spawn_points.get_child_count() > 0:
var spawn_children = player_spawn_points.get_children()
# Use round-robin to distribute players across spawn points
var spawn_index = _next_spawn_index % spawn_children.size()
_next_spawn_index = (spawn_index + 1) % spawn_children.size()
var spawn_node = spawn_children[spawn_index]
print("[Level] Using spawn point ", spawn_index, " at ", spawn_node.global_position)
return spawn_node.global_position
else:
# Fallback to random circle spawn if no spawn points defined
print("[Level] Warning: No PlayerSpawnPoint container found, using fallback random spawn")
var spawn_point = Vector2.from_angle(randf() * 2 * PI) * 10 # spawn radius var spawn_point = Vector2.from_angle(randf() * 2 * PI) * 10 # spawn radius
return Vector3(spawn_point.x, 0, spawn_point.y) return Vector3(spawn_point.x, 0, spawn_point.y)
@@ -245,6 +418,16 @@ func _input(event):
toggle_chat() toggle_chat()
elif event is InputEventKey and event.keycode == KEY_ENTER: elif event is InputEventKey and event.keycode == KEY_ENTER:
_on_send_pressed() _on_send_pressed()
elif event is InputEventKey and event.keycode == KEY_N and event.pressed:
# Start next wave (server only, press N)
if multiplayer.is_server() and enemy_spawner:
enemy_spawner.start_next_wave()
print("[Level] Starting wave via N key")
elif event is InputEventKey and event.keycode == KEY_H and event.pressed:
# Toggle hitbox/hurtbox debug visualization
HitBox.debug_visible = not HitBox.debug_visible
HurtBox.debug_visible = not HurtBox.debug_visible
print("[Level] Hitbox/Hurtbox debug visibility: ", HitBox.debug_visible)
func _on_send_pressed() -> void: func _on_send_pressed() -> void:
var trimmed_message = message.text.strip_edges() var trimmed_message = message.text.strip_edges()
@@ -261,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
@@ -297,6 +514,63 @@ func _on_jemz_preset():
skin_input.text = "Red" skin_input.text = "Red"
address_input.text = "127.0.0.1" address_input.text = "127.0.0.1"
# ---------- ENEMY SPAWNING ----------
func _spawn_practice_dummies():
if not multiplayer.is_server():
return
if not practice_dummy_scene:
push_warning("Practice dummy scene not assigned!")
return
# Wait a frame for everything to be ready
await get_tree().process_frame
print("[Server] Spawning practice dummies")
# Spawn dummies at different positions
var dummy_positions = [
Vector3(10, 0, 0),
Vector3(-10, 0, 0),
Vector3(0, 0, 10),
Vector3(0, 0, -10),
]
var dummy_counter = 0
for pos in dummy_positions:
dummy_counter += 1
rpc("_spawn_dummy_local", dummy_counter, pos)
## Spawn a practice dummy on all clients
@rpc("any_peer", "call_local", "reliable")
func _spawn_dummy_local(dummy_id: int, spawn_pos: Vector3):
if not practice_dummy_scene:
push_error("Practice dummy scene not loaded!")
return
if not enemies_container:
push_error("EnemiesContainer not found!")
return
var dummy_name = "PracticeDummy_" + str(dummy_id)
# Don't spawn duplicates
if enemies_container.has_node(dummy_name):
print("[Peer ", multiplayer.get_unique_id(), "] Dummy ", dummy_name, " already exists")
return
print("[Peer ", multiplayer.get_unique_id(), "] Spawning dummy ", dummy_name, " at ", spawn_pos)
var dummy = practice_dummy_scene.instantiate()
dummy.name = dummy_name
dummy.position = spawn_pos
# Set multiplayer authority to server
dummy.set_multiplayer_authority(1)
enemies_container.add_child(dummy, true)
print("[Peer ", multiplayer.get_unique_id(), "] Dummy ", dummy_name, " spawned successfully")
# ---------- WEAPON SPAWNING ---------- # ---------- WEAPON SPAWNING ----------
## Spawn a weapon in the world (called from server, syncs to all clients) ## Spawn a weapon in the world (called from server, syncs to all clients)
@rpc("any_peer", "call_local", "reliable") @rpc("any_peer", "call_local", "reliable")
@@ -356,6 +630,14 @@ func remove_world_weapon(weapon_id: int):
print("[ERROR] remove_world_weapon called on client!") print("[ERROR] remove_world_weapon called on client!")
return return
# Immediately remove from active weapons to prevent late-join sync issues
if _active_weapons.has(weapon_id):
_active_weapons.erase(weapon_id)
print("[Server] Removed weapon ", weapon_id, " from _active_weapons. Remaining: ", _active_weapons.size())
print("[Server] Remaining weapon IDs: ", _active_weapons.keys())
else:
print("[Server] WARNING: Weapon ", weapon_id, " not found in _active_weapons!")
# Broadcast removal to all clients # Broadcast removal to all clients
print("[Server] Broadcasting removal RPC to all clients") print("[Server] Broadcasting removal RPC to all clients")
rpc("_remove_weapon_on_clients", weapon_id) rpc("_remove_weapon_on_clients", weapon_id)
@@ -394,3 +676,179 @@ func _client_spawn_weapon(weapon_data_path: String, spawn_position: Vector3, ini
# Call the regular spawn function to create the weapon # Call the regular spawn function to create the weapon
print("[Client ", multiplayer.get_unique_id(), "] Calling spawn_world_weapon locally") print("[Client ", multiplayer.get_unique_id(), "] Calling spawn_world_weapon locally")
spawn_world_weapon(weapon_data_path, spawn_position, initial_velocity, weapon_id) spawn_world_weapon(weapon_data_path, spawn_position, initial_velocity, weapon_id)
# ---------- HEALTH ORBS ----------
var _orb_spawn_counter: int = 0
var _active_orbs: Dictionary = {} # orb_id -> spawn position (Vector3), for late-join sync
## Spawn a health orb at a position (server only, replicates to all clients)
func spawn_health_orb(spawn_pos: Vector3):
if not multiplayer.is_server():
return
_orb_spawn_counter += 1
rpc("_spawn_health_orb_local", _orb_spawn_counter, spawn_pos)
## Spawn a health orb on all clients (call_local spawns on server too)
@rpc("any_peer", "call_local", "reliable")
func _spawn_health_orb_local(orb_id: int, spawn_pos: Vector3):
if not orbs_container:
push_error("[Level] OrbsContainer not found!")
return
var orb_name = "HealthOrb_" + str(orb_id)
if orbs_container.has_node(orb_name):
return # Already spawned (e.g. duplicate sync)
var orb_scene = load("res://level/scenes/health_orb.tscn")
if not orb_scene:
push_error("[Level] Failed to load health_orb.tscn")
return
var orb = orb_scene.instantiate()
orb.orb_id = orb_id
orb.name = orb_name
orb.position = spawn_pos
orbs_container.add_child(orb, true)
# Server tracks active orbs for late-join sync
if multiplayer.is_server():
_active_orbs[orb_id] = spawn_pos
print("[Peer ", multiplayer.get_unique_id(), "] Spawned health orb ", orb_name, " at ", spawn_pos)
## Remove a health orb from all clients (called by HealthOrb when collected)
func remove_health_orb(orb_id: int):
if not multiplayer.is_server():
return
if _active_orbs.has(orb_id):
_active_orbs.erase(orb_id)
rpc("_remove_orb_on_clients", orb_id)
## RPC to remove an orb on all clients
@rpc("any_peer", "call_local", "reliable")
func _remove_orb_on_clients(orb_id: int):
var orb_name = "HealthOrb_" + str(orb_id)
if orbs_container and orbs_container.has_node(orb_name):
orbs_container.get_node(orb_name).queue_free()
## Scatter a few health orbs at server start
func _spawn_initial_health_orbs():
if not multiplayer.is_server():
return
# Wait a frame for everything to be ready
await get_tree().process_frame
var orb_positions = [
Vector3(6, 1, 6),
Vector3(-6, 1, -6),
Vector3(-6, 1, 6),
]
for pos in orb_positions:
spawn_health_orb(pos)
# ---------- ARMED ENEMY SPAWNING ----------
var _armed_enemy_counter: int = 0
## Spawn an armed enemy (server only, replicates to all clients)
func spawn_armed_enemy(spawn_pos: Vector3, main_weapon_path: String = "", offhand_weapon_path: String = ""):
if not multiplayer.is_server():
return
if not armed_enemy_scene:
push_warning("[Level] Armed enemy scene not assigned!")
return
_armed_enemy_counter += 1
var enemy_name = "ArmedEnemy_" + str(_armed_enemy_counter)
rpc("_spawn_armed_enemy_local", enemy_name, spawn_pos, main_weapon_path, offhand_weapon_path)
## Spawn armed enemy on all clients
@rpc("any_peer", "call_local", "reliable")
func _spawn_armed_enemy_local(enemy_name: String, spawn_pos: Vector3, main_weapon_path: String, offhand_weapon_path: String):
if not armed_enemy_scene:
push_error("[Level] Armed enemy scene not loaded!")
return
if not enemies_container:
push_error("[Level] EnemiesContainer not found!")
return
# Don't spawn duplicates
if enemies_container.has_node(enemy_name):
print("[Peer ", multiplayer.get_unique_id(), "] Armed enemy ", enemy_name, " already exists")
return
print("[Peer ", multiplayer.get_unique_id(), "] Spawning armed enemy ", enemy_name, " at ", spawn_pos)
var enemy = armed_enemy_scene.instantiate()
enemy.name = enemy_name
enemy.position = spawn_pos
# Set multiplayer authority to server
enemy.set_multiplayer_authority(1)
# Set starting weapons (for server to trigger RPCs)
if main_weapon_path != "":
var weapon_data = load(main_weapon_path) as WeaponData
if weapon_data:
enemy.starting_weapon = weapon_data
if offhand_weapon_path != "":
var offhand_data = load(offhand_weapon_path) as WeaponData
if offhand_data:
enemy.starting_offhand = offhand_data
enemies_container.add_child(enemy, true)
print("[Peer ", multiplayer.get_unique_id(), "] Armed enemy ", enemy_name, " spawned successfully")
# Server credits kill gold for armed enemies spawned outside the wave spawner
if multiplayer.is_server():
enemy.died.connect(_on_level_enemy_died.bind(enemy))
## Kill-gold payout for enemies spawned directly by the level (not the wave spawner).
## Practice dummies are deliberately NOT connected - they respawn and would be farmable.
func _on_level_enemy_died(killer_id: int, enemy: Node):
if not multiplayer.is_server():
return
if killer_id > 0 and Network.players.has(killer_id) and enemy is BaseEnemy:
GameState.server_credit_kill(killer_id, enemy.gold_reward)
## Spawn initial armed enemies when server starts
func _spawn_armed_enemies():
if not multiplayer.is_server():
return
if not armed_enemy_scene:
push_warning("[Level] Armed enemy scene not assigned - skipping armed enemy spawn")
return
# Wait a frame for everything to be ready
await get_tree().process_frame
print("[Server] Spawning armed enemies")
# Find enemy spawn points from EnemySpawner
var enemy_spawner = get_node_or_null("EnemySpawner")
if not enemy_spawner:
push_warning("[Level] EnemySpawner not found - skipping armed enemy spawn")
return
# Get all spawn points (children of EnemySpawner that start with "EnemySpawnPoint")
var spawn_points: Array[Node3D] = []
for child in enemy_spawner.get_children():
if child is Node3D and child.name.begins_with("EnemySpawnPoint"):
spawn_points.append(child)
if spawn_points.is_empty():
push_warning("[Level] No enemy spawn points found in EnemySpawner")
return
print("[Server] Found ", spawn_points.size(), " enemy spawn points")
# Spawn an unarmed enemy at each spawn point
for spawn_point in spawn_points:
var spawn_pos = spawn_point.global_position
print("[Server] Spawning armed enemy at ", spawn_pos)
spawn_armed_enemy(spawn_pos, "", "")
+69 -7
View File
@@ -11,21 +11,28 @@ 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
# 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 == "Attack_OneHand": if animation_player.is_playing() and animation_player.current_animation.begins_with("Attack"):
return return
# Check if we're dashing # Check if we're dashing (defensive check for enemies that don't have this)
if _character._is_dashing: if _character and "_is_dashing" in _character and _character._is_dashing:
if animation_player.current_animation != "Jump": if animation_player.current_animation != "Jump":
_play_animation("Jump") _play_animation("Jump")
return return
if not _character.is_on_floor(): # Check if on floor (works for any CharacterBody3D)
var on_floor = _character.is_on_floor() if _character else true
if not on_floor:
if _velocity.y < 0: if _velocity.y < 0:
# Falling - use FallIdle animation # Falling - use FallIdle animation
_play_animation("FallIdle") _play_animation("FallIdle")
@@ -34,7 +41,14 @@ func animate(_velocity: Vector3) -> void:
return return
if _velocity: if _velocity:
if _character.is_running() and _character.is_on_floor(): # Check if running (defensive check - enemies don't have is_running)
var is_running_val = false
if _character and _character.has_method("is_running"):
is_running_val = _character.is_running() and on_floor
elif _velocity.length() > 5.0: # Fallback: high speed = running
is_running_val = on_floor
if is_running_val:
# Sprint animation = Run for Lilguy # Sprint animation = Run for Lilguy
_play_animation("Run") _play_animation("Run")
return return
@@ -54,9 +68,57 @@ func _play_animation(anim_name: String):
animation_player.play(anim_name) animation_player.play(anim_name)
# Silently ignore if animation doesn't exist # Silently ignore if animation doesn't exist
func play_attack() -> void: func play_attack(anim_name: String = "Attack_OneHand") -> void:
if animation_player: if animation_player:
# Play attack animation once (don't loop) # Play attack animation once (don't loop)
if animation_player.has_animation(anim_name):
animation_player.play(anim_name, -1, 1.0)
animation_player.animation_set_next(anim_name, "")
else:
# Fallback to default if animation doesn't exist
#push_warning("Animation '%s' not found, using Attack_OneHand" % anim_name)
animation_player.play("Attack_OneHand", -1, 1.0) animation_player.play("Attack_OneHand", -1, 1.0)
# Ensure it doesn't loop
animation_player.animation_set_next("Attack_OneHand", "") animation_player.animation_set_next("Attack_OneHand", "")
## Apply hue shift to all mesh instances in the character
func set_character_color(hue_shift: float) -> void:
# Find all MeshInstance3D children recursively
var mesh_instances = _find_mesh_instances(self)
for mesh in mesh_instances:
_apply_hue_to_mesh(mesh, hue_shift)
## Recursively find all MeshInstance3D nodes
func _find_mesh_instances(node: Node) -> Array[MeshInstance3D]:
var meshes: Array[MeshInstance3D] = []
if node is MeshInstance3D:
meshes.append(node)
for child in node.get_children():
meshes.append_array(_find_mesh_instances(child))
return meshes
## Apply hue shift to a mesh instance
func _apply_hue_to_mesh(mesh_instance: MeshInstance3D, hue_shift: float) -> void:
if not mesh_instance:
return
# Get or create material for each surface
for i in range(mesh_instance.get_surface_override_material_count()):
var material = mesh_instance.get_surface_override_material(i)
# If no override material, get the base material and duplicate it
if not material:
material = mesh_instance.mesh.surface_get_material(i)
if material:
material = material.duplicate()
mesh_instance.set_surface_override_material(i, material)
# Apply hue shift if it's a StandardMaterial3D
if material and material is StandardMaterial3D:
var std_mat = material as StandardMaterial3D
# Create a modulate color from hue
var color = Color.from_hsv(hue_shift, 0.6, 1.0)
std_mat.albedo_color = color
-4
View File
@@ -13,10 +13,6 @@ var player_info = {
signal player_connected(peer_id, player_info) signal player_connected(peer_id, player_info)
signal server_disconnected signal server_disconnected
func _process(_delta):
if Input.is_action_just_pressed("quit"):
get_tree().quit(0)
func _ready() -> void: func _ready() -> void:
multiplayer.server_disconnected.connect(_on_connection_failed) multiplayer.server_disconnected.connect(_on_connection_failed)
multiplayer.connection_failed.connect(_on_server_disconnected) multiplayer.connection_failed.connect(_on_server_disconnected)
+209 -55
View File
@@ -42,7 +42,13 @@ var is_blocking: bool = false
@export var attack_damage: float = 10.0 @export var attack_damage: float = 10.0
@export var attack_range: float = 3.0 @export var attack_range: float = 3.0
@export var attack_cooldown: float = 0.5 @export var attack_cooldown: float = 0.5
@export var unarmed_knockback: float = 5.0
@export_category("Unarmed Attack Timing")
@export var unarmed_startup: float = 0.1 # Wind-up before hit
@export var unarmed_active: float = 0.15 # Hit window duration
var _attack_timer: float = 0.0 var _attack_timer: float = 0.0
var _unarmed_hitbox: HitBox = null
var _is_unarmed_attacking: bool = false # Prevents overlapping unarmed attacks
# Dash system # Dash system
@export var dash_speed_multiplier: float = 2.0 @export var dash_speed_multiplier: float = 2.0
@@ -53,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)
@@ -63,8 +72,19 @@ func _enter_tree():
$SpringArmOffset/SpringArm3D/Camera3D.current = is_multiplayer_authority() $SpringArmOffset/SpringArm3D/Camera3D.current = is_multiplayer_authority()
func _ready(): func _ready():
# Players passively regenerate health (set before super._ready so the regen timer is created)
if health_regen <= 0.0:
health_regen = 3.0
regen_delay = 5.0
# Gladiator arena: death ends your run - no respawning
can_respawn = false
super._ready() super._ready()
set_respawn_point(Vector3(0, 5, 0)) # Set respawn point to current position (where we spawned) - base_unit._ready already does this
# Don't override with a hardcoded position
print("[Player ", name, "] _ready called. Authority: ", is_multiplayer_authority(), " Position: ", global_position)
# Capture mouse for local player # Capture mouse for local player
if is_multiplayer_authority(): if is_multiplayer_authority():
@@ -72,13 +92,18 @@ func _ready():
# Auto-find body node (needed for instanced scenes where @export NodePath doesn't work reliably) # Auto-find body node (needed for instanced scenes where @export NodePath doesn't work reliably)
if _body == null: if _body == null:
for child in get_children(): # Try specific paths first
if child.name == "Armature" or child.name == "3DGodotRobot": if has_node("LilguyRigged/Armature"):
_body = child _body = get_node("LilguyRigged/Armature")
print("Auto-found _body: ", child.name) print("Auto-found _body: LilguyRigged/Armature")
break elif has_node("Armature"):
if _body == null: _body = get_node("Armature")
push_error("Could not find body node (Armature or 3DGodotRobot)!") print("Auto-found _body: Armature")
elif has_node("3DGodotRobot"):
_body = get_node("3DGodotRobot")
print("Auto-found _body: 3DGodotRobot")
else:
push_error("Could not find body node!")
# Auto-find spring arm offset # Auto-find spring arm offset
if _spring_arm_offset == null: if _spring_arm_offset == null:
@@ -144,6 +169,9 @@ func _ready():
# Setup weapon pickup detection area # Setup weapon pickup detection area
_setup_weapon_pickup_area() _setup_weapon_pickup_area()
# Setup unarmed attack hitbox (deferred to avoid multiplayer timing issues)
call_deferred("_setup_unarmed_hitbox")
# Auto-find weapon attachment if not set # Auto-find weapon attachment if not set
if _weapon_attachment == null: if _weapon_attachment == null:
var bone_attach = get_node_or_null("3DGodotRobot/RobotArmature/Skeleton3D/BoneAttachment3D") var bone_attach = get_node_or_null("3DGodotRobot/RobotArmature/Skeleton3D/BoneAttachment3D")
@@ -189,18 +217,18 @@ func _physics_process(delta):
freeze() freeze()
return return
# Apply gravity when not on floor
if not is_on_floor(): if not is_on_floor():
velocity.y -= gravity * delta velocity.y -= gravity * delta
_body.animate(velocity)
if is_on_floor(): # Handle jump
if Input.is_action_just_pressed("jump"): if is_on_floor() and Input.is_action_just_pressed("jump"):
velocity.y = JUMP_VELOCITY velocity.y = JUMP_VELOCITY
else:
velocity.y -= gravity * delta
_move() _move()
move_and_slide() move_and_slide()
if _body:
_body.animate(velocity) _body.animate(velocity)
func _process(delta): func _process(delta):
@@ -218,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
@@ -269,14 +301,14 @@ func freeze():
velocity.x = 0 velocity.x = 0
velocity.z = 0 velocity.z = 0
_current_speed = 0 _current_speed = 0
if _body:
_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
_body.apply_rotation(velocity)
return return
var _input_direction: Vector2 = Vector2.ZERO var _input_direction: Vector2 = Vector2.ZERO
@@ -289,17 +321,41 @@ func _move() -> void:
var _direction: Vector3 = transform.basis * Vector3(_input_direction.x, 0, _input_direction.y).normalized() var _direction: Vector3 = transform.basis * Vector3(_input_direction.x, 0, _input_direction.y).normalized()
is_running() is_running()
if _spring_arm_offset:
_direction = _direction.rotated(Vector3.UP, _spring_arm_offset.rotation.y) _direction = _direction.rotated(Vector3.UP, _spring_arm_offset.rotation.y)
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
# 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
@@ -328,13 +384,28 @@ func get_texture_from_name(skin_color: SkinColor) -> CompressedTexture2D:
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
func set_player_skin(skin_name: SkinColor) -> void: func set_player_skin(skin_name: SkinColor) -> void:
# Check if we're using the LilguyRigged model
if _body is LilguyBody:
# Use hue-based color system for Lilguy
var hue = _get_hue_from_skin_color(skin_name)
_body.set_character_color(hue)
else:
# Use texture-based system for 3DGodotRobot
var texture = get_texture_from_name(skin_name) var texture = get_texture_from_name(skin_name)
set_mesh_texture(_bottom_mesh, texture) set_mesh_texture(_bottom_mesh, texture)
set_mesh_texture(_chest_mesh, texture) set_mesh_texture(_chest_mesh, texture)
set_mesh_texture(_face_mesh, texture) set_mesh_texture(_face_mesh, texture)
set_mesh_texture(_limbs_head_mesh, texture) set_mesh_texture(_limbs_head_mesh, texture)
## Convert SkinColor enum to hue value (0.0 to 1.0)
func _get_hue_from_skin_color(skin_color: SkinColor) -> float:
match skin_color:
SkinColor.BLUE: return 0.6 # Blue hue
SkinColor.GREEN: return 0.33 # Green hue
SkinColor.RED: return 0.0 # Red hue
SkinColor.YELLOW: return 0.16 # Yellow hue
_: return 0.6 # Default to blue
func set_mesh_texture(mesh_instance: MeshInstance3D, texture: CompressedTexture2D) -> void: func set_mesh_texture(mesh_instance: MeshInstance3D, texture: CompressedTexture2D) -> void:
if mesh_instance: if mesh_instance:
var material := mesh_instance.get_surface_override_material(0) var material := mesh_instance.get_surface_override_material(0)
@@ -382,51 +453,42 @@ 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
# Fallback to default unarmed attack # Fallback to default unarmed attack
# Don't attack if already attacking # Don't attack if already attacking
if _body and _body.animation_player and _body.animation_player.current_animation == "Attack1": if _body and _body.animation_player and _body.animation_player.current_animation.begins_with("Attack"):
return return
if _attack_timer > 0: if _attack_timer > 0 or _is_unarmed_attacking:
return return
_attack_timer = attack_cooldown # Calculate total attack duration and ensure cooldown covers it
var total_duration = unarmed_startup + unarmed_active
var cooldown = max(attack_cooldown, total_duration)
_attack_timer = cooldown
_is_unarmed_attacking = true
# Aim the punch at the camera direction and commit to it
_lock_facing_to_camera(total_duration)
# Play attack animation once # Play attack animation once
if _body: if _body:
_body.play_attack() _body.play_attack("Attack_OneHand")
# Sync animation to other clients
_sync_attack_animation.rpc("Attack_OneHand")
# Find nearest enemy in range # Activate unarmed hitbox for damage detection
var space_state = get_world_3d().direct_space_state _activate_unarmed_hitbox()
var query = PhysicsShapeQueryParameters3D.new()
var sphere = SphereShape3D.new()
sphere.radius = attack_range
query.shape = sphere
query.transform = global_transform
query.collision_mask = 1 # Player layer
var results = space_state.intersect_shape(query)
for result in results:
var hit_body = result["collider"]
if hit_body != self and hit_body is BaseUnit:
var attacker_id = multiplayer.get_unique_id()
# If we're the server, apply damage directly (default unarmed knockback)
if multiplayer.is_server():
_server_apply_damage(hit_body.name, attack_damage, attacker_id, 5.0, global_position)
else:
# Otherwise, request server to apply damage
rpc_id(1, "_server_apply_damage", hit_body.name, attack_damage, attacker_id, 5.0, global_position)
break # Only hit one target per attack
## Server-side damage application ## Server-side damage application
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
@@ -434,16 +496,25 @@ func _server_apply_damage(target_name: String, damage: float, attacker_id: int,
if not multiplayer.is_server(): if not multiplayer.is_server():
return return
# Get the target from the players container
var level = get_tree().get_current_scene() var level = get_tree().get_current_scene()
if not level or not level.has_node("PlayersContainer"): if not level:
return return
var target = null
# Check players container first
if level.has_node("PlayersContainer"):
var players_container = level.get_node("PlayersContainer") var players_container = level.get_node("PlayersContainer")
if not players_container.has_node(target_name): if players_container.has_node(target_name):
return target = players_container.get_node(target_name)
var target = players_container.get_node(target_name) # If not found in players, check enemies container
if not target and level.has_node("EnemiesContainer"):
var enemies_container = level.get_node("EnemiesContainer")
if enemies_container.has_node(target_name):
target = enemies_container.get_node(target_name)
# Apply damage if target found
if target and target is BaseUnit: if target and target is BaseUnit:
target.take_damage(damage, attacker_id, knockback, attacker_pos) target.take_damage(damage, attacker_id, knockback, attacker_pos)
@@ -462,11 +533,13 @@ func _on_health_changed(_old_health: float, _new_health: float):
_update_health_display() _update_health_display()
func _on_died(killer_id: int): func _on_died(killer_id: int):
# Disable player when dead print("[Player ", name, "] _on_died called. Authority: ", is_multiplayer_authority())
# Only the authority should disable their own processing
if is_multiplayer_authority():
set_physics_process(false) set_physics_process(false)
set_process(false) set_process(false)
# Visual feedback - could add death animation here # Visual feedback - runs on all peers
if _body: if _body:
_body.visible = false _body.visible = false
@@ -480,21 +553,22 @@ 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():
# Re-enable player print("[Player ", name, "] _on_respawned called. Authority: ", is_multiplayer_authority(), " Position: ", global_position)
# Only the authority should re-enable their own processing
if is_multiplayer_authority():
set_physics_process(true) set_physics_process(true)
set_process(true) set_process(true)
print("[Player ", name, "] Re-enabled physics processing")
# Visual feedback - runs on all peers
if _body: if _body:
_body.visible = true _body.visible = true
_update_health_display() _update_health_display()
if is_multiplayer_authority():
print("You respawned!")
## Dash system ## Dash system
func _perform_dash(): func _perform_dash():
if not is_multiplayer_authority() or is_dead or not is_on_floor(): if not is_multiplayer_authority() or is_dead or not is_on_floor():
@@ -522,8 +596,17 @@ 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
@rpc("any_peer", "call_remote", "unreliable")
func _sync_attack_animation(anim_name: String):
if _body:
_body.play_attack(anim_name)
## Override hurt animation from BaseUnit ## Override hurt animation from BaseUnit
func _play_hurt_animation(): func _play_hurt_animation():
if _body and _body.animation_player: if _body and _body.animation_player:
@@ -577,6 +660,68 @@ func _setup_weapon_pickup_area():
pickup_area.area_entered.connect(_on_weapon_area_entered) pickup_area.area_entered.connect(_on_weapon_area_entered)
pickup_area.area_exited.connect(_on_weapon_area_exited) pickup_area.area_exited.connect(_on_weapon_area_exited)
func _setup_unarmed_hitbox():
# Create hitbox for unarmed attacks
_unarmed_hitbox = HitBox.new()
_unarmed_hitbox.name = "UnarmedHitBox"
_unarmed_hitbox.owner_entity = self
_unarmed_hitbox.set_stats(attack_damage, unarmed_knockback)
# Add collision shape BEFORE adding hitbox to tree (so _ready can find it)
var collision = CollisionShape3D.new()
var sphere = SphereShape3D.new()
sphere.radius = attack_range # Full attack range as radius
collision.shape = sphere
# Position in front of player (Z is forward for the body)
collision.position = Vector3(0, 0.8, -attack_range * 0.75)
_unarmed_hitbox.add_child(collision)
# Now attach the fully configured hitbox to body so it rotates with player facing direction
if _body:
_body.add_child(_unarmed_hitbox)
else:
add_child(_unarmed_hitbox)
# Connect hit signal
_unarmed_hitbox.hit_landed.connect(_on_unarmed_hit)
func _on_unarmed_hit(target: Node, damage_amount: float, knockback_amount: float, attacker_pos: Vector3):
if not target:
return
# Route damage through server
var attacker_id = multiplayer.get_unique_id()
if multiplayer.is_server():
_server_apply_damage(target.name, damage_amount, attacker_id, knockback_amount, attacker_pos)
else:
rpc_id(1, "_server_apply_damage", target.name, damage_amount, attacker_id, knockback_amount, attacker_pos)
func _activate_unarmed_hitbox():
if not _unarmed_hitbox:
_is_unarmed_attacking = false
return
# STARTUP PHASE - Wait before activating (wind-up animation)
if unarmed_startup > 0:
await get_tree().create_timer(unarmed_startup).timeout
if not _unarmed_hitbox or not is_instance_valid(_unarmed_hitbox):
_is_unarmed_attacking = false
return
# ACTIVE PHASE - Hitbox on, can deal damage
_unarmed_hitbox.activate()
await get_tree().create_timer(unarmed_active).timeout
# RECOVERY PHASE - Hitbox off
if _unarmed_hitbox and is_instance_valid(_unarmed_hitbox):
_unarmed_hitbox.deactivate()
# Attack complete
_is_unarmed_attacking = false
func _on_weapon_area_entered(area: Area3D): func _on_weapon_area_entered(area: Area3D):
# Check if the area belongs to a WorldWeapon # Check if the area belongs to a WorldWeapon
var weapon = area.get_parent() var weapon = area.get_parent()
@@ -609,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:
+132
View File
@@ -0,0 +1,132 @@
extends BaseEnemy
class_name PracticeDummy
## A stationary practice dummy for testing combat
## Cannot move or attack - just takes damage and shows health
## Visual mesh reference
@onready var _mesh: MeshInstance3D = null
## Health label above dummy
@onready var _health_label: Label3D = null
## Original material for hit flash effect
var _original_material: Material = null
## Hit flash effect
var _hit_flash_timer: float = 0.0
const HIT_FLASH_DURATION: float = 0.2
func _ready():
super._ready()
# Practice dummy should not be aggressive
is_aggressive = false
# Auto-find mesh and health label
_mesh = get_node_or_null("Mesh")
_health_label = get_node_or_null("HealthLabel")
# Store original material for flash effect
if _mesh:
_original_material = _mesh.get_surface_override_material(0)
if not _original_material and _mesh.mesh:
_original_material = _mesh.mesh.surface_get_material(0)
# Update initial health display
_update_health_display()
# Connect health change to update display
health_changed.connect(_on_health_display_changed)
func _process(delta):
# Handle hit flash timer
if _hit_flash_timer > 0:
_hit_flash_timer -= delta
if _hit_flash_timer <= 0:
_reset_material()
func _physics_process(delta):
# Don't call super._physics_process since we don't move
# Just apply gravity
if not is_on_floor():
velocity.y -= ProjectSettings.get_setting("physics/3d/default_gravity") * delta
move_and_slide()
## Update health display
func _update_health_display():
if _health_label:
_health_label.text = "HP: %d/%d" % [int(current_health), int(max_health)]
func _on_health_display_changed(_old_health: float, _new_health: float):
_update_health_display()
## Override hurt animation to flash red
@rpc("any_peer", "call_local", "reliable")
func _play_hurt_animation():
if _mesh:
_flash_red()
## Flash the dummy red when hit
func _flash_red():
if not _mesh:
return
_hit_flash_timer = HIT_FLASH_DURATION
# Create red material
var red_material = StandardMaterial3D.new()
red_material.albedo_color = Color(1.5, 0.3, 0.3) # Bright red
# Copy properties from original if it exists
if _original_material and _original_material is StandardMaterial3D:
var orig = _original_material as StandardMaterial3D
red_material.metallic = orig.metallic
red_material.roughness = orig.roughness
red_material.albedo_texture = orig.albedo_texture
_mesh.set_surface_override_material(0, red_material)
## Reset to original material
func _reset_material():
if _mesh and _original_material:
_mesh.set_surface_override_material(0, _original_material.duplicate())
## Override death to just hide the mesh
func _on_enemy_died(killer_id: int):
super._on_enemy_died(killer_id)
# Hide mesh when dead
if _mesh:
_mesh.visible = false
# Disable collision so players can walk through
var collision_shape = get_node_or_null("CollisionShape3D")
if collision_shape:
collision_shape.disabled = true
# Disable hurtbox
var hurtbox = get_node_or_null("HurtBox")
if hurtbox:
hurtbox.monitorable = false
print("[PracticeDummy] Killed by player ", killer_id, ". Respawning in ", respawn_delay, " seconds...")
## Override respawn to show mesh again
func _on_enemy_respawned():
super._on_enemy_respawned()
# Show mesh when respawned
if _mesh:
_mesh.visible = true
_reset_material()
# Re-enable collision
var collision_shape = get_node_or_null("CollisionShape3D")
if collision_shape:
collision_shape.disabled = false
# Re-enable hurtbox
var hurtbox = get_node_or_null("HurtBox")
if hurtbox:
hurtbox.monitorable = true
_update_health_display()
print("[PracticeDummy] Respawned!")
+1
View File
@@ -0,0 +1 @@
uid://practice_dummy_script
+14
View File
@@ -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
@@ -18,6 +24,14 @@ enum Hand { MAIN_HAND, OFF_HAND, TWO_HAND }
@export var attack_animation: String = "Attack1" # Animation to play when attacking @export var attack_animation: String = "Attack1" # Animation to play when attacking
@export var knockback_force: float = 8.0 # How much to push the target back @export var knockback_force: float = 8.0 # How much to push the target back
@export_category("Attack Timing")
## Time before hitbox activates (wind-up/anticipation)
@export var startup_time: float = 0.15
## Duration hitbox stays active (the actual hit window)
@export var active_time: float = 0.2
## Time after hitbox deactivates (follow-through, can't act)
## Note: recovery_time = attack_cooldown - startup_time - active_time (calculated automatically)
@export_category("Defense Stats") @export_category("Defense Stats")
@export var can_block: bool = false @export var can_block: bool = false
@export_range(0.0, 1.0) var block_reduction: float = 0.5 # Percentage of damage blocked (0.5 = 50%) @export_range(0.0, 1.0) var block_reduction: float = 0.5 # Percentage of damage blocked (0.5 = 50%)
+41
View File
@@ -0,0 +1,41 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://level/ui/scripts/keybind_hint.gd" id="1_script"]
[ext_resource type="Theme" uid="uid://dvsh7tuhulnfm" path="res://level/ui/theme/wow_style.tres" id="2_theme"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_kb"]
bg_color = Color(0.15, 0.15, 0.15, 0.85)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(0.6, 0.5, 0.2, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[node name="KeybindHint" type="PanelContainer"]
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -210.0
offset_top = 20.0
offset_right = -20.0
offset_bottom = 20.0
grow_horizontal = 0
grow_vertical = 1
theme = ExtResource("2_theme")
theme_override_styles/panel = SubResource("StyleBoxFlat_kb")
script = ExtResource("1_script")
[node name="Margin" type="MarginContainer" parent="."]
layout_mode = 2
theme_override_constants/margin_left = 10
theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 10
theme_override_constants/margin_bottom = 8
[node name="Content" type="VBoxContainer" parent="Margin"]
layout_mode = 2
theme_override_constants/separation = 4
+139 -23
View File
@@ -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:
_configure_preview_layout()
show()
is_visible = true
refresh_all()
else:
# Start hidden (in-game Tab toggle behavior)
hide() hide()
is_visible = false 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_name: String
var current_hp: int
var max_hp: int
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() var player_id = player.name.to_int()
var player_name = "Player" player_name = "Player"
if Network.players.has(player_id): if Network.players.has(player_id):
player_name = Network.players[player_id]["nick"] 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,12 +291,19 @@ 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")
if preview_mode:
_add_stat_label("Status: READY", Color(0.0, 1.0, 0.0))
else:
var dash_remaining = player._dash_cooldown_timer var dash_remaining = player._dash_cooldown_timer
if dash_remaining > 0: if dash_remaining > 0:
_add_stat_label("Ready in: " + str(ceil(dash_remaining)) + "s", Color(1.0, 0.5, 0.5)) _add_stat_label("Ready in: " + str(ceil(dash_remaining)) + "s", Color(1.0, 0.5, 0.5))
@@ -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))
+47
View File
@@ -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()
+113 -2
View File
@@ -8,7 +8,12 @@ var unit_frame: Control = null
var target_frame: Control = null var target_frame: Control = null
var character_sheet: Control = null var character_sheet: Control = null
var tab_hint: Control = null var tab_hint: Control = null
var keybind_hint: Control = null
var escape_menu: Control = null var escape_menu: Control = null
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
@@ -77,7 +82,9 @@ func _create_ui_components():
_create_unit_frame() _create_unit_frame()
_create_character_sheet() _create_character_sheet()
_create_tab_hint() _create_tab_hint()
_create_keybind_hint()
_create_escape_menu() _create_escape_menu()
_create_arena_status()
## Create action bar at bottom of screen ## Create action bar at bottom of screen
func _create_action_bar(): func _create_action_bar():
@@ -125,6 +132,16 @@ func _create_tab_hint():
else: else:
push_error("[HUD] Failed to load tab_hint.tscn") push_error("[HUD] Failed to load tab_hint.tscn")
## Create keybind hint (always visible, top-right corner)
func _create_keybind_hint():
var keybind_hint_scene = load("res://level/ui/scenes/keybind_hint.tscn")
if keybind_hint_scene:
keybind_hint = keybind_hint_scene.instantiate()
add_child(keybind_hint)
print("[HUD] Keybind hint created")
else:
push_error("[HUD] Failed to load keybind_hint.tscn")
## Create escape menu (toggle with Escape) ## Create escape menu (toggle with Escape)
func _create_escape_menu(): func _create_escape_menu():
var escape_menu_scene = load("res://level/ui/scenes/escape_menu.tscn") var escape_menu_scene = load("res://level/ui/scenes/escape_menu.tscn")
@@ -137,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()
@@ -145,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
@@ -152,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")
+75
View File
@@ -0,0 +1,75 @@
extends PanelContainer
## Always-on compact panel listing the current controls.
## Reads bindings straight from the InputMap so it stays accurate if keys are rebound.
# Each row is a friendly label plus the input action(s) that feed it.
# Grouped actions (like movement) show their keys joined together.
const ROWS := [
{ "label": "Move", "actions": ["move_forward", "move_left", "move_backward", "move_right"] },
{ "label": "Jump", "actions": ["jump"] },
{ "label": "Sprint", "actions": ["shift"] },
{ "label": "Dash", "actions": ["dash"] },
{ "label": "Attack", "actions": ["attack"] },
{ "label": "Block", "actions": ["block"] },
{ "label": "Pick Up / Drop", "actions": ["pickup"] },
{ "label": "Character Sheet", "actions": ["toggle_character_sheet"] },
{ "label": "Chat", "actions": ["toggle_chat"] },
{ "label": "Menu", "actions": ["quit"] },
]
func _ready() -> void:
_build()
func _build() -> void:
var content: VBoxContainer = $Margin/Content
var title := Label.new()
title.text = "CONTROLS"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 16)
title.add_theme_color_override("font_color", Color(1, 0.8, 0, 1))
title.add_theme_color_override("font_outline_color", Color.BLACK)
title.add_theme_constant_override("outline_size", 2)
content.add_child(title)
var grid := GridContainer.new()
grid.columns = 2
grid.add_theme_constant_override("h_separation", 12)
grid.add_theme_constant_override("v_separation", 3)
content.add_child(grid)
for row in ROWS:
var keys: Array[String] = []
for action in row["actions"]:
var k := _key_text(action)
if k != "":
keys.append(k)
if keys.is_empty():
continue
grid.add_child(_make_label(" ".join(keys), Color(1, 0.82, 0.2, 1)))
grid.add_child(_make_label(row["label"], Color.WHITE))
func _make_label(text: String, color: Color) -> Label:
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", 13)
l.add_theme_color_override("font_color", color)
l.add_theme_color_override("font_outline_color", Color.BLACK)
l.add_theme_constant_override("outline_size", 1)
return l
## Returns a human-readable name for the first event bound to an action.
func _key_text(action: String) -> String:
if not InputMap.has_action(action):
return ""
for ev in InputMap.action_get_events(action):
if ev is InputEventKey:
var code: int = ev.physical_keycode if ev.physical_keycode != 0 else ev.keycode
return OS.get_keycode_string(code)
elif ev is InputEventMouseButton:
match ev.button_index:
MOUSE_BUTTON_LEFT: return "LMB"
MOUSE_BUTTON_RIGHT: return "RMB"
MOUSE_BUTTON_MIDDLE: return "MMB"
_: return "Mouse %d" % ev.button_index
return ""
+1
View File
@@ -0,0 +1 @@
uid://dh85b10pfih5e
+248
View File
@@ -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
+162
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://i1s8oc7snqnm
+36
View File
@@ -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():
+2 -2
View File
@@ -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")
+7 -2
View File
@@ -20,12 +20,15 @@ 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]
window/size/viewport_width=1280 window/size/viewport_width=1920
window/size/viewport_height=720 window/size/viewport_height=1080
window/stretch/mode="canvas_items"
window/stretch/aspect="expand"
[input] [input]
@@ -100,3 +103,5 @@ toggle_character_sheet={
3d_physics/layer_1="player" 3d_physics/layer_1="player"
3d_physics/layer_2="world" 3d_physics/layer_2="world"
3d_physics/layer_3="weapon" 3d_physics/layer_3="weapon"
3d_physics/layer_4="hitbox"
3d_physics/layer_5="hurtbox"