Compare commits
22
Commits
Dashfix
...
d4eb2e00be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4eb2e00be | ||
|
|
4d2b177f7b | ||
|
|
8f28304d0f | ||
|
|
fb10f7b042 | ||
|
|
f5e0642b39 | ||
|
|
1562b37563 | ||
|
|
97ebbb1618 | ||
|
|
7fa2efabaf | ||
|
|
7629342540 | ||
|
|
fce4c4a3e2 | ||
|
|
689181ac30 | ||
|
|
b606563f4d | ||
|
|
fab47fb1e0 | ||
|
|
ddc1522174 | ||
|
|
7d18de1621 | ||
|
|
10cc8720b7 | ||
|
|
2ec7d56511 | ||
|
|
b1f1016475 | ||
|
|
e192802f01 | ||
|
|
85e76d67ca | ||
|
|
6e4e3a3585 | ||
|
|
bdc507e852 |
@@ -4,111 +4,127 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## 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
|
||||
|
||||
### Running the Game
|
||||
- Open the project in Godot Editor and press F5 to run
|
||||
- Or use: `godot --path . res://level/scenes/level.tscn`
|
||||
- Open the project in Godot Editor and press F5, or: `godot --path . res://level/scenes/level.tscn`
|
||||
- Headless script-error check: `godot --headless --path . --quit`
|
||||
|
||||
### Testing Multiplayer Locally
|
||||
1. Run the main scene (level.tscn)
|
||||
2. Click "Host" on one instance to create a server (default port 8080)
|
||||
3. Run another instance and click "Join" to connect as a client
|
||||
1. Run one instance, click ENTER ARENA to host (port 8080)
|
||||
2. Run a second instance, click JOIN FRIEND to connect (IP 127.0.0.1)
|
||||
|
||||
### 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
|
||||
|
||||
### Inheritance Structure
|
||||
The codebase follows an inheritance-based design pattern:
|
||||
- **BaseUnit** (base class) - Common functionality for all game entities that can take damage/have health
|
||||
- **Character** extends **CharacterBody3D** - Player character class (class_name: Character)
|
||||
- Currently implements movement, jumping, sprinting, respawning, and skin customization
|
||||
- Uses multiplayer authority for input handling (only the owner processes input)
|
||||
### Autoloads (singletons)
|
||||
- **Network** (`level/scripts/network.gd`) — ENet setup, host/join, `players` dict keyed by peer_id
|
||||
(`{"nick": String, "skin": Character.SkinColor}`). Server = peer 1, MAX_PLAYERS = 10.
|
||||
- **GameState** (`level/scripts/game_state.gd`) — the economy. Persists `banked_gold`,
|
||||
`owned_items`, `selected_loadout` to `user://save.cfg`. Per-run: `run_gold`, `current_wave`,
|
||||
`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)
|
||||
- **Type**: Autoload singleton (accessible via `Network` globally)
|
||||
- **Protocol**: ENet (UDP-based)
|
||||
- **Architecture**: Client-Server (one host, multiple clients up to MAX_PLAYERS=10)
|
||||
- **Player Data**: Stored in `Network.players` dictionary keyed by peer_id
|
||||
- Contains: `{"nick": String, "skin": Character.SkinColor}`
|
||||
### Class hierarchy
|
||||
- **BaseUnit** (`base_unit.gd`) — health, damage, death, respawn, passive regen. Server validates
|
||||
all damage. NOTE: `died` can fire twice on the host (direct emit + call_local sync) — guard
|
||||
anything expensive hooked to it.
|
||||
- **Character** (`player.gd`, class_name Character) — player: movement, dash, attacks, blocking,
|
||||
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
|
||||
The project uses Godot 4's RPC system for multiplayer synchronization:
|
||||
- Use `@rpc("any_peer", "reliable")` for critical data (health, damage)
|
||||
- Use `@rpc("any_peer", "unreliable")` for frequent position updates
|
||||
- The server (multiplayer.is_server()) is authoritative for game state
|
||||
- Use `multiplayer.get_remote_sender_id()` to identify RPC sender
|
||||
- Use `rpc_id(peer_id, "method_name")` for targeted RPCs
|
||||
- `@rpc("any_peer", "reliable")` — server-validated actions (damage, heal); server checks
|
||||
`multiplayer.is_server()` inside.
|
||||
- `@rpc("any_peer", "call_local", "reliable")` — state sync to all peers (spawns, equips, health).
|
||||
- `@rpc("authority", ...)` — server-initiated (GameState credits, wave sync).
|
||||
- Use `rpc_id(peer_id, ...)` for targeted sync (late-join). When the server targets itself
|
||||
(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
|
||||
- **level/scenes/level.tscn** - Main scene with menu UI, chat, and PlayersContainer
|
||||
- level.gd spawns players dynamically when they connect
|
||||
- **level/scenes/player.tscn** - Player character scene
|
||||
- Instantiated by server when players connect
|
||||
- Named with peer_id for easy lookup
|
||||
### Scene Structure (level.tscn)
|
||||
- Containers the code expects by name: `PlayersContainer`, `WeaponsContainer`, `EnemiesContainer`,
|
||||
`OrbsContainer`, `PlayerSpawnPoints`, `EnemySpawner` (spawn points are its children named
|
||||
`EnemySpawnPoint*`).
|
||||
- Networked nodes are named deterministically for lookup: players by peer_id, `WorldWeapon_<id>`,
|
||||
`Enemy_<wave>_<n>`, `ArmedEnemy_<n>`, `HealthOrb_<id>`.
|
||||
|
||||
### Physics Layers
|
||||
- Layer 1: "player" - Player collision
|
||||
- Layer 2: "world" - Environment collision
|
||||
1 player · 2 world · 3 weapon · 4 hitbox · 5 hurtbox
|
||||
|
||||
### Input Actions
|
||||
Defined in project.godot:
|
||||
- `move_left`, `move_right`, `move_forward`, `move_backward` (WASD)
|
||||
- `jump` (Space)
|
||||
- `shift` (Left Shift for sprinting)
|
||||
- `quit` (Escape)
|
||||
- `toggle_chat` (Enter)
|
||||
### Input Actions (project.godot)
|
||||
`move_left/right/forward/backward` (WASD), `jump` (Space), `shift` (sprint), `dash` (F),
|
||||
`attack` (LMB), `block` (RMB), `pickup` (E), `toggle_character_sheet` (Tab),
|
||||
`toggle_chat` (F12), `quit` (Esc).
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### 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
|
||||
1. Create scripts in `level/scripts/`
|
||||
2. For multiplayer-synchronized components:
|
||||
- Always check `is_multiplayer_authority()` before processing input
|
||||
- Use RPC for state changes that need to replicate
|
||||
- Server should validate all important state changes
|
||||
### Verification
|
||||
- Always run `godot --headless --path . --quit` after script changes and check for SCRIPT ERROR.
|
||||
(Pre-existing "invalid UID" warnings on mesh/theme assets are known noise.)
|
||||
- Economy logic can be tested standalone: a SceneTree script that instantiates
|
||||
`game_state.gd` and exercises the API (`--script` mode; autoload `_ready` doesn't fire there,
|
||||
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)
|
||||
1. Create a new class that extends BaseUnit
|
||||
2. Implement required virtual methods
|
||||
3. Add any unique behavior in _physics_process/_process
|
||||
4. Ensure multiplayer authority is set correctly
|
||||
|
||||
### Testing Multiplayer
|
||||
- Always test with at least 2 instances (1 host, 1 client)
|
||||
- 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
|
||||
```
|
||||
### Adding Content
|
||||
- **New weapon**: create a WeaponData `.tres` in `level/resources/` with `cost`/`tier` — the shop
|
||||
picks it up automatically.
|
||||
- **New enemy**: extend BaseEnemy, set `gold_reward`, decide `can_respawn` (respawning enemies
|
||||
must not pay gold), add scene to the EnemySpawner pool or spawn via level.gd (connect its
|
||||
`died` signal for gold there).
|
||||
- **Multiplayer state changes**: server validates, then syncs via RPC; check
|
||||
`is_multiplayer_authority()` before processing input.
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
@@ -7,11 +7,15 @@
|
||||
script = ExtResource("1")
|
||||
weapon_name = "Wooden Shield"
|
||||
description = "A sturdy wooden shield. Can be used to bash enemies or block attacks."
|
||||
cost = 120
|
||||
tier = 1
|
||||
hand_type = 1
|
||||
damage = 8.0
|
||||
attack_range = 2.5
|
||||
attack_cooldown = 0.8
|
||||
knockback_force = 15.0
|
||||
startup_time = 0.25
|
||||
active_time = 0.15
|
||||
can_block = true
|
||||
block_reduction = 0.7
|
||||
mesh_scene = ExtResource("2")
|
||||
|
||||
@@ -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="PackedScene" path="res://level/scenes/weapons/sword_mesh.tscn" id="2"]
|
||||
[ext_resource type="Script" uid="uid://d2homvlmrg6xs" path="res://level/scripts/weapon_data.gd" id="1"]
|
||||
[ext_resource type="PackedScene" uid="uid://dyjfaq654xne3" path="res://level/scenes/weapons/sword_mesh.tscn" id="2"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
weapon_name = "Iron Sword"
|
||||
description = "A simple iron sword. Good for close combat."
|
||||
cost = 150
|
||||
tier = 1
|
||||
damage = 15.0
|
||||
attack_range = 3.5
|
||||
attack_cooldown = 0.6
|
||||
knockback_force = 12.0
|
||||
attack_animation = "Attack1"
|
||||
startup_time = 0.12
|
||||
active_time = 1.0
|
||||
mesh_scene = ExtResource("2")
|
||||
pickup_radius = 1.5
|
||||
weight = 2.0
|
||||
|
||||
@@ -7,9 +7,13 @@
|
||||
script = ExtResource("1")
|
||||
weapon_name = "Iron Sword"
|
||||
description = "A simple iron sword. Good for close combat."
|
||||
cost = 250
|
||||
tier = 2
|
||||
damage = 20.0
|
||||
attack_range = 3.5
|
||||
attack_cooldown = 0.6
|
||||
knockback_force = 12.0
|
||||
startup_time = 0.1
|
||||
active_time = 1.0
|
||||
mesh_scene = ExtResource("1_gdc1w")
|
||||
weight = 2.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[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"]
|
||||
|
||||
|
||||
@@ -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://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"]
|
||||
radius = 0.35796
|
||||
height = 1.73092
|
||||
|
||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_hurtbox"]
|
||||
radius = 0.4
|
||||
height = 1.8
|
||||
|
||||
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_xbohm"]
|
||||
properties/0/path = NodePath(".:position")
|
||||
properties/0/spawn = true
|
||||
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/replication_mode = 1
|
||||
properties/2/path = NodePath("PlayerNick/Nickname:text")
|
||||
properties/2/spawn = true
|
||||
properties/2/replication_mode = 1
|
||||
properties/3/path = NodePath("Armature:rotation")
|
||||
properties/3/path = NodePath("LilguyRigged/Armature:rotation")
|
||||
properties/3/spawn = true
|
||||
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
|
||||
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)
|
||||
script = ExtResource("3_body")
|
||||
_character = NodePath("..")
|
||||
_character = NodePath("../..")
|
||||
animation_player = NodePath("../AnimationPlayer")
|
||||
|
||||
[node name="Skeleton3D" parent="Armature" index="0"]
|
||||
bones/0/position = Vector3(-0.32852697, 2.914154, -546.76843)
|
||||
bones/0/rotation = Quaternion(-0.6608288, 0.28933647, -0.19178489, 0.6654384)
|
||||
[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.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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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)
|
||||
|
||||
[node name="WeaponPoint" type="BoneAttachment3D" parent="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)
|
||||
[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="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)
|
||||
|
||||
[node name="OffhandPoint" type="BoneAttachment3D" parent="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)
|
||||
[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="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)
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="." index="2"]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.066, 0.828, 0.01)
|
||||
[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_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)
|
||||
script = ExtResource("9_dlyie")
|
||||
script = ExtResource("4_spring")
|
||||
_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)
|
||||
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
|
||||
|
||||
[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)
|
||||
billboard = 1
|
||||
outline_modulate = Color(0, 0, 0, 0.301961)
|
||||
text = "player name test"
|
||||
|
||||
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="." index="5"]
|
||||
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="."]
|
||||
replication_config = SubResource("SceneReplicationConfig_xbohm")
|
||||
|
||||
[editable path="LilguyRigged"]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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="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://b48oxbcgxu3d8" path="res://assets/Objects/Colosseum_10.fbx" id="4_u750a"]
|
||||
[ext_resource type="PackedScene" uid="uid://dif4t1y3c07ax" path="res://level/scenes/enemies/practice_dummy.tscn" id="3_i7s07"]
|
||||
[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://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"]
|
||||
size = Vector2(90, 90)
|
||||
@@ -13,29 +18,7 @@ size = Vector2(90, 90)
|
||||
albedo_color = Color(0, 0.321569, 0.172549, 1)
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_x3h1o"]
|
||||
size = Vector3(90, 0.05, 90)
|
||||
|
||||
[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)
|
||||
size = Vector3(288.0893, 0.05, 350.08374)
|
||||
|
||||
[sub_resource type="Environment" id="Environment_qb4jd"]
|
||||
fog_enabled = true
|
||||
@@ -46,6 +29,8 @@ color = Color(0, 0, 0, 0)
|
||||
[node name="Level" type="Node3D"]
|
||||
script = ExtResource("1_e1sh7")
|
||||
player_scene = ExtResource("1_uvcbi")
|
||||
practice_dummy_scene = ExtResource("3_i7s07")
|
||||
armed_enemy_scene = ExtResource("4_armed")
|
||||
|
||||
[node name="Environment" type="Node3D" parent="."]
|
||||
|
||||
@@ -53,67 +38,19 @@ player_scene = ExtResource("1_uvcbi")
|
||||
collision_layer = 2
|
||||
|
||||
[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")
|
||||
surface_material_override/0 = SubResource("StandardMaterial3D_o02aj")
|
||||
|
||||
[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")
|
||||
|
||||
[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"]
|
||||
environment = SubResource("Environment_qb4jd")
|
||||
|
||||
[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_blur = 0.5
|
||||
|
||||
@@ -345,13 +282,44 @@ offset_right = 40.0
|
||||
offset_bottom = 40.0
|
||||
|
||||
[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="WorldWeaponSword" parent="WeaponsContainer" instance=ExtResource("5_cwx4m")]
|
||||
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/Join" to="." method="_on_join_pressed"]
|
||||
[connection signal="pressed" from="Menu/MainContainer/MainMenu/Option4/Quit" to="." method="_on_quit_pressed"]
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
[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="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="Texture2D" uid="uid://cw6pxwst2gh85" 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://fpmmv2oxjcdv" path="res://assets/characters/player/GodotRobotPaletteSwap/GodotPalette.png" id="4_max2b"]
|
||||
[ext_resource type="Texture2D" uid="uid://bx1whlf637r18" path="res://assets/characters/player/GodotRobotPaletteSwap/GodotYellowPalette.png" id="3_l3dv8"]
|
||||
[ext_resource type="Texture2D" uid="uid://cjqqgbprwdl6h" path="res://assets/characters/player/GodotRobotPaletteSwap/GodotGreenPalette.png" id="4_74ree"]
|
||||
[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="Texture2D" uid="uid://brp1gy30s4rks" 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="Texture2D" uid="uid://1p20xwfvvfk2" path="res://assets/characters/player/GodotRobotPaletteSwap/GodotRedPalette.png" id="5_qlefn"]
|
||||
[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"]
|
||||
radius = 0.35796
|
||||
@@ -5055,7 +5055,7 @@ _spring_arm = NodePath("SpringArm3D")
|
||||
|
||||
[node name="SpringArm3D" type="SpringArm3D" parent="SpringArmOffset"]
|
||||
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"]
|
||||
current = true
|
||||
|
||||
@@ -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.
@@ -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")
|
||||
@@ -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="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="TestSword" parent="." instance=ExtResource("1_4fdvi")]
|
||||
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")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[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://dmottq5u3my52" path="res://assets/characters/Lobster/10029_Lobster_v1_iterations-2.obj" id="2_gfu4n"]
|
||||
[ext_resource type="ArrayMesh" uid="uid://bwpa5ct4buxs0" path="res://assets/Objects/shield.obj" id="1"]
|
||||
[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"]
|
||||
|
||||
|
||||
@@ -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="MeshInstance3D" type="MeshInstance3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4.4839063, -2.9492104, -3.4711354)
|
||||
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")
|
||||
@@ -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"]
|
||||
|
||||
[sub_resource type="BoxShape3D" id="1"]
|
||||
|
||||
@@ -13,6 +13,10 @@ func apply_rotation(_velocity: Vector3) -> void:
|
||||
|
||||
# rpc("sync_player_rotation", new_rotation_y)
|
||||
|
||||
## Instantly face a direction (no lerp) - used to lock attacks to camera facing
|
||||
func snap_rotation(_direction: Vector3) -> void:
|
||||
rotation.y = atan2(-_direction.x, -_direction.z)
|
||||
|
||||
func animate(_velocity: Vector3) -> void:
|
||||
# Don't override attack animation if it's playing
|
||||
if animation_player.is_playing() and animation_player.current_animation == "Attack1":
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://deefoag762nvc
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://base_enemy_script
|
||||
@@ -11,15 +11,29 @@ signal respawned()
|
||||
@export var max_health: float = 100.0
|
||||
@export var can_respawn: bool = true
|
||||
@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 is_dead: bool = false
|
||||
var _respawn_point: Vector3 = Vector3.ZERO
|
||||
var _last_damage_time: float = -1000.0
|
||||
|
||||
func _ready():
|
||||
current_health = max_health
|
||||
_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():
|
||||
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:
|
||||
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)
|
||||
var final_damage = amount
|
||||
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:
|
||||
_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
|
||||
@rpc("any_peer", "reliable")
|
||||
func heal(amount: float):
|
||||
|
||||
+157
-54
@@ -3,20 +3,24 @@ class_name BaseWeapon
|
||||
|
||||
## Base class for equipped weapons
|
||||
## Attached to player's hand via BoneAttachment3D
|
||||
## Provides common weapon functionality and stats
|
||||
## Uses HitBox/HurtBox system for damage detection
|
||||
|
||||
signal attack_performed()
|
||||
signal hit_connected(target: Node)
|
||||
|
||||
@export var weapon_data: WeaponData
|
||||
|
||||
# Runtime references
|
||||
var owner_character: Character = null
|
||||
var owner_character: Node = null # Can be Character or ArmedEnemy
|
||||
var _mesh_instance: Node3D = null
|
||||
var _attack_timer: float = 0.0
|
||||
var _hitbox: HitBox = null
|
||||
var _is_attacking: bool = false # Prevents overlapping attacks
|
||||
|
||||
func _ready():
|
||||
if weapon_data and weapon_data.mesh_scene:
|
||||
_spawn_mesh()
|
||||
_setup_hitbox()
|
||||
|
||||
func _process(delta):
|
||||
if _attack_timer > 0:
|
||||
@@ -32,85 +36,184 @@ func _spawn_mesh():
|
||||
_mesh_instance = weapon_data.mesh_scene.instantiate()
|
||||
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
|
||||
## Called by the character who owns this weapon
|
||||
func perform_attack() -> bool:
|
||||
if not weapon_data or not owner_character:
|
||||
return false
|
||||
|
||||
# Check cooldown
|
||||
if _attack_timer > 0:
|
||||
# Check cooldown and if already attacking
|
||||
if _attack_timer > 0 or _is_attacking:
|
||||
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)
|
||||
if owner_character and owner_character.is_multiplayer_authority():
|
||||
owner_character._attack_timer = weapon_data.attack_cooldown
|
||||
# Set cooldown to at least cover the full attack duration
|
||||
var cooldown = max(weapon_data.attack_cooldown, total_duration)
|
||||
_attack_timer = cooldown
|
||||
_is_attacking = true
|
||||
|
||||
# Play attack animation on owner
|
||||
if owner_character._body:
|
||||
owner_character._body.play_attack()
|
||||
# Notify owner character of attack cooldown (for UI) - only for Characters
|
||||
if owner_character.is_multiplayer_authority() and "_attack_timer" in owner_character:
|
||||
owner_character._attack_timer = cooldown
|
||||
|
||||
# Delay damage until animation hits (roughly 70% through the animation)
|
||||
# This makes the damage apply when the swing actually connects
|
||||
var damage_delay = weapon_data.attack_cooldown * 0.4 # Adjust this multiplier to change when damage happens
|
||||
get_tree().create_timer(damage_delay).timeout.connect(_find_and_damage_targets)
|
||||
# Play attack animation on owner (use weapon's animation)
|
||||
if "_body" in owner_character and owner_character._body:
|
||||
var anim_name = weapon_data.attack_animation if weapon_data.attack_animation else "Attack_OneHand"
|
||||
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()
|
||||
return true
|
||||
|
||||
## Find targets in range and apply damage
|
||||
func _find_and_damage_targets():
|
||||
if not owner_character:
|
||||
## Activate the hitbox for attack detection - waits for startup, then activates
|
||||
func _activate_hitbox():
|
||||
if not _hitbox:
|
||||
_is_attacking = false
|
||||
return
|
||||
|
||||
# Check if the owner character has authority (not this node)
|
||||
if not owner_character.is_multiplayer_authority():
|
||||
# Update owner reference in case it changed
|
||||
_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
|
||||
|
||||
var space_state = get_world_3d().direct_space_state
|
||||
var query = PhysicsShapeQueryParameters3D.new()
|
||||
var sphere = SphereShape3D.new()
|
||||
sphere.radius = weapon_data.attack_range
|
||||
query.shape = sphere
|
||||
query.transform = global_transform
|
||||
query.collision_mask = 1 # Player layer
|
||||
# ACTIVE PHASE - Hitbox on, can deal damage
|
||||
_hitbox.activate()
|
||||
|
||||
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:
|
||||
var hit_body = result["collider"]
|
||||
if hit_body != owner_character and hit_body is BaseUnit:
|
||||
var attacker_id = multiplayer.get_unique_id()
|
||||
# RECOVERY PHASE - Hitbox off
|
||||
if _hitbox and is_instance_valid(_hitbox):
|
||||
_hitbox.deactivate()
|
||||
|
||||
# If we're the server, apply damage directly
|
||||
if multiplayer.is_server():
|
||||
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
|
||||
# Attack complete
|
||||
_is_attacking = false
|
||||
|
||||
## Check if weapon can attack
|
||||
func can_attack() -> bool:
|
||||
return _attack_timer <= 0
|
||||
return _attack_timer <= 0 and not _is_attacking
|
||||
|
||||
## Set the character who owns this weapon
|
||||
func set_owner_character(character: Character):
|
||||
## Set the character who owns this weapon (can be Character or ArmedEnemy)
|
||||
func set_owner_character(character: Node):
|
||||
owner_character = character
|
||||
# Update hitbox owner
|
||||
if _hitbox:
|
||||
_hitbox.owner_entity = character
|
||||
|
||||
## Get weapon stats
|
||||
func get_damage() -> float:
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://cd87rsuiqhdav
|
||||
@@ -0,0 +1,34 @@
|
||||
extends BasicEnemy
|
||||
class_name BossLobster
|
||||
## The Snippest itself: the basic lobster, scaled up in size and stats.
|
||||
## Spawned by the EnemySpawner on boss milestone waves.
|
||||
|
||||
const BOSS_MULTIPLIER := 2.0
|
||||
|
||||
func _ready():
|
||||
# Scaled combat stats - applied BEFORE super._ready() so the hitbox
|
||||
# picks them up via set_stats(attack_damage, attack_knockback)
|
||||
max_health *= BOSS_MULTIPLIER
|
||||
attack_damage *= BOSS_MULTIPLIER
|
||||
attack_knockback *= BOSS_MULTIPLIER
|
||||
attack_range *= BOSS_MULTIPLIER # A bigger lobster has longer reach
|
||||
|
||||
# Movement deliberately stays near normal - big things lumber
|
||||
move_speed *= 1.2
|
||||
|
||||
# Boss rewards: jackpot gold, guaranteed health orb
|
||||
gold_reward = 250
|
||||
health_orb_drop_chance = 1.0
|
||||
|
||||
super._ready()
|
||||
|
||||
_apply_boss_scale()
|
||||
|
||||
## Uniformly scale the visual and collision children around the root.
|
||||
## Child-node scaling (not root CharacterBody3D scale, not shared shape
|
||||
## resources) keeps physics sane and other lobsters unaffected.
|
||||
func _apply_boss_scale():
|
||||
for child_name in ["Mesh", "CollisionShape3D", "HurtBox", "HitBox"]:
|
||||
var child = get_node_or_null(child_name)
|
||||
if child:
|
||||
child.transform = child.transform.scaled(Vector3.ONE * BOSS_MULTIPLIER)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b7mbsg83olhh4
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dim3dvik1fd27
|
||||
@@ -0,0 +1,286 @@
|
||||
extends Node
|
||||
## Autoload singleton holding the gladiator roguelike economy + meta-progression.
|
||||
## Accessible globally as `GameState`.
|
||||
##
|
||||
## - banked_gold persists between sessions (saved to user://save.cfg).
|
||||
## - owned_items are resource paths of weapons the player has bought.
|
||||
## - run_gold is gold earned during the current arena run (NOT persisted).
|
||||
##
|
||||
## Death settlement: on run end you keep 10% of your total value (banked + run
|
||||
## gold + owned gear value), floored at the DEFAULT_GOLD starting amount so you
|
||||
## can never fall below a fresh start. This punishes hoarding and rewards spending
|
||||
## before you enter the arena.
|
||||
|
||||
const SAVE_PATH: String = "user://save.cfg"
|
||||
const DEFAULT_GOLD: int = 500
|
||||
## Fraction of total value kept on death.
|
||||
const SURVIVAL_KEEP_FRACTION: float = 0.10
|
||||
|
||||
# Persisted progression
|
||||
var banked_gold: int = DEFAULT_GOLD
|
||||
var owned_items: Array[String] = []
|
||||
# Loadout chosen in the outfitting screen: slot -> WeaponData resource path ("" = empty)
|
||||
var selected_loadout: Dictionary = {"main": "", "offhand": ""}
|
||||
|
||||
# Per-run state (reset each run, not saved)
|
||||
var run_gold: int = 0
|
||||
var run_active: bool = false
|
||||
var current_wave: int = 0
|
||||
var kills_this_run: int = 0
|
||||
var last_run_stats: Dictionary = {}
|
||||
var _run_start_msec: int = 0
|
||||
|
||||
signal gold_changed(banked_gold: int)
|
||||
signal run_gold_changed(run_gold: int)
|
||||
signal wave_changed(wave: int)
|
||||
signal run_ended(stats: Dictionary)
|
||||
signal loadout_changed(loadout: Dictionary)
|
||||
|
||||
func _ready() -> void:
|
||||
load_save()
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
# Closing the window mid-run counts as walking out alive - bank the winnings
|
||||
if what == NOTIFICATION_WM_CLOSE_REQUEST and run_active:
|
||||
retire_run()
|
||||
|
||||
# --- Persistence ---------------------------------------------------------
|
||||
|
||||
func load_save() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
var err := cfg.load(SAVE_PATH)
|
||||
if err != OK:
|
||||
# No save yet (or unreadable): start fresh.
|
||||
banked_gold = DEFAULT_GOLD
|
||||
owned_items = []
|
||||
save_game()
|
||||
return
|
||||
banked_gold = int(cfg.get_value("progress", "banked_gold", DEFAULT_GOLD))
|
||||
# ConfigFile returns an untyped Array; copy into the typed array.
|
||||
owned_items.clear()
|
||||
for path in cfg.get_value("progress", "owned_items", []):
|
||||
owned_items.append(str(path))
|
||||
# Restore loadout, dropping anything no longer owned
|
||||
var saved_loadout = cfg.get_value("progress", "selected_loadout", {"main": "", "offhand": ""})
|
||||
for slot in ["main", "offhand"]:
|
||||
var path := str(saved_loadout.get(slot, ""))
|
||||
selected_loadout[slot] = path if owns_item(path) else ""
|
||||
|
||||
func save_game() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
cfg.set_value("progress", "banked_gold", banked_gold)
|
||||
cfg.set_value("progress", "owned_items", owned_items)
|
||||
cfg.set_value("progress", "selected_loadout", selected_loadout)
|
||||
cfg.save(SAVE_PATH)
|
||||
|
||||
# --- Shop economy --------------------------------------------------------
|
||||
|
||||
func can_afford(cost: int) -> bool:
|
||||
return banked_gold >= cost
|
||||
|
||||
## Buy an item by its WeaponData resource path. Returns true on success.
|
||||
func buy_item(resource_path: String, cost: int) -> bool:
|
||||
if not can_afford(cost):
|
||||
return false
|
||||
banked_gold -= cost
|
||||
if not owned_items.has(resource_path):
|
||||
owned_items.append(resource_path)
|
||||
gold_changed.emit(banked_gold)
|
||||
save_game()
|
||||
return true
|
||||
|
||||
func owns_item(resource_path: String) -> bool:
|
||||
return owned_items.has(resource_path)
|
||||
|
||||
# --- Loadout (what you walk into the arena with) --------------------------
|
||||
|
||||
## Equip an owned item into the slot its hand_type dictates.
|
||||
## Two-handed weapons take the main slot and clear the off-hand.
|
||||
func equip_loadout_item(resource_path: String) -> bool:
|
||||
if not owns_item(resource_path):
|
||||
return false
|
||||
var data = load(resource_path)
|
||||
if not data is WeaponData:
|
||||
return false
|
||||
|
||||
match data.hand_type:
|
||||
WeaponData.Hand.OFF_HAND:
|
||||
selected_loadout["offhand"] = resource_path
|
||||
# An off-hand displaces a two-handed main - you only have two hands
|
||||
if _is_two_hand(selected_loadout.get("main", "")):
|
||||
selected_loadout["main"] = ""
|
||||
WeaponData.Hand.TWO_HAND:
|
||||
selected_loadout["main"] = resource_path
|
||||
selected_loadout["offhand"] = ""
|
||||
_: # MAIN_HAND
|
||||
selected_loadout["main"] = resource_path
|
||||
# If the previous main was two-handed it's gone now; offhand stays as-is
|
||||
loadout_changed.emit(selected_loadout)
|
||||
save_game()
|
||||
return true
|
||||
|
||||
func unequip_loadout_slot(slot: String) -> void:
|
||||
if selected_loadout.get(slot, "") != "":
|
||||
selected_loadout[slot] = ""
|
||||
loadout_changed.emit(selected_loadout)
|
||||
save_game()
|
||||
|
||||
func is_loadout_item_selected(resource_path: String) -> bool:
|
||||
return selected_loadout.get("main", "") == resource_path \
|
||||
or selected_loadout.get("offhand", "") == resource_path
|
||||
|
||||
func get_selected_loadout() -> Dictionary:
|
||||
return selected_loadout
|
||||
|
||||
# --- Run lifecycle -------------------------------------------------------
|
||||
|
||||
func start_run() -> void:
|
||||
run_gold = 0
|
||||
current_wave = 0
|
||||
kills_this_run = 0
|
||||
run_active = true
|
||||
_run_start_msec = Time.get_ticks_msec()
|
||||
run_gold_changed.emit(run_gold)
|
||||
wave_changed.emit(current_wave)
|
||||
|
||||
func add_run_gold(amount: int) -> void:
|
||||
run_gold += amount
|
||||
run_gold_changed.emit(run_gold)
|
||||
|
||||
## Seconds since the current run started.
|
||||
func get_run_time() -> float:
|
||||
if _run_start_msec == 0:
|
||||
return 0.0
|
||||
return (Time.get_ticks_msec() - _run_start_msec) / 1000.0
|
||||
|
||||
## Total liquid value of the player: banked + earned this run + value of owned gear.
|
||||
func get_total_value() -> int:
|
||||
var total := banked_gold + run_gold
|
||||
for path in owned_items:
|
||||
total += _item_cost(path)
|
||||
return total
|
||||
|
||||
## Settle a finished run (death). Liquidates gear, keeps 10% of total value
|
||||
## floored at DEFAULT_GOLD, then persists. Returns a stats Dictionary.
|
||||
## Guarded against double-calls (died can fire more than once on the host).
|
||||
func end_run() -> Dictionary:
|
||||
if not run_active:
|
||||
return last_run_stats
|
||||
run_active = false
|
||||
|
||||
var total := get_total_value()
|
||||
var kept: int = max(DEFAULT_GOLD, int(floor(total * SURVIVAL_KEEP_FRACTION)))
|
||||
|
||||
last_run_stats = {
|
||||
"waves": current_wave,
|
||||
"kills": kills_this_run,
|
||||
"time": get_run_time(),
|
||||
"gold_earned": run_gold,
|
||||
"total_value": total,
|
||||
"kept": kept,
|
||||
}
|
||||
|
||||
banked_gold = kept
|
||||
owned_items.clear()
|
||||
selected_loadout = {"main": "", "offhand": ""}
|
||||
run_gold = 0
|
||||
gold_changed.emit(banked_gold)
|
||||
run_gold_changed.emit(run_gold)
|
||||
loadout_changed.emit(selected_loadout)
|
||||
save_game()
|
||||
run_ended.emit(last_run_stats)
|
||||
return last_run_stats
|
||||
|
||||
## Walk out of the arena alive: bank 100% of run gold, keep all gear.
|
||||
## The reward for retiring instead of dying (death only keeps 10%).
|
||||
func retire_run() -> Dictionary:
|
||||
if not run_active:
|
||||
return last_run_stats
|
||||
run_active = false
|
||||
|
||||
last_run_stats = {
|
||||
"waves": current_wave,
|
||||
"kills": kills_this_run,
|
||||
"time": get_run_time(),
|
||||
"gold_earned": run_gold,
|
||||
"total_value": get_total_value(),
|
||||
"kept": banked_gold + run_gold,
|
||||
"retired": true,
|
||||
}
|
||||
|
||||
banked_gold += run_gold
|
||||
run_gold = 0
|
||||
gold_changed.emit(banked_gold)
|
||||
run_gold_changed.emit(run_gold)
|
||||
save_game()
|
||||
run_ended.emit(last_run_stats)
|
||||
return last_run_stats
|
||||
|
||||
# --- Multiplayer crediting (server -> owning peer) ------------------------
|
||||
# GameState is an autoload, so it exists at the same path on every peer with
|
||||
# the server (peer 1) as its multiplayer authority. The server decides who
|
||||
# earned gold and credits exactly that peer's local GameState.
|
||||
|
||||
## Server-side entry point: credit kill gold (and a kill) to a specific peer.
|
||||
func server_credit_kill(peer_id: int, amount: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
if peer_id == 1:
|
||||
credit_kill(amount) # Host credits itself directly
|
||||
elif _is_known_peer(peer_id):
|
||||
rpc_id(peer_id, "credit_kill", amount)
|
||||
|
||||
## Server-side entry point: credit plain gold (e.g. wave bonus) to a peer.
|
||||
func server_credit_gold(peer_id: int, amount: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
if peer_id == 1:
|
||||
credit_gold(amount)
|
||||
elif _is_known_peer(peer_id):
|
||||
rpc_id(peer_id, "credit_gold", amount)
|
||||
|
||||
## Runtime lookup so this singleton has no compile-time dependency on Network
|
||||
## (keeps the economy testable standalone).
|
||||
func _is_known_peer(peer_id: int) -> bool:
|
||||
var network = get_node_or_null("/root/Network")
|
||||
return network != null and network.players.has(peer_id)
|
||||
|
||||
## Server-side entry point: broadcast the current wave number to all peers.
|
||||
func server_sync_wave(wave: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
rpc("sync_wave", wave)
|
||||
|
||||
@rpc("authority", "reliable")
|
||||
func credit_gold(amount: int) -> void:
|
||||
if not run_active:
|
||||
return
|
||||
add_run_gold(amount)
|
||||
|
||||
@rpc("authority", "reliable")
|
||||
func credit_kill(amount: int) -> void:
|
||||
if not run_active:
|
||||
return
|
||||
kills_this_run += 1
|
||||
add_run_gold(amount)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_wave(wave: int) -> void:
|
||||
current_wave = wave
|
||||
wave_changed.emit(current_wave)
|
||||
|
||||
# --- Helpers -------------------------------------------------------------
|
||||
|
||||
func _is_two_hand(resource_path: String) -> bool:
|
||||
if resource_path.is_empty() or not ResourceLoader.exists(resource_path):
|
||||
return false
|
||||
var res = load(resource_path)
|
||||
return res is WeaponData and res.hand_type == WeaponData.Hand.TWO_HAND
|
||||
|
||||
func _item_cost(resource_path: String) -> int:
|
||||
if resource_path.is_empty() or not ResourceLoader.exists(resource_path):
|
||||
return 0
|
||||
var res := load(resource_path)
|
||||
if res is WeaponData:
|
||||
return res.cost
|
||||
return 0
|
||||
@@ -0,0 +1 @@
|
||||
uid://dlcyyjtlb8s7x
|
||||
@@ -0,0 +1,51 @@
|
||||
extends Area3D
|
||||
class_name HealthOrb
|
||||
|
||||
## Floating pickup that restores health when a player walks into it.
|
||||
## Server-authoritative pickup; the bob/spin visuals run locally on every peer.
|
||||
|
||||
@export var heal_amount: float = 25.0
|
||||
@export var bob_height: float = 0.3
|
||||
@export var bob_speed: float = 2.0
|
||||
@export var spin_speed: float = 2.0
|
||||
|
||||
var orb_id: int = -1 # Assigned by Level when spawned
|
||||
var _base_y: float = 0.0
|
||||
var _time: float = 0.0
|
||||
var _collected: bool = false
|
||||
@onready var _mesh: Node3D = get_node_or_null("Mesh")
|
||||
|
||||
func _ready():
|
||||
_base_y = position.y
|
||||
_time = randf() * TAU # Random phase so multiple orbs don't bob in unison
|
||||
|
||||
# Detect players (physics layer 1) without colliding with anything
|
||||
collision_layer = 0
|
||||
collision_mask = 1
|
||||
|
||||
# Only the server resolves pickups
|
||||
if multiplayer.is_server():
|
||||
body_entered.connect(_on_body_entered)
|
||||
|
||||
func _process(delta):
|
||||
_time += delta
|
||||
position.y = _base_y + sin(_time * bob_speed) * bob_height
|
||||
if _mesh:
|
||||
_mesh.rotate_y(spin_speed * delta)
|
||||
|
||||
func _on_body_entered(body: Node3D):
|
||||
if _collected or not multiplayer.is_server():
|
||||
return
|
||||
if not (body is Character) or body.is_dead:
|
||||
return
|
||||
# Leave the orb in place if the player is already at full health
|
||||
if body.current_health >= body.max_health:
|
||||
return
|
||||
|
||||
_collected = true
|
||||
body.heal(heal_amount)
|
||||
|
||||
# Remove the orb from every client via the level's centralized system
|
||||
var level = get_tree().get_current_scene()
|
||||
if level and level.has_method("remove_health_orb"):
|
||||
level.remove_health_orb(orb_id)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bbekeah8iio6t
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://jyas86y3f0jp
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bj3uepduxvgju
|
||||
+544
-86
@@ -4,15 +4,25 @@ extends Node3D
|
||||
@onready var nick_input: LineEdit = $Menu/MainContainer/MainMenu/Option1/NickInput
|
||||
@onready var address_input: LineEdit = $Menu/MainContainer/MainMenu/Option3/AddressInput
|
||||
@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 main_menu: VBoxContainer = $Menu/MainContainer/MainMenu
|
||||
@export var player_scene: PackedScene
|
||||
@export var practice_dummy_scene: PackedScene
|
||||
@export var armed_enemy_scene: PackedScene
|
||||
|
||||
# Weapon spawning counter (server-side only)
|
||||
var _weapon_spawn_counter: int = 0
|
||||
# Track active weapons for late-join sync (server-side only)
|
||||
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
|
||||
@onready var message: LineEdit = $MultiplayerChat/VBoxContainer/HBoxContainer/Message
|
||||
@@ -23,6 +33,8 @@ var _active_weapons: Dictionary = {} # weapon_id -> WorldWeapon reference
|
||||
var chat_visible = false
|
||||
|
||||
func _ready():
|
||||
print("[Level] _ready() called. Peer ID: ", multiplayer.get_unique_id(), " Is server: ", multiplayer.is_server())
|
||||
|
||||
multiplayer_chat.hide()
|
||||
menu.show()
|
||||
multiplayer_chat.set_process_input(true)
|
||||
@@ -30,6 +42,12 @@ func _ready():
|
||||
# Add quick-fill 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
|
||||
if has_node("WeaponsContainer"):
|
||||
weapons_container = get_node("WeaponsContainer")
|
||||
@@ -39,121 +57,161 @@ func _ready():
|
||||
add_child(weapons_container)
|
||||
print("Created WeaponsContainer")
|
||||
|
||||
# Clients: Remove manually placed weapons (server will sync them via RPC)
|
||||
if not multiplayer.is_server():
|
||||
_cleanup_manual_weapons_on_client()
|
||||
# Create or find enemies container
|
||||
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()
|
||||
|
||||
## 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
|
||||
|
||||
Network.connect("player_connected", Callable(self, "_on_player_connected"))
|
||||
multiplayer.peer_disconnected.connect(_remove_player)
|
||||
_multiplayer_initialized = true
|
||||
|
||||
# Initialize any manually placed weapons in the scene
|
||||
_initialize_manual_weapons()
|
||||
# Begin this player's arena run (starts the survival clock + gold tracking)
|
||||
GameState.start_run()
|
||||
|
||||
# Spawn initial weapons when server starts
|
||||
_spawn_initial_weapons()
|
||||
if multiplayer.is_server():
|
||||
print("[Level] Running server initialization")
|
||||
Network.connect("player_connected", Callable(self, "_on_player_connected"))
|
||||
multiplayer.peer_disconnected.connect(_remove_player)
|
||||
|
||||
# Remove any manually placed weapons - free floor loot undermines the shop.
|
||||
# Weapons only enter the arena via the armory or dropped by slain enemies.
|
||||
_remove_manual_weapons_on_server()
|
||||
|
||||
# Spawn practice dummies
|
||||
_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():
|
||||
"""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:
|
||||
print("[Client] No weapons_container found!")
|
||||
return
|
||||
|
||||
print("[Client] WeaponsContainer has ", weapons_container.get_child_count(), " children")
|
||||
|
||||
var weapons_to_remove = []
|
||||
for child in weapons_container.get_children():
|
||||
if child is WorldWeapon and child.weapon_id == -1:
|
||||
weapons_to_remove.append(child)
|
||||
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)
|
||||
print("[Client] - Marked for removal")
|
||||
|
||||
print("[Client] Found ", weapons_to_remove.size(), " weapons to remove")
|
||||
for weapon in weapons_to_remove:
|
||||
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():
|
||||
"""Initialize any WorldWeapon nodes manually placed in the level scene"""
|
||||
func _remove_manual_weapons_on_server():
|
||||
"""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():
|
||||
return
|
||||
|
||||
if not weapons_container:
|
||||
return
|
||||
|
||||
# Find all WorldWeapon nodes in the weapons container
|
||||
var manual_weapons = []
|
||||
var removed = 0
|
||||
for child in weapons_container.get_children():
|
||||
if child is WorldWeapon:
|
||||
manual_weapons.append(child)
|
||||
if child is WorldWeapon and child.weapon_id == -1:
|
||||
weapons_container.remove_child(child)
|
||||
child.free()
|
||||
removed += 1
|
||||
|
||||
if manual_weapons.is_empty():
|
||||
print("[Server] No manually placed weapons found")
|
||||
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
|
||||
)
|
||||
if removed > 0:
|
||||
print("[Server] Removed ", removed, " manually placed floor weapon(s)")
|
||||
|
||||
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)
|
||||
|
||||
# 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)
|
||||
if multiplayer.is_server() and peer_id != 1:
|
||||
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())
|
||||
for weapon_id in _active_weapons.keys():
|
||||
var weapon = _active_weapons[weapon_id]
|
||||
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
|
||||
rpc_id(peer_id, "_client_spawn_weapon",
|
||||
weapon.weapon_data.resource_path,
|
||||
@@ -161,25 +219,119 @@ func _on_player_connected(peer_id, player_info):
|
||||
Vector3.ZERO,
|
||||
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():
|
||||
print("[Level] Host button pressed")
|
||||
menu.hide()
|
||||
print("[Level] Calling Network.start_host()")
|
||||
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():
|
||||
print("[Level] Join button pressed")
|
||||
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())
|
||||
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):
|
||||
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)):
|
||||
print("[Level] Player ", id, " already exists, skipping")
|
||||
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()
|
||||
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)
|
||||
print("[Peer ", multiplayer.get_unique_id(), "] Player ", id, " spawned at ", player.position)
|
||||
|
||||
var nick = Network.players[id]["nick"]
|
||||
player.nickname.text = nick
|
||||
# Get player info from Network
|
||||
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)
|
||||
|
||||
# Set up HUD for local player
|
||||
@@ -192,6 +344,13 @@ func _add_player(id: int, player_info : Dictionary):
|
||||
else:
|
||||
push_warning("[Level] HUD autoload not found! Make sure to restart Godot to register the new autoload.")
|
||||
|
||||
# Equip the loadout chosen in the outfitting screen (broadcasts to all peers)
|
||||
var loadout = GameState.get_selected_loadout()
|
||||
if loadout.get("main", "") != "":
|
||||
player.rpc("equip_weapon_from_world", loadout["main"])
|
||||
if loadout.get("offhand", "") != "":
|
||||
player.rpc("equip_weapon_from_world", loadout["offhand"])
|
||||
|
||||
var skin_enum = player_info["skin"]
|
||||
player.set_player_skin(skin_enum)
|
||||
# rpc("sync_player_skin", id, skin_enum)
|
||||
@@ -199,8 +358,22 @@ func _add_player(id: int, player_info : Dictionary):
|
||||
# rpc("sync_player_position", id, player.position)
|
||||
|
||||
func get_spawn_point() -> Vector3:
|
||||
var spawn_point = Vector2.from_angle(randf() * 2 * PI) * 10 # spawn radius
|
||||
return Vector3(spawn_point.x, 0, spawn_point.y)
|
||||
# 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
|
||||
return Vector3(spawn_point.x, 0, spawn_point.y)
|
||||
|
||||
func _remove_player(id):
|
||||
if not multiplayer.is_server() or not players_container.has_node(str(id)):
|
||||
@@ -245,6 +418,16 @@ func _input(event):
|
||||
toggle_chat()
|
||||
elif event is InputEventKey and event.keycode == KEY_ENTER:
|
||||
_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:
|
||||
var trimmed_message = message.text.strip_edges()
|
||||
@@ -261,6 +444,40 @@ func _on_send_pressed() -> void:
|
||||
func msg_rpc(nick, msg):
|
||||
chat.text += str(nick, " : ", msg, "\n")
|
||||
|
||||
# ---------- OUTFITTING MENU ----------
|
||||
## Turn the plain host/join menu into the gladiator camp:
|
||||
## menu options on the left, armory shop + character sheet preview on the right.
|
||||
func _setup_outfitting_menu():
|
||||
# Retitle and shrink the header so it fits the narrowed left column
|
||||
var title = menu.get_node_or_null("MainContainer/Label")
|
||||
if title:
|
||||
title.text = "SURVIVAL OF\nTHE SNIPPEST"
|
||||
title.add_theme_font_size_override("font_size", 42)
|
||||
|
||||
# Narrow the existing menu column to the left third of the screen
|
||||
var main_container = menu.get_node_or_null("MainContainer")
|
||||
if main_container:
|
||||
main_container.anchor_right = 0.34
|
||||
|
||||
# Rename buttons to fit the arena fiction
|
||||
var host_button = menu.get_node_or_null("MainContainer/MainMenu/Buttons/Host")
|
||||
if host_button:
|
||||
host_button.text = "ENTER ARENA"
|
||||
var join_button = menu.get_node_or_null("MainContainer/MainMenu/Buttons/Join")
|
||||
if join_button:
|
||||
join_button.text = "JOIN FRIEND"
|
||||
|
||||
# Armory + preview fill the rest of the screen
|
||||
var outfitting_script = load("res://level/ui/scripts/outfitting_screen.gd")
|
||||
var outfitting = outfitting_script.new()
|
||||
outfitting.name = "OutfittingScreen"
|
||||
outfitting.anchor_left = 0.35
|
||||
outfitting.anchor_top = 0.04
|
||||
outfitting.anchor_right = 0.985
|
||||
outfitting.anchor_bottom = 0.96
|
||||
menu.add_child(outfitting)
|
||||
outfitting.set_nick_input(nick_input)
|
||||
|
||||
# ---------- PRESET BUTTONS ----------
|
||||
func _create_preset_buttons():
|
||||
# Create a container for preset buttons
|
||||
@@ -297,6 +514,63 @@ func _on_jemz_preset():
|
||||
skin_input.text = "Red"
|
||||
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 ----------
|
||||
## Spawn a weapon in the world (called from server, syncs to all clients)
|
||||
@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!")
|
||||
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
|
||||
print("[Server] Broadcasting removal RPC to all clients")
|
||||
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
|
||||
print("[Client ", multiplayer.get_unique_id(), "] Calling spawn_world_weapon locally")
|
||||
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, "", "")
|
||||
|
||||
@@ -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)
|
||||
rotation.y = new_rotation_y
|
||||
|
||||
## Instantly face a direction (no lerp) - used to lock attacks to camera facing
|
||||
func snap_rotation(_direction: Vector3) -> void:
|
||||
rotation.y = atan2(_direction.x, _direction.z)
|
||||
|
||||
func animate(_velocity: Vector3) -> void:
|
||||
if not animation_player:
|
||||
return
|
||||
|
||||
# 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
|
||||
|
||||
# Check if we're dashing
|
||||
if _character._is_dashing:
|
||||
# Check if we're dashing (defensive check for enemies that don't have this)
|
||||
if _character and "_is_dashing" in _character and _character._is_dashing:
|
||||
if animation_player.current_animation != "Jump":
|
||||
_play_animation("Jump")
|
||||
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:
|
||||
# Falling - use FallIdle animation
|
||||
_play_animation("FallIdle")
|
||||
@@ -34,7 +41,14 @@ func animate(_velocity: Vector3) -> void:
|
||||
return
|
||||
|
||||
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
|
||||
_play_animation("Run")
|
||||
return
|
||||
@@ -54,9 +68,57 @@ func _play_animation(anim_name: String):
|
||||
animation_player.play(anim_name)
|
||||
# Silently ignore if animation doesn't exist
|
||||
|
||||
func play_attack() -> void:
|
||||
func play_attack(anim_name: String = "Attack_OneHand") -> void:
|
||||
if animation_player:
|
||||
# Play attack animation once (don't loop)
|
||||
animation_player.play("Attack_OneHand", -1, 1.0)
|
||||
# Ensure it doesn't loop
|
||||
animation_player.animation_set_next("Attack_OneHand", "")
|
||||
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.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
|
||||
|
||||
@@ -13,10 +13,6 @@ var player_info = {
|
||||
signal player_connected(peer_id, player_info)
|
||||
signal server_disconnected
|
||||
|
||||
func _process(_delta):
|
||||
if Input.is_action_just_pressed("quit"):
|
||||
get_tree().quit(0)
|
||||
|
||||
func _ready() -> void:
|
||||
multiplayer.server_disconnected.connect(_on_connection_failed)
|
||||
multiplayer.connection_failed.connect(_on_server_disconnected)
|
||||
|
||||
+223
-69
@@ -42,7 +42,13 @@ var is_blocking: bool = false
|
||||
@export var attack_damage: float = 10.0
|
||||
@export var attack_range: float = 3.0
|
||||
@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 _unarmed_hitbox: HitBox = null
|
||||
var _is_unarmed_attacking: bool = false # Prevents overlapping unarmed attacks
|
||||
|
||||
# Dash system
|
||||
@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 _dash_direction: Vector3 = Vector3.ZERO
|
||||
|
||||
# Facing lock: while > 0 the body cannot turn (attacks aim at camera, dashes stay straight)
|
||||
var _facing_locked_timer: float = 0.0
|
||||
|
||||
# UI Signals
|
||||
signal dash_cooldown_updated(remaining: float, total: float)
|
||||
signal attack_cooldown_updated(remaining: float, total: float)
|
||||
@@ -63,8 +72,19 @@ func _enter_tree():
|
||||
$SpringArmOffset/SpringArm3D/Camera3D.current = is_multiplayer_authority()
|
||||
|
||||
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()
|
||||
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
|
||||
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)
|
||||
if _body == null:
|
||||
for child in get_children():
|
||||
if child.name == "Armature" or child.name == "3DGodotRobot":
|
||||
_body = child
|
||||
print("Auto-found _body: ", child.name)
|
||||
break
|
||||
if _body == null:
|
||||
push_error("Could not find body node (Armature or 3DGodotRobot)!")
|
||||
# Try specific paths first
|
||||
if has_node("LilguyRigged/Armature"):
|
||||
_body = get_node("LilguyRigged/Armature")
|
||||
print("Auto-found _body: LilguyRigged/Armature")
|
||||
elif has_node("Armature"):
|
||||
_body = get_node("Armature")
|
||||
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
|
||||
if _spring_arm_offset == null:
|
||||
@@ -144,6 +169,9 @@ func _ready():
|
||||
# Setup weapon pickup detection 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
|
||||
if _weapon_attachment == null:
|
||||
var bone_attach = get_node_or_null("3DGodotRobot/RobotArmature/Skeleton3D/BoneAttachment3D")
|
||||
@@ -189,19 +217,19 @@ func _physics_process(delta):
|
||||
freeze()
|
||||
return
|
||||
|
||||
# Apply gravity when not on floor
|
||||
if not is_on_floor():
|
||||
velocity.y -= gravity * delta
|
||||
_body.animate(velocity)
|
||||
|
||||
if is_on_floor():
|
||||
if Input.is_action_just_pressed("jump"):
|
||||
velocity.y = JUMP_VELOCITY
|
||||
else:
|
||||
velocity.y -= gravity * delta
|
||||
# Handle jump
|
||||
if is_on_floor() and Input.is_action_just_pressed("jump"):
|
||||
velocity.y = JUMP_VELOCITY
|
||||
|
||||
_move()
|
||||
move_and_slide()
|
||||
_body.animate(velocity)
|
||||
|
||||
if _body:
|
||||
_body.animate(velocity)
|
||||
|
||||
func _process(delta):
|
||||
# Check if multiplayer is ready
|
||||
@@ -218,6 +246,10 @@ func _process(delta):
|
||||
_attack_timer -= delta
|
||||
attack_cooldown_updated.emit(_attack_timer, attack_cooldown)
|
||||
|
||||
# Update facing lock (attack/dash direction commitment)
|
||||
if _facing_locked_timer > 0:
|
||||
_facing_locked_timer -= delta
|
||||
|
||||
# Update dash timers
|
||||
if _dash_timer > 0:
|
||||
_dash_timer -= delta
|
||||
@@ -269,14 +301,14 @@ func freeze():
|
||||
velocity.x = 0
|
||||
velocity.z = 0
|
||||
_current_speed = 0
|
||||
_body.animate(Vector3.ZERO)
|
||||
if _body:
|
||||
_body.animate(Vector3.ZERO)
|
||||
|
||||
func _move() -> void:
|
||||
# If dashing, use dash movement
|
||||
# If dashing, use dash movement (facing already snapped and locked at dash start)
|
||||
if _is_dashing:
|
||||
velocity.x = _dash_direction.x * _current_speed * dash_speed_multiplier
|
||||
velocity.z = _dash_direction.z * _current_speed * dash_speed_multiplier
|
||||
_body.apply_rotation(velocity)
|
||||
return
|
||||
|
||||
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()
|
||||
|
||||
is_running()
|
||||
_direction = _direction.rotated(Vector3.UP, _spring_arm_offset.rotation.y)
|
||||
if _spring_arm_offset:
|
||||
_direction = _direction.rotated(Vector3.UP, _spring_arm_offset.rotation.y)
|
||||
|
||||
if _direction:
|
||||
velocity.x = _direction.x * _current_speed
|
||||
velocity.z = _direction.z * _current_speed
|
||||
_body.apply_rotation(velocity)
|
||||
# Body only turns toward movement while facing isn't locked by an attack/dash
|
||||
if _body and _facing_locked_timer <= 0:
|
||||
_body.apply_rotation(velocity)
|
||||
return
|
||||
|
||||
velocity.x = move_toward(velocity.x, 0, _current_speed)
|
||||
velocity.z = move_toward(velocity.z, 0, _current_speed)
|
||||
|
||||
## Horizontal direction the camera is looking (movement-space, matches _move math)
|
||||
func _get_camera_forward() -> Vector3:
|
||||
var fwd: Vector3 = transform.basis * Vector3(0, 0, -1)
|
||||
if _spring_arm_offset:
|
||||
fwd = fwd.rotated(Vector3.UP, _spring_arm_offset.rotation.y)
|
||||
fwd.y = 0
|
||||
return fwd.normalized()
|
||||
|
||||
## Snap the body to face a direction and hold it there for `duration` seconds
|
||||
func _lock_facing(direction: Vector3, duration: float):
|
||||
_facing_locked_timer = max(_facing_locked_timer, duration)
|
||||
if _body and direction.length_squared() > 0.001:
|
||||
if _body.has_method("snap_rotation"):
|
||||
_body.snap_rotation(direction)
|
||||
else:
|
||||
_body.apply_rotation(direction)
|
||||
|
||||
## Attacks commit to where the camera looks, not where the character was walking
|
||||
func _lock_facing_to_camera(duration: float):
|
||||
_lock_facing(_get_camera_forward(), duration)
|
||||
|
||||
func is_running() -> bool:
|
||||
if Input.is_action_pressed("shift"):
|
||||
_current_speed = SPRINT_SPEED
|
||||
@@ -328,12 +384,27 @@ func get_texture_from_name(skin_color: SkinColor) -> CompressedTexture2D:
|
||||
|
||||
@rpc("any_peer", "reliable")
|
||||
func set_player_skin(skin_name: SkinColor) -> void:
|
||||
var texture = get_texture_from_name(skin_name)
|
||||
# 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)
|
||||
set_mesh_texture(_bottom_mesh, texture)
|
||||
set_mesh_texture(_chest_mesh, texture)
|
||||
set_mesh_texture(_face_mesh, texture)
|
||||
set_mesh_texture(_limbs_head_mesh, texture)
|
||||
|
||||
set_mesh_texture(_bottom_mesh, texture)
|
||||
set_mesh_texture(_chest_mesh, texture)
|
||||
set_mesh_texture(_face_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:
|
||||
if mesh_instance:
|
||||
@@ -382,51 +453,42 @@ func _perform_attack():
|
||||
|
||||
# Use main hand weapon if available
|
||||
if equipped_weapon and equipped_weapon.can_attack():
|
||||
# Aim the swing at the camera direction and commit to it
|
||||
_lock_facing_to_camera(equipped_weapon.weapon_data.startup_time + equipped_weapon.weapon_data.active_time)
|
||||
equipped_weapon.perform_attack()
|
||||
return
|
||||
|
||||
# Or use off-hand weapon if available
|
||||
if equipped_offhand and equipped_offhand.can_attack():
|
||||
_lock_facing_to_camera(equipped_offhand.weapon_data.startup_time + equipped_offhand.weapon_data.active_time)
|
||||
equipped_offhand.perform_attack()
|
||||
return
|
||||
|
||||
# Fallback to default unarmed attack
|
||||
# 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
|
||||
|
||||
if _attack_timer > 0:
|
||||
if _attack_timer > 0 or _is_unarmed_attacking:
|
||||
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
|
||||
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
|
||||
var space_state = get_world_3d().direct_space_state
|
||||
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
|
||||
# Activate unarmed hitbox for damage detection
|
||||
_activate_unarmed_hitbox()
|
||||
|
||||
## Server-side damage application
|
||||
@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():
|
||||
return
|
||||
|
||||
# Get the target from the players container
|
||||
var level = get_tree().get_current_scene()
|
||||
if not level or not level.has_node("PlayersContainer"):
|
||||
if not level:
|
||||
return
|
||||
|
||||
var players_container = level.get_node("PlayersContainer")
|
||||
if not players_container.has_node(target_name):
|
||||
return
|
||||
var target = null
|
||||
|
||||
var target = players_container.get_node(target_name)
|
||||
# Check players container first
|
||||
if level.has_node("PlayersContainer"):
|
||||
var players_container = level.get_node("PlayersContainer")
|
||||
if players_container.has_node(target_name):
|
||||
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:
|
||||
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()
|
||||
|
||||
func _on_died(killer_id: int):
|
||||
# Disable player when dead
|
||||
set_physics_process(false)
|
||||
set_process(false)
|
||||
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_process(false)
|
||||
|
||||
# Visual feedback - could add death animation here
|
||||
# Visual feedback - runs on all peers
|
||||
if _body:
|
||||
_body.visible = false
|
||||
|
||||
@@ -480,21 +553,22 @@ func _on_died(killer_id: int):
|
||||
|
||||
# Show death message on UI
|
||||
if has_node("HealthUI/HealthText"):
|
||||
get_node("HealthUI/HealthText").text = "DEAD - Respawning..."
|
||||
get_node("HealthUI/HealthText").text = "DEAD - Run Over"
|
||||
|
||||
func _on_respawned():
|
||||
# Re-enable player
|
||||
set_physics_process(true)
|
||||
set_process(true)
|
||||
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_process(true)
|
||||
print("[Player ", name, "] Re-enabled physics processing")
|
||||
|
||||
# Visual feedback - runs on all peers
|
||||
if _body:
|
||||
_body.visible = true
|
||||
|
||||
_update_health_display()
|
||||
|
||||
if is_multiplayer_authority():
|
||||
print("You respawned!")
|
||||
|
||||
## Dash system
|
||||
func _perform_dash():
|
||||
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_cooldown_timer = dash_cooldown
|
||||
|
||||
# Commit to the dash direction - no steering or turning mid-dash
|
||||
_lock_facing(_dash_direction, dash_duration)
|
||||
|
||||
# Animation is handled by the Body's animate function (Jump animation plays during dash)
|
||||
|
||||
## Sync attack animation to all clients
|
||||
@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
|
||||
func _play_hurt_animation():
|
||||
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_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):
|
||||
# Check if the area belongs to a WorldWeapon
|
||||
var weapon = area.get_parent()
|
||||
@@ -609,6 +754,15 @@ func equip_weapon(data: WeaponData):
|
||||
# Determine which hand based on weapon type
|
||||
var is_offhand = (data.hand_type == WeaponData.Hand.OFF_HAND)
|
||||
|
||||
# Two-handed weapons occupy both hands - clear the off-hand
|
||||
if data.hand_type == WeaponData.Hand.TWO_HAND and equipped_offhand:
|
||||
unequip_weapon(true)
|
||||
|
||||
# And symmetrically: an off-hand displaces a two-handed main weapon
|
||||
if is_offhand and equipped_weapon and equipped_weapon.weapon_data \
|
||||
and equipped_weapon.weapon_data.hand_type == WeaponData.Hand.TWO_HAND:
|
||||
unequip_weapon(false)
|
||||
|
||||
# Unequip current weapon in that hand first
|
||||
if is_offhand:
|
||||
if equipped_offhand:
|
||||
|
||||
@@ -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!")
|
||||
@@ -0,0 +1 @@
|
||||
uid://practice_dummy_script
|
||||
@@ -11,6 +11,12 @@ enum Hand { MAIN_HAND, OFF_HAND, TWO_HAND }
|
||||
@export_multiline var description: String = ""
|
||||
@export var hand_type: Hand = Hand.MAIN_HAND
|
||||
|
||||
@export_category("Shop / Economy")
|
||||
## Cost to buy this weapon in the character-creation shop.
|
||||
@export var cost: int = 100
|
||||
## Rough power tier (1 = starter, higher = stronger). Used for shop sorting/grouping.
|
||||
@export var tier: int = 1
|
||||
|
||||
@export_category("Combat Stats")
|
||||
@export var damage: float = 10.0
|
||||
@export var attack_range: float = 3.0
|
||||
@@ -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 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 var can_block: bool = false
|
||||
@export_range(0.0, 1.0) var block_reduction: float = 0.5 # Percentage of damage blocked (0.5 = 50%)
|
||||
|
||||
@@ -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
|
||||
@@ -11,15 +11,40 @@ class_name CharacterSheet
|
||||
# Player reference
|
||||
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
|
||||
var is_visible: bool = false
|
||||
|
||||
func _ready():
|
||||
# Start hidden
|
||||
hide()
|
||||
is_visible = false
|
||||
if preview_mode:
|
||||
_configure_preview_layout()
|
||||
show()
|
||||
is_visible = true
|
||||
refresh_all()
|
||||
else:
|
||||
# Start hidden (in-game Tab toggle behavior)
|
||||
hide()
|
||||
is_visible = false
|
||||
|
||||
func _input(event):
|
||||
# Embedded preview never toggles - it's part of the outfitting screen
|
||||
if preview_mode:
|
||||
return
|
||||
|
||||
# Close on Escape press if open
|
||||
if event.is_action_pressed("quit") and is_visible:
|
||||
toggle_sheet()
|
||||
@@ -37,6 +62,35 @@ func set_player(p: Character):
|
||||
if player:
|
||||
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
|
||||
func toggle_sheet():
|
||||
is_visible = !is_visible
|
||||
@@ -53,7 +107,7 @@ func toggle_sheet():
|
||||
|
||||
## Refresh all data in the sheet
|
||||
func refresh_all():
|
||||
if not player:
|
||||
if not player and not preview_mode:
|
||||
return
|
||||
|
||||
_refresh_stats()
|
||||
@@ -81,11 +135,40 @@ func _refresh_stats():
|
||||
spacer1.custom_minimum_size = Vector2(0, 10)
|
||||
stats_container.add_child(spacer1)
|
||||
|
||||
# Get player name
|
||||
var player_id = player.name.to_int()
|
||||
var player_name = "Player"
|
||||
if Network.players.has(player_id):
|
||||
player_name = Network.players[player_id]["nick"]
|
||||
# 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()
|
||||
player_name = "Player"
|
||||
if Network.players.has(player_id):
|
||||
player_name = Network.players[player_id]["nick"]
|
||||
current_hp = int(player.current_health)
|
||||
max_hp = int(player.max_health)
|
||||
atk_damage = player.attack_damage
|
||||
atk_range = player.attack_range
|
||||
atk_cooldown = player.attack_cooldown
|
||||
|
||||
_add_stat_label("Name: " + player_name)
|
||||
_add_stat_label("Level: 1") # Hardcoded for now
|
||||
@@ -93,23 +176,35 @@ func _refresh_stats():
|
||||
|
||||
# Health stats
|
||||
_add_stat_label("=== HEALTH ===", Color(0.8, 0.8, 0.8))
|
||||
_add_stat_label("Current HP: " + str(int(player.current_health)))
|
||||
_add_stat_label("Max HP: " + str(int(player.max_health)))
|
||||
_add_stat_label("Health: " + str(int(player.get_health_percent() * 100)) + "%")
|
||||
_add_stat_label("Current HP: " + str(current_hp))
|
||||
_add_stat_label("Max HP: " + str(max_hp))
|
||||
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("")
|
||||
|
||||
# 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("Walk Speed: " + str(player.NORMAL_SPEED))
|
||||
_add_stat_label("Sprint Speed: " + str(player.SPRINT_SPEED))
|
||||
_add_stat_label("Jump Power: " + str(player.JUMP_VELOCITY))
|
||||
_add_stat_label("Walk Speed: " + str(Character.NORMAL_SPEED))
|
||||
_add_stat_label("Sprint Speed: " + str(Character.SPRINT_SPEED))
|
||||
_add_stat_label("Jump Power: " + str(Character.JUMP_VELOCITY))
|
||||
_add_stat_label("")
|
||||
|
||||
# Combat stats
|
||||
_add_stat_label("=== COMBAT ===", Color(0.8, 0.8, 0.8))
|
||||
_add_stat_label("Base Damage: " + str(player.attack_damage))
|
||||
_add_stat_label("Attack Range: " + str(player.attack_range))
|
||||
_add_stat_label("Attack Cooldown: " + str(player.attack_cooldown) + "s")
|
||||
if preview_mode and preview_data.get("main") is WeaponData:
|
||||
_add_stat_label("Weapon Damage: " + str(atk_damage))
|
||||
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
|
||||
func _refresh_weapons():
|
||||
@@ -143,12 +238,26 @@ func _refresh_weapons():
|
||||
push_error("[CharacterSheet] Failed to load weapon_slot.tscn")
|
||||
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
|
||||
var main_hand_slot = weapon_slot_scene.instantiate()
|
||||
slots_container.add_child(main_hand_slot)
|
||||
|
||||
if player.equipped_weapon and player.equipped_weapon.weapon_data:
|
||||
main_hand_slot.set_weapon(player.equipped_weapon.weapon_data, "Main Hand")
|
||||
if main_data:
|
||||
main_hand_slot.set_weapon(main_data, "Main Hand")
|
||||
else:
|
||||
main_hand_slot.clear_weapon()
|
||||
|
||||
@@ -156,8 +265,8 @@ func _refresh_weapons():
|
||||
var off_hand_slot = weapon_slot_scene.instantiate()
|
||||
slots_container.add_child(off_hand_slot)
|
||||
|
||||
if player.equipped_offhand and player.equipped_offhand.weapon_data:
|
||||
off_hand_slot.set_weapon(player.equipped_offhand.weapon_data, "Off-Hand")
|
||||
if offhand_data:
|
||||
off_hand_slot.set_weapon(offhand_data, "Off-Hand")
|
||||
else:
|
||||
off_hand_slot.clear_weapon()
|
||||
|
||||
@@ -182,17 +291,24 @@ func _refresh_abilities():
|
||||
spacer.custom_minimum_size = Vector2(0, 10)
|
||||
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("Cooldown: " + str(player.dash_cooldown) + "s")
|
||||
_add_stat_label("Duration: " + str(player.dash_duration) + "s")
|
||||
_add_stat_label("Speed: " + str(player.dash_speed_multiplier) + "x")
|
||||
_add_stat_label("Cooldown: " + str(dash_cd) + "s")
|
||||
_add_stat_label("Duration: " + str(dash_dur) + "s")
|
||||
_add_stat_label("Speed: " + str(dash_mult) + "x")
|
||||
_add_stat_label("Description: Dash in movement direction")
|
||||
var dash_remaining = player._dash_cooldown_timer
|
||||
if dash_remaining > 0:
|
||||
_add_stat_label("Ready in: " + str(ceil(dash_remaining)) + "s", Color(1.0, 0.5, 0.5))
|
||||
else:
|
||||
if preview_mode:
|
||||
_add_stat_label("Status: READY", Color(0.0, 1.0, 0.0))
|
||||
else:
|
||||
var dash_remaining = player._dash_cooldown_timer
|
||||
if dash_remaining > 0:
|
||||
_add_stat_label("Ready in: " + str(ceil(dash_remaining)) + "s", Color(1.0, 0.5, 0.5))
|
||||
else:
|
||||
_add_stat_label("Status: READY", Color(0.0, 1.0, 0.0))
|
||||
|
||||
# Spacer
|
||||
var spacer2 = Control.new()
|
||||
@@ -201,7 +317,7 @@ func _refresh_abilities():
|
||||
|
||||
# Jump ability
|
||||
_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("Status: ALWAYS READY", Color(0.0, 1.0, 0.0))
|
||||
|
||||
|
||||
@@ -9,11 +9,27 @@ var is_visible: bool = false
|
||||
# Player reference (to control mouse mode)
|
||||
var player: Character = null
|
||||
|
||||
var _retire_button: Button = null
|
||||
|
||||
func _ready():
|
||||
# Start hidden
|
||||
hide()
|
||||
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):
|
||||
# Toggle on Escape press
|
||||
if event.is_action_pressed("quit"):
|
||||
@@ -29,6 +45,7 @@ func toggle_menu():
|
||||
is_visible = !is_visible
|
||||
if is_visible:
|
||||
show()
|
||||
_update_retire_button()
|
||||
# Release mouse when opening menu
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
else:
|
||||
@@ -37,10 +54,40 @@ func toggle_menu():
|
||||
if player and player.is_multiplayer_authority():
|
||||
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
|
||||
func _on_resume_pressed():
|
||||
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
|
||||
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()
|
||||
|
||||
@@ -8,7 +8,12 @@ var unit_frame: Control = null
|
||||
var target_frame: Control = null
|
||||
var character_sheet: Control = null
|
||||
var tab_hint: Control = null
|
||||
var keybind_hint: 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
|
||||
var local_player: Character = null
|
||||
@@ -77,7 +82,9 @@ func _create_ui_components():
|
||||
_create_unit_frame()
|
||||
_create_character_sheet()
|
||||
_create_tab_hint()
|
||||
_create_keybind_hint()
|
||||
_create_escape_menu()
|
||||
_create_arena_status()
|
||||
|
||||
## Create action bar at bottom of screen
|
||||
func _create_action_bar():
|
||||
@@ -125,6 +132,16 @@ func _create_tab_hint():
|
||||
else:
|
||||
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)
|
||||
func _create_escape_menu():
|
||||
var escape_menu_scene = load("res://level/ui/scenes/escape_menu.tscn")
|
||||
@@ -137,6 +154,57 @@ func _create_escape_menu():
|
||||
else:
|
||||
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
|
||||
func show_hud():
|
||||
show()
|
||||
@@ -145,6 +213,36 @@ func show_hud():
|
||||
func hide_hud():
|
||||
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
|
||||
func _on_player_health_changed(old_health: float, new_health: float):
|
||||
# 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)
|
||||
|
||||
func _on_player_died(killer_id: int):
|
||||
print("[HUD] Player died")
|
||||
# Could show death screen or respawn timer here
|
||||
print("[HUD] Player died - ending run")
|
||||
_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():
|
||||
print("[HUD] Player respawned")
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
extends PanelContainer
|
||||
## Always-on compact panel listing the current controls.
|
||||
## Reads bindings straight from the InputMap so it stays accurate if keys are rebound.
|
||||
|
||||
# Each row is a friendly label plus the input action(s) that feed it.
|
||||
# Grouped actions (like movement) show their keys joined together.
|
||||
const ROWS := [
|
||||
{ "label": "Move", "actions": ["move_forward", "move_left", "move_backward", "move_right"] },
|
||||
{ "label": "Jump", "actions": ["jump"] },
|
||||
{ "label": "Sprint", "actions": ["shift"] },
|
||||
{ "label": "Dash", "actions": ["dash"] },
|
||||
{ "label": "Attack", "actions": ["attack"] },
|
||||
{ "label": "Block", "actions": ["block"] },
|
||||
{ "label": "Pick Up / Drop", "actions": ["pickup"] },
|
||||
{ "label": "Character Sheet", "actions": ["toggle_character_sheet"] },
|
||||
{ "label": "Chat", "actions": ["toggle_chat"] },
|
||||
{ "label": "Menu", "actions": ["quit"] },
|
||||
]
|
||||
|
||||
func _ready() -> void:
|
||||
_build()
|
||||
|
||||
func _build() -> void:
|
||||
var content: VBoxContainer = $Margin/Content
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "CONTROLS"
|
||||
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
title.add_theme_font_size_override("font_size", 16)
|
||||
title.add_theme_color_override("font_color", Color(1, 0.8, 0, 1))
|
||||
title.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||
title.add_theme_constant_override("outline_size", 2)
|
||||
content.add_child(title)
|
||||
|
||||
var grid := GridContainer.new()
|
||||
grid.columns = 2
|
||||
grid.add_theme_constant_override("h_separation", 12)
|
||||
grid.add_theme_constant_override("v_separation", 3)
|
||||
content.add_child(grid)
|
||||
|
||||
for row in ROWS:
|
||||
var keys: Array[String] = []
|
||||
for action in row["actions"]:
|
||||
var k := _key_text(action)
|
||||
if k != "":
|
||||
keys.append(k)
|
||||
if keys.is_empty():
|
||||
continue
|
||||
grid.add_child(_make_label(" ".join(keys), Color(1, 0.82, 0.2, 1)))
|
||||
grid.add_child(_make_label(row["label"], Color.WHITE))
|
||||
|
||||
func _make_label(text: String, color: Color) -> Label:
|
||||
var l := Label.new()
|
||||
l.text = text
|
||||
l.add_theme_font_size_override("font_size", 13)
|
||||
l.add_theme_color_override("font_color", color)
|
||||
l.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||
l.add_theme_constant_override("outline_size", 1)
|
||||
return l
|
||||
|
||||
## Returns a human-readable name for the first event bound to an action.
|
||||
func _key_text(action: String) -> String:
|
||||
if not InputMap.has_action(action):
|
||||
return ""
|
||||
for ev in InputMap.action_get_events(action):
|
||||
if ev is InputEventKey:
|
||||
var code: int = ev.physical_keycode if ev.physical_keycode != 0 else ev.keycode
|
||||
return OS.get_keycode_string(code)
|
||||
elif ev is InputEventMouseButton:
|
||||
match ev.button_index:
|
||||
MOUSE_BUTTON_LEFT: return "LMB"
|
||||
MOUSE_BUTTON_RIGHT: return "RMB"
|
||||
MOUSE_BUTTON_MIDDLE: return "MMB"
|
||||
_: return "Mouse %d" % ev.button_index
|
||||
return ""
|
||||
@@ -0,0 +1 @@
|
||||
uid://dh85b10pfih5e
|
||||
@@ -0,0 +1,248 @@
|
||||
extends Control
|
||||
class_name OutfittingScreen
|
||||
## Gladiator outfitting screen shown on the main menu.
|
||||
## Left: the armory shop (buy and equip gear with banked gold).
|
||||
## Right: the character sheet in preview mode, live-updating as you change loadout.
|
||||
## Reads/writes GameState (banked_gold, owned_items, selected_loadout).
|
||||
|
||||
const WEAPONS_DIR := "res://level/resources/"
|
||||
|
||||
# Catalog of WeaponData found on disk, sorted by tier then cost
|
||||
var _catalog: Array[WeaponData] = []
|
||||
var _catalog_paths: Dictionary = {} # WeaponData -> resource path
|
||||
|
||||
var _gold_label: Label = null
|
||||
var _items_container: VBoxContainer = null
|
||||
var _sheet: Control = null # CharacterSheet in preview mode
|
||||
var _nick_input: LineEdit = null # menu's nickname field (for preview name)
|
||||
|
||||
func _ready():
|
||||
_load_catalog()
|
||||
_build_ui()
|
||||
|
||||
GameState.gold_changed.connect(func(_g): _refresh())
|
||||
GameState.loadout_changed.connect(func(_l): _refresh())
|
||||
|
||||
_refresh()
|
||||
|
||||
## The menu's nickname LineEdit, so the preview shows the entered name
|
||||
func set_nick_input(nick_input: LineEdit):
|
||||
_nick_input = nick_input
|
||||
if _nick_input:
|
||||
_nick_input.text_changed.connect(func(_t): _update_preview())
|
||||
|
||||
# --- Catalog --------------------------------------------------------------
|
||||
|
||||
func _load_catalog():
|
||||
_catalog.clear()
|
||||
_catalog_paths.clear()
|
||||
|
||||
var dir = DirAccess.open(WEAPONS_DIR)
|
||||
if not dir:
|
||||
push_error("[Outfitting] Cannot open " + WEAPONS_DIR)
|
||||
return
|
||||
|
||||
for file in dir.get_files():
|
||||
# Exported builds list resources as .tres.remap
|
||||
var file_name = file.trim_suffix(".remap")
|
||||
if not file_name.ends_with(".tres"):
|
||||
continue
|
||||
var path = WEAPONS_DIR + file_name
|
||||
var res = load(path)
|
||||
if res is WeaponData:
|
||||
_catalog.append(res)
|
||||
_catalog_paths[res] = path
|
||||
|
||||
_catalog.sort_custom(func(a, b):
|
||||
if a.tier != b.tier:
|
||||
return a.tier < b.tier
|
||||
return a.cost < b.cost
|
||||
)
|
||||
print("[Outfitting] Loaded ", _catalog.size(), " shop items")
|
||||
|
||||
# --- UI construction --------------------------------------------------------
|
||||
|
||||
func _build_ui():
|
||||
var hbox = HBoxContainer.new()
|
||||
hbox.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
hbox.add_theme_constant_override("separation", 16)
|
||||
add_child(hbox)
|
||||
|
||||
# ----- Shop panel (left) -----
|
||||
var shop_panel = PanelContainer.new()
|
||||
shop_panel.custom_minimum_size = Vector2(420, 0)
|
||||
shop_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
shop_panel.size_flags_stretch_ratio = 0.85
|
||||
hbox.add_child(shop_panel)
|
||||
|
||||
var shop_margin = MarginContainer.new()
|
||||
shop_margin.add_theme_constant_override("margin_left", 16)
|
||||
shop_margin.add_theme_constant_override("margin_right", 16)
|
||||
shop_margin.add_theme_constant_override("margin_top", 12)
|
||||
shop_margin.add_theme_constant_override("margin_bottom", 12)
|
||||
shop_panel.add_child(shop_margin)
|
||||
|
||||
var shop_vbox = VBoxContainer.new()
|
||||
shop_vbox.add_theme_constant_override("separation", 8)
|
||||
shop_margin.add_child(shop_vbox)
|
||||
|
||||
var shop_title = Label.new()
|
||||
shop_title.text = "THE ARMORY"
|
||||
shop_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
shop_title.add_theme_font_size_override("font_size", 26)
|
||||
shop_title.add_theme_color_override("font_color", Color(1.0, 0.8, 0.0))
|
||||
shop_vbox.add_child(shop_title)
|
||||
|
||||
_gold_label = Label.new()
|
||||
_gold_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_gold_label.add_theme_font_size_override("font_size", 20)
|
||||
_gold_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.2))
|
||||
shop_vbox.add_child(_gold_label)
|
||||
|
||||
var hint = Label.new()
|
||||
hint.text = "Buy gear, equip it, then enter the arena.\nDie and the arena takes 90% of everything you're worth."
|
||||
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
hint.add_theme_font_size_override("font_size", 13)
|
||||
hint.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
|
||||
shop_vbox.add_child(hint)
|
||||
|
||||
shop_vbox.add_child(HSeparator.new())
|
||||
|
||||
var scroll = ScrollContainer.new()
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
shop_vbox.add_child(scroll)
|
||||
|
||||
_items_container = VBoxContainer.new()
|
||||
_items_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_items_container.add_theme_constant_override("separation", 6)
|
||||
scroll.add_child(_items_container)
|
||||
|
||||
# ----- Character sheet preview (right) -----
|
||||
var sheet_scene = load("res://level/ui/scenes/character_sheet.tscn")
|
||||
if sheet_scene:
|
||||
_sheet = sheet_scene.instantiate()
|
||||
_sheet.preview_mode = true # Must be set before _ready runs
|
||||
_sheet.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_sheet.size_flags_stretch_ratio = 1.0
|
||||
_sheet.custom_minimum_size = Vector2(480, 0)
|
||||
hbox.add_child(_sheet)
|
||||
else:
|
||||
push_error("[Outfitting] Failed to load character_sheet.tscn")
|
||||
|
||||
# --- Refresh ---------------------------------------------------------------
|
||||
|
||||
func _refresh():
|
||||
if _gold_label:
|
||||
_gold_label.text = "Gold: " + str(GameState.banked_gold)
|
||||
_rebuild_items()
|
||||
_update_preview()
|
||||
|
||||
func _rebuild_items():
|
||||
if not _items_container:
|
||||
return
|
||||
for child in _items_container.get_children():
|
||||
child.queue_free()
|
||||
|
||||
for weapon in _catalog:
|
||||
_items_container.add_child(_make_item_row(weapon))
|
||||
|
||||
func _make_item_row(weapon: WeaponData) -> Control:
|
||||
var path: String = _catalog_paths[weapon]
|
||||
var owned := GameState.owns_item(path)
|
||||
var selected := GameState.is_loadout_item_selected(path)
|
||||
|
||||
var row = PanelContainer.new()
|
||||
var margin = MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 8)
|
||||
margin.add_theme_constant_override("margin_right", 8)
|
||||
margin.add_theme_constant_override("margin_top", 6)
|
||||
margin.add_theme_constant_override("margin_bottom", 6)
|
||||
row.add_child(margin)
|
||||
|
||||
var hbox = HBoxContainer.new()
|
||||
hbox.add_theme_constant_override("separation", 10)
|
||||
margin.add_child(hbox)
|
||||
|
||||
# Name + stat line
|
||||
var info = VBoxContainer.new()
|
||||
info.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
hbox.add_child(info)
|
||||
|
||||
var name_label = Label.new()
|
||||
name_label.text = weapon.weapon_name + (" [" + _hand_label(weapon) + "]")
|
||||
name_label.add_theme_font_size_override("font_size", 16)
|
||||
name_label.add_theme_color_override("font_color",
|
||||
Color(0.4, 1.0, 0.4) if selected else (Color(0.85, 0.85, 1.0) if owned else Color.WHITE))
|
||||
info.add_child(name_label)
|
||||
|
||||
var stats_label = Label.new()
|
||||
var stat_bits: Array[String] = ["DMG " + str(weapon.damage), "CD " + str(weapon.attack_cooldown) + "s"]
|
||||
if weapon.can_block:
|
||||
stat_bits.append("Block " + str(int(weapon.block_reduction * 100)) + "%")
|
||||
stats_label.text = " | ".join(stat_bits)
|
||||
stats_label.add_theme_font_size_override("font_size", 12)
|
||||
stats_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
|
||||
info.add_child(stats_label)
|
||||
|
||||
# Action button
|
||||
var button = Button.new()
|
||||
button.custom_minimum_size = Vector2(120, 36)
|
||||
if not owned:
|
||||
button.text = str(weapon.cost) + "g Buy"
|
||||
button.disabled = not GameState.can_afford(weapon.cost)
|
||||
button.pressed.connect(_on_buy_pressed.bind(path, weapon.cost))
|
||||
elif selected:
|
||||
button.text = "Equipped"
|
||||
button.pressed.connect(_on_unequip_pressed.bind(weapon))
|
||||
else:
|
||||
button.text = "Equip"
|
||||
button.pressed.connect(_on_equip_pressed.bind(path))
|
||||
hbox.add_child(button)
|
||||
|
||||
return row
|
||||
|
||||
func _hand_label(weapon: WeaponData) -> String:
|
||||
match weapon.hand_type:
|
||||
WeaponData.Hand.OFF_HAND: return "Off-Hand"
|
||||
WeaponData.Hand.TWO_HAND: return "Two-Hand"
|
||||
_: return "Main Hand"
|
||||
|
||||
# --- Actions ----------------------------------------------------------------
|
||||
|
||||
func _on_buy_pressed(path: String, cost: int):
|
||||
if GameState.buy_item(path, cost):
|
||||
# Auto-equip fresh purchases - you bought it to use it
|
||||
GameState.equip_loadout_item(path)
|
||||
# _refresh happens via gold_changed/loadout_changed signals
|
||||
|
||||
func _on_equip_pressed(path: String):
|
||||
GameState.equip_loadout_item(path)
|
||||
|
||||
func _on_unequip_pressed(weapon: WeaponData):
|
||||
var slot = "offhand" if weapon.hand_type == WeaponData.Hand.OFF_HAND else "main"
|
||||
GameState.unequip_loadout_slot(slot)
|
||||
|
||||
# --- Preview ----------------------------------------------------------------
|
||||
|
||||
func _update_preview():
|
||||
if not _sheet:
|
||||
return
|
||||
|
||||
var loadout = GameState.get_selected_loadout()
|
||||
var main_data = null
|
||||
var offhand_data = null
|
||||
if loadout.get("main", "") != "":
|
||||
main_data = load(loadout["main"])
|
||||
if loadout.get("offhand", "") != "":
|
||||
offhand_data = load(loadout["offhand"])
|
||||
|
||||
var nick = "Gladiator"
|
||||
if _nick_input and _nick_input.text.strip_edges() != "":
|
||||
nick = _nick_input.text.strip_edges()
|
||||
|
||||
_sheet.set_preview({
|
||||
"nick": nick,
|
||||
"main": main_data,
|
||||
"offhand": offhand_data,
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
uid://ngicph2qsf0m
|
||||
@@ -0,0 +1,162 @@
|
||||
extends Control
|
||||
class_name RunResults
|
||||
## End-of-run results screen for the gladiator arena.
|
||||
## Shown by the HUD when the local player dies. Displays survival stats and
|
||||
## the death settlement (keep 10% of total value, floored at the fresh-start
|
||||
## default), then returns the player to the menu/camp.
|
||||
|
||||
var stats: Dictionary = {}
|
||||
|
||||
func setup(run_stats: Dictionary):
|
||||
stats = run_stats
|
||||
|
||||
func _ready():
|
||||
# Full-screen dark backdrop
|
||||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
|
||||
var backdrop = ColorRect.new()
|
||||
backdrop.color = Color(0.0, 0.0, 0.0, 0.75)
|
||||
backdrop.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(backdrop)
|
||||
|
||||
# Centered panel
|
||||
var center = CenterContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(center)
|
||||
|
||||
var panel = PanelContainer.new()
|
||||
panel.custom_minimum_size = Vector2(460, 0)
|
||||
center.add_child(panel)
|
||||
|
||||
var margin = MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 30)
|
||||
margin.add_theme_constant_override("margin_right", 30)
|
||||
margin.add_theme_constant_override("margin_top", 24)
|
||||
margin.add_theme_constant_override("margin_bottom", 24)
|
||||
panel.add_child(margin)
|
||||
|
||||
var vbox = VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 8)
|
||||
margin.add_child(vbox)
|
||||
|
||||
# Title
|
||||
var title = Label.new()
|
||||
title.text = "YOU HAVE FALLEN"
|
||||
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
title.add_theme_font_size_override("font_size", 36)
|
||||
title.add_theme_color_override("font_color", Color(0.9, 0.2, 0.2))
|
||||
vbox.add_child(title)
|
||||
|
||||
var subtitle = Label.new()
|
||||
subtitle.text = "The arena claims another gladiator"
|
||||
subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
subtitle.add_theme_font_size_override("font_size", 16)
|
||||
subtitle.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
|
||||
vbox.add_child(subtitle)
|
||||
|
||||
_add_spacer(vbox, 12)
|
||||
|
||||
# Survival stats
|
||||
_add_header(vbox, "=== SURVIVAL ===")
|
||||
_add_stat_row(vbox, "Waves Survived", str(int(stats.get("waves", 0))))
|
||||
_add_stat_row(vbox, "Time in Arena", _format_time(stats.get("time", 0.0)))
|
||||
_add_stat_row(vbox, "Kills", str(int(stats.get("kills", 0))))
|
||||
|
||||
_add_spacer(vbox, 12)
|
||||
|
||||
# Settlement
|
||||
_add_header(vbox, "=== SETTLEMENT ===")
|
||||
_add_stat_row(vbox, "Gold Earned This Run", str(int(stats.get("gold_earned", 0))))
|
||||
_add_stat_row(vbox, "Total Value at Death", str(int(stats.get("total_value", 0))))
|
||||
|
||||
var kept = int(stats.get("kept", 0))
|
||||
var kept_row = Label.new()
|
||||
kept_row.text = "The arena takes its cut... you keep " + str(kept) + " gold"
|
||||
kept_row.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
kept_row.add_theme_font_size_override("font_size", 18)
|
||||
kept_row.add_theme_color_override("font_color", Color(1.0, 0.8, 0.0))
|
||||
vbox.add_child(kept_row)
|
||||
|
||||
_add_spacer(vbox, 16)
|
||||
|
||||
# Spectate option - only when a teammate is still fighting
|
||||
if _teammates_still_alive():
|
||||
var spectate_button = Button.new()
|
||||
spectate_button.text = "Spectate"
|
||||
spectate_button.custom_minimum_size = Vector2(0, 44)
|
||||
spectate_button.pressed.connect(_on_spectate_pressed)
|
||||
vbox.add_child(spectate_button)
|
||||
_add_spacer(vbox, 6)
|
||||
|
||||
# Return button
|
||||
var return_button = Button.new()
|
||||
return_button.text = "Return to Camp"
|
||||
return_button.custom_minimum_size = Vector2(0, 44)
|
||||
return_button.pressed.connect(_on_return_pressed)
|
||||
vbox.add_child(return_button)
|
||||
|
||||
# Make sure the mouse is usable on the overlay
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
|
||||
## Any other living player in the arena?
|
||||
func _teammates_still_alive() -> bool:
|
||||
if multiplayer.multiplayer_peer == null:
|
||||
return false
|
||||
var level = get_tree().get_current_scene()
|
||||
if not level or not level.has_node("PlayersContainer"):
|
||||
return false
|
||||
var my_id = multiplayer.get_unique_id()
|
||||
for p in level.get_node("PlayersContainer").get_children():
|
||||
if p is Character and not p.is_dead and str(p.name).to_int() != my_id:
|
||||
return true
|
||||
return false
|
||||
|
||||
## Close the overlay and watch the run play out (escape menu can leave later).
|
||||
## For the host this also keeps the server alive for everyone else.
|
||||
func _on_spectate_pressed():
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
queue_free()
|
||||
|
||||
func _add_header(parent: Control, text: String):
|
||||
var label = Label.new()
|
||||
label.text = text
|
||||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
label.add_theme_font_size_override("font_size", 16)
|
||||
label.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8))
|
||||
parent.add_child(label)
|
||||
|
||||
func _add_stat_row(parent: Control, label_text: String, value_text: String):
|
||||
var row = HBoxContainer.new()
|
||||
var label = Label.new()
|
||||
label.text = label_text
|
||||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
label.add_theme_font_size_override("font_size", 16)
|
||||
row.add_child(label)
|
||||
|
||||
var value = Label.new()
|
||||
value.text = value_text
|
||||
value.add_theme_font_size_override("font_size", 16)
|
||||
value.add_theme_color_override("font_color", Color(1.0, 0.8, 0.0))
|
||||
row.add_child(value)
|
||||
parent.add_child(row)
|
||||
|
||||
func _add_spacer(parent: Control, height: int):
|
||||
var spacer = Control.new()
|
||||
spacer.custom_minimum_size = Vector2(0, height)
|
||||
parent.add_child(spacer)
|
||||
|
||||
func _format_time(seconds: float) -> String:
|
||||
var total = int(seconds)
|
||||
return "%dm %02ds" % [total / 60, total % 60]
|
||||
|
||||
func _on_return_pressed():
|
||||
# Tear down the multiplayer session and go back to the menu.
|
||||
# (Host quitting ends the session for connected clients - acceptable for now.)
|
||||
multiplayer.multiplayer_peer = null
|
||||
Network.players.clear()
|
||||
|
||||
if has_node("/root/HUD"):
|
||||
get_node("/root/HUD").reset()
|
||||
|
||||
get_tree().reload_current_scene()
|
||||
@@ -0,0 +1 @@
|
||||
uid://i1s8oc7snqnm
|
||||
@@ -5,6 +5,8 @@ class_name WeaponSlot
|
||||
# References
|
||||
@onready var icon_rect: TextureRect = $SlotPanel/MarginContainer/IconRect
|
||||
var tooltip: PanelContainer = null
|
||||
# Fallback name display for weapons without an icon texture
|
||||
var _name_label: Label = null
|
||||
|
||||
# Weapon data
|
||||
var weapon_data: WeaponData = null
|
||||
@@ -53,9 +55,43 @@ func _update_display():
|
||||
if weapon_data and weapon_data.icon:
|
||||
icon_rect.texture = weapon_data.icon
|
||||
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:
|
||||
icon_rect.texture = null
|
||||
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
|
||||
func _on_mouse_entered():
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[gd_resource type="Theme" load_steps=2 format=3]
|
||||
[gd_resource type="Theme" load_steps=2 format=3 uid="uid://dvsh7tuhulnfm"]
|
||||
|
||||
[ext_resource type="FontFile" uid="uid://dh88edx2pf6ax" path="res://assets/fonts/Kurland.ttf" id="1_font"]
|
||||
[ext_resource type="FontFile" uid="uid://wipqjhfqeuwd" path="res://assets/fonts/Kurland.ttf" id="1_font"]
|
||||
|
||||
[resource]
|
||||
default_font = ExtResource("1_font")
|
||||
|
||||
+7
-2
@@ -20,12 +20,15 @@ config/icon="res://icon.png"
|
||||
[autoload]
|
||||
|
||||
Network="*res://level/scripts/network.gd"
|
||||
GameState="*res://level/scripts/game_state.gd"
|
||||
HUD="*res://level/ui/scripts/hud_manager.gd"
|
||||
|
||||
[display]
|
||||
|
||||
window/size/viewport_width=1280
|
||||
window/size/viewport_height=720
|
||||
window/size/viewport_width=1920
|
||||
window/size/viewport_height=1080
|
||||
window/stretch/mode="canvas_items"
|
||||
window/stretch/aspect="expand"
|
||||
|
||||
[input]
|
||||
|
||||
@@ -100,3 +103,5 @@ toggle_character_sheet={
|
||||
3d_physics/layer_1="player"
|
||||
3d_physics/layer_2="world"
|
||||
3d_physics/layer_3="weapon"
|
||||
3d_physics/layer_4="hitbox"
|
||||
3d_physics/layer_5="hurtbox"
|
||||
|
||||
Reference in New Issue
Block a user