Add engine flames, damage numbers, nameplates, music, and combat/collision fixes

Feature work (CLAUDE.md items 17-18):
- Race roster art rework (Apex/ISN/ORC) plus per-ship engine-flame sprites
  that swap in on thrust
- Floating damage-number VFX, per-ship nameplates, energy bar rework
- Background music manager and per-race ship SFX

Bugfixes/tuning from live testing:
- No friendly fire: bullets stop on a teammate's hull but deal no damage
  (bullet.gd), decided by race via PlayerRegistry
- Ships no longer physically block each other (own collision layer,
  ship.tscn/bullet.tscn) -- fixes ramming/parking trolling and the stuck-on-
  corpse bug after a kill
- Explosions ghost any ship's sprite passing through instead of blocking it
  (effects/explosion.gd's GhostArea)
- Fixed two "flushing physics queries" crashes from the above: explosion
  spawn and death/respawn collision toggling are now deferred
  (ship_movement.gd)
- Dead ships stop blocking bullets during their respawn delay
- Bots fly a mixed roster (1 Tank + 2 Gunner + rest Fighter per team)
  instead of always the Fighter (bot_manager.gd)
- Camera zoom knob, larger/longer-lived damage numbers and nameplate text,
  red (not yellow) enemy name color for better contrast

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 23:55:18 -04:00
parent 7b44b2f2dc
commit 2f6607af8c
67 changed files with 979 additions and 125 deletions
+2
View File
@@ -38,6 +38,8 @@ README for how to run/extend it.
16. **Real game-server pool (self-registration + heartbeat + stale sweep)**`NetworkManager.host_server()` now calls the new `MatchmakingClient.register_server()` once on boot and then every `HEARTBEAT_INTERVAL` (8s) via a `Timer`, reporting live `PlayerRegistry.players.size()`/`MAX_PLAYERS` — item 15's demo-seeded rows are no longer the only thing populating `GET /servers`; a real server registering on the same `(ip, port, mode)` as a demo row just takes it over in place, no special-casing needed. `--server-ip=` is a new cmdline arg for the IP a hosted server advertises (defaults to loopback for local dev — see the reachability note already in `matchmaking-api/README.md` about `DEV_SEED_SERVER_IP`, same constraint applies here). API-side, `POST /servers/register` now computes `status` from `player_count`/`max_players` (`full` vs `available`) instead of always writing `available`, and a new background loop (`sweep_stale_servers` in `app/routers/servers.py`, run every 5s from `main.py`'s lifespan) marks any server `offline` once its `last_heartbeat` exceeds `server_stale_seconds` (20s) — catches a crashed/killed server that never got to deregister cleanly, so a dead server doesn't sit in the list looking joinable forever. `menu/server_select.gd`'s connect button now also blocks (with a message) on `status == "full"`, not just `"offline"`. 16. **Real game-server pool (self-registration + heartbeat + stale sweep)**`NetworkManager.host_server()` now calls the new `MatchmakingClient.register_server()` once on boot and then every `HEARTBEAT_INTERVAL` (8s) via a `Timer`, reporting live `PlayerRegistry.players.size()`/`MAX_PLAYERS` — item 15's demo-seeded rows are no longer the only thing populating `GET /servers`; a real server registering on the same `(ip, port, mode)` as a demo row just takes it over in place, no special-casing needed. `--server-ip=` is a new cmdline arg for the IP a hosted server advertises (defaults to loopback for local dev — see the reachability note already in `matchmaking-api/README.md` about `DEV_SEED_SERVER_IP`, same constraint applies here). API-side, `POST /servers/register` now computes `status` from `player_count`/`max_players` (`full` vs `available`) instead of always writing `available`, and a new background loop (`sweep_stale_servers` in `app/routers/servers.py`, run every 5s from `main.py`'s lifespan) marks any server `offline` once its `last_heartbeat` exceeds `server_stale_seconds` (20s) — catches a crashed/killed server that never got to deregister cleanly, so a dead server doesn't sit in the list looking joinable forever. `menu/server_select.gd`'s connect button now also blocks (with a message) on `status == "full"`, not just `"offline"`.
17. **Race roster replaced (Terran/Mechanos/Vorg → Apex Dynamics/Inner Sphere Navy/Outer Rim Collective)**`team_select.gd`'s `RACES` now points at the 3 factions from `overview/racesclasses.md`'s pivot, each with a 3-ship Fighter/Gunner/Tank roster (Lancet/Pulsar/Sovereign, Patriot/Barrage/Behemoth, Rail-Jack/Scrap-Spitter/Iron-Clad) instead of the old 5-ship Interceptor/Gunship/Bomber/Support/Heavy set; `SPEED_BY_ROLE` shrunk to match. Art was extracted from 3 user-provided concept sheets (originally dropped at `assets/images/ship/`, now cropped per-ship into `assets/images/ships/apex|isn|orc/` with a `source/` copy of each sheet, same convention as the old race folders) — background removed via flood-fill + largest-connected-component matting (plain background for Apex, starfield for ORC, grid-lined UI panels for ISN, each needing different thresholding). ISN and ORC's source art was drawn nose-*sideways*; both were rotated 90° before saving since this project's ship rotation convention is nose-up at `rotation = 0` (`Vector2.UP.rotated(rotation)` in `ship_movement.gd`) — Apex's source art was already nose-up. `scale` values were computed with the same per-role target-on-screen-height normalization the old roster used (Fighter/Gunner/Tank targets reuse the old Interceptor/Gunship/Heavy heights, ~52.5/58.5/82.5px) so ship sizes read consistently across factions. Verified by hosting a real server and screenshotting bot fill flying with the new sprites — no load errors, transparency and nose-up orientation both correct. The old `terran/mech/vorg` asset folders are left on disk but unreferenced (no code points at them); not deleted since that wasn't asked for. 17. **Race roster replaced (Terran/Mechanos/Vorg → Apex Dynamics/Inner Sphere Navy/Outer Rim Collective)**`team_select.gd`'s `RACES` now points at the 3 factions from `overview/racesclasses.md`'s pivot, each with a 3-ship Fighter/Gunner/Tank roster (Lancet/Pulsar/Sovereign, Patriot/Barrage/Behemoth, Rail-Jack/Scrap-Spitter/Iron-Clad) instead of the old 5-ship Interceptor/Gunship/Bomber/Support/Heavy set; `SPEED_BY_ROLE` shrunk to match. Art was extracted from 3 user-provided concept sheets (originally dropped at `assets/images/ship/`, now cropped per-ship into `assets/images/ships/apex|isn|orc/` with a `source/` copy of each sheet, same convention as the old race folders) — background removed via flood-fill + largest-connected-component matting (plain background for Apex, starfield for ORC, grid-lined UI panels for ISN, each needing different thresholding). ISN and ORC's source art was drawn nose-*sideways*; both were rotated 90° before saving since this project's ship rotation convention is nose-up at `rotation = 0` (`Vector2.UP.rotated(rotation)` in `ship_movement.gd`) — Apex's source art was already nose-up. `scale` values were computed with the same per-role target-on-screen-height normalization the old roster used (Fighter/Gunner/Tank targets reuse the old Interceptor/Gunship/Heavy heights, ~52.5/58.5/82.5px) so ship sizes read consistently across factions. Verified by hosting a real server and screenshotting bot fill flying with the new sprites — no load errors, transparency and nose-up orientation both correct. The old `terran/mech/vorg` asset folders are left on disk but unreferenced (no code points at them); not deleted since that wasn't asked for.
18. **Engine-flame sprite swap on thrust** — 6 of item 17's 9 ships (ISN's Patriot/Barrage/Behemoth, ORC's Rail-Jack/Scrap-Spitter/Iron-Clad) gained a second `_flame.png` sprite alongside their idle one, cropped from the same concept sheets' flame-frame columns (ISN's sheet has a clean idle/flame pair per ship; ORC's only unambiguous case was Rail-Jack's brighter 3rd-column flame, so Scrap-Spitter/Iron-Clad's flame PNGs are just copies of their idle sprite for now — their sheet's other columns are damage/weapon-fire poses, not more exhaust). Behemoth's flame was composited from two separate sheet crops (clean idle hull + a flame-only crop of just its thrusters) rather than cropped directly, since the sheet's flame frame has a large red shield-ability graphic overlapping the hull that isn't exhaust. Apex's 3 ships (Lancet/Pulsar/Sovereign) have no flame art — their concept sheet never drew a thrust pose — so they're untouched and always show their one sprite. Wired into gameplay via a new optional `flame_path` per ship in `team_select.gd`'s `RACES`, threaded through `PlayerRegistry` (`submit_local_loadout`/`register_bot`/the loadout RPCs, new `ship_flame_path` field, default `""`) so bots and remote peers all get it too, not just the local owner. `ship_movement.gd`'s `_set_thrust_sprite()` swaps the `Sprite2D` texture based on whether the up/down thrust key is *currently held*, not current speed — this game's movement has no drag, so a first cut keyed off `velocity.length()` left the flame on long after letting off the gas since a released ship keeps drifting at speed. The owner predicts this locally the instant it samples input (`_owner_tick`); the server does the same for bots/itself (`_server_tick`) and broadcasts its authoritative value as a new `thrusting` bool on the existing `_receive_state` RPC so every other peer's view of a ship — which never sees raw input, only replicated state — stays in sync too, with no separate RPC needed. Ships with no `flame_path` (Apex) just skip the check entirely (`_flame_texture == null` short-circuits).
## Current Tasks ## Current Tasks
- [ ] Asteroids and environment hazards - [ ] Asteroids and environment hazards
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://csymmghcpcpue"
path="res://.godot/imported/70fb9114-c6c8-459c-ad2f-8111f30c9b82.jpeg-7e809ec090d7c262b7af7abf69bc7fa3.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/banners/70fb9114-c6c8-459c-ad2f-8111f30c9b82.jpeg"
dest_files=["res://.godot/imported/70fb9114-c6c8-459c-ad2f-8111f30c9b82.jpeg-7e809ec090d7c262b7af7abf69bc7fa3.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://do26o8h5m5hb8"
path="res://.godot/imported/ed212cd5-530a-4520-ba00-23795b40f32c.jpeg-c98f9e50e9255c5b98318bf225959231.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/banners/ed212cd5-530a-4520-ba00-23795b40f32c.jpeg"
dest_files=["res://.godot/imported/ed212cd5-530a-4520-ba00-23795b40f32c.jpeg-c98f9e50e9255c5b98318bf225959231.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://7enw50oma8ny"
path="res://.godot/imported/f6790c6b-65b5-4701-ad12-75db071f4969.jpeg-ee0c086fa7093a1da46de3e84860fc21.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/banners/f6790c6b-65b5-4701-ad12-75db071f4969.jpeg"
dest_files=["res://.godot/imported/f6790c6b-65b5-4701-ad12-75db071f4969.jpeg-ee0c086fa7093a1da46de3e84860fc21.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cbvtvq4i1b6mj"
path="res://.godot/imported/barrage_flame.png-dc31f37d25c43c1dd680a3d87a9d51ce.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ships/isn/barrage_flame.png"
dest_files=["res://.godot/imported/barrage_flame.png-dc31f37d25c43c1dd680a3d87a9d51ce.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://jjqmov8jp2gn"
path="res://.godot/imported/behemoth_flame.png-c0e05b4a3fe0f18416e728d95489a5c4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ships/isn/behemoth_flame.png"
dest_files=["res://.godot/imported/behemoth_flame.png-c0e05b4a3fe0f18416e728d95489a5c4.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dkdx0l7j5niek"
path="res://.godot/imported/patriot_flame.png-9ca141f355519db996c8cde162fafa5f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ships/isn/patriot_flame.png"
dest_files=["res://.godot/imported/patriot_flame.png-9ca141f355519db996c8cde162fafa5f.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dh6xgxgv31wxb"
path="res://.godot/imported/iron_clad_flame.png-920e3b9df5be762900ddeb1e02f1c4fe.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ships/orc/iron_clad_flame.png"
dest_files=["res://.godot/imported/iron_clad_flame.png-920e3b9df5be762900ddeb1e02f1c4fe.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dqjwq05h8bvo"
path="res://.godot/imported/rail_jack_flame.png-adcdcd3322d05f20100b740587d9ca87.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ships/orc/rail_jack_flame.png"
dest_files=["res://.godot/imported/rail_jack_flame.png-adcdcd3322d05f20100b740587d9ca87.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bfp4jaegntjnw"
path="res://.godot/imported/scrap_spitter_flame.png-4dde0769583adec98b69041adc88e9d5.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ships/orc/scrap_spitter_flame.png"
dest_files=["res://.godot/imported/scrap_spitter_flame.png-4dde0769583adec98b69041adc88e9d5.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://di0k3q7l1cqic"
path="res://.godot/imported/MyVeryOwnDeadShip.ogg-2887b4e03d42491161284b1a1f82465a.oggvorbisstr"
[deps]
source_file="res://assets/sound/menu/MyVeryOwnDeadShip.ogg"
dest_files=["res://.godot/imported/MyVeryOwnDeadShip.ogg-2887b4e03d42491161284b1a1f82465a.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://fvoe3ofyvk0"
path="res://.godot/imported/apex_1.wav-04224be7c4d888cfa8ad7540e351b885.sample"
[deps]
source_file="res://assets/sound/ships/apex/apex_1.wav"
dest_files=["res://.godot/imported/apex_1.wav-04224be7c4d888cfa8ad7540e351b885.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://trblktkshml2"
path="res://.godot/imported/isn_1.wav-debf57fe39a72ee924b79a5dd14ab830.sample"
[deps]
source_file="res://assets/sound/ships/isn/isn_1.wav"
dest_files=["res://.godot/imported/isn_1.wav-debf57fe39a72ee924b79a5dd14ab830.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://d2jvn7p3wll04"
path="res://.godot/imported/orc_1.wav-0e7d27aca8a1e64998f822086458e7b2.sample"
[deps]
source_file="res://assets/sound/ships/orc/orc_1.wav"
dest_files=["res://.godot/imported/orc_1.wav-0e7d27aca8a1e64998f822086458e7b2.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
+49 -3
View File
@@ -10,22 +10,34 @@ var ship_rotation_speed: float = 1.0
var ship_rotation_ramp_delay: float = 0.15 var ship_rotation_ramp_delay: float = 0.15
var ship_rotation_speed_held: float = 2.0 var ship_rotation_speed_held: float = 2.0
# Shooting # Shooting — damage/energy cost/bullet count all scale per-role (Fighter/
# Gunner/Tank, see menu/team_select.gd's RACES "role" field); Gunner and Tank
# fire their bullets in a side-by-side pair instead of Fighter's single shot
# (see ship_movement.gd's _server_process_shoot()).
var ship_fire_rate: float = 0.15 var ship_fire_rate: float = 0.15
var bullet_speed: float = 450.0 var bullet_speed: float = 450.0
var ship_bullet_damage_by_role: Dictionary = {"Fighter": 100, "Gunner": 25, "Tank": 50}
var ship_bullet_count_by_role: Dictionary = {"Fighter": 1, "Gunner": 2, "Tank": 2}
var bullet_side_spacing: float = 14.0
var bullet_max_range: float = 1500.0
# Per-shot sound effect (menu/team_select.gd's RACES "sound_path" field, one
# shared clip per faction) — max_distance is in world/pixel units, same as
# AudioStreamPlayer2D expects, so only ships within this radius of the shot
# actually hear it (see world.gd's _play_shoot_sound()).
var ship_shoot_sound_max_distance: float = 500.0
# Health & respawn # Health & respawn
var ship_max_health: int = 100 var ship_max_health: int = 100
var ship_respawn_delay: float = 3.0 var ship_respawn_delay: float = 3.0
var ship_invincibility_time: float = 2.0 var ship_invincibility_time: float = 2.0
var bullet_damage: int = 40
# Energy (see ships/ship_movement.gd) — max energy scales by ship role # Energy (see ships/ship_movement.gd) — max energy scales by ship role
# (Fighter/Gunner/Tank, see menu/team_select.gd's RACES ship "role" field); # (Fighter/Gunner/Tank, see menu/team_select.gd's RACES ship "role" field);
# regen is a flat rate regardless of role, so bigger ships take longer to # regen is a flat rate regardless of role, so bigger ships take longer to
# top off from empty than smaller ones. # top off from empty than smaller ones.
var ship_max_energy_by_role: Dictionary = {"Fighter": 100.0, "Gunner": 150.0, "Tank": 250.0} var ship_max_energy_by_role: Dictionary = {"Fighter": 100.0, "Gunner": 150.0, "Tank": 250.0}
var bullet_energy_cost: float = 75.0 var ship_bullet_energy_cost_by_role: Dictionary = {"Fighter": 75.0, "Gunner": 25.0, "Tank": 50.0}
var ship_energy_regen_rate: float = 100.0 / 2.5 # 100 energy per 2.5s var ship_energy_regen_rate: float = 100.0 / 2.5 # 100 energy per 2.5s
# Environment collision (asteroids only) — bounce-back and impact damage scale with impact speed # Environment collision (asteroids only) — bounce-back and impact damage scale with impact speed
@@ -38,6 +50,11 @@ var ship_collision_damage_scale: float = 0.064
# balanced per-role values, just scales the end result uniformly. # balanced per-role values, just scales the end result uniformly.
var ship_scale_factor: float = 0.75 var ship_scale_factor: float = 0.75
# How much of the sprite's own texture bounds its collision capsule covers
# (see ship_movement.gd's _update_collision_shape()) — under 1.0 to stay
# inside the hull's soft/anti-aliased edge instead of the full texture rect.
var ship_hitbox_scale: float = 0.85
# Explosion VFX (effects/explosion.gd) — one shared knob to resize every # Explosion VFX (effects/explosion.gd) — one shared knob to resize every
# explosion in the game at once; explosion_fps controls playback speed of the # explosion in the game at once; explosion_fps controls playback speed of the
# 12-frame animation (see assets/images/effects/explosion/). Each frame is # 12-frame animation (see assets/images/effects/explosion/). Each frame is
@@ -48,6 +65,35 @@ var ship_scale_factor: float = 0.75
var explosion_scale: float = 0.75 var explosion_scale: float = 0.75
var explosion_fps: float = 24.0 # 60 ticks / 24.0 fps = 2.5s total var explosion_fps: float = 24.0 # 60 ticks / 24.0 fps = 2.5s total
# Damage-number VFX (effects/damage_number.gd) — floating "(amount)" combat
# text shown only to the attacker when their shot lands, not broadcast to
# every peer like the kill feed (see ship_movement.gd's
# _notify_damage_dealt()/_show_damage_number()).
var damage_number_rise_distance: float = 36.0
var damage_number_duration: float = 1.4
var damage_number_font_size: int = 34
var damage_number_color: Color = Color(0.95, 0.15, 0.15)
# Shared white/teammate vs. red/enemy color scheme -- used by both the
# top-left roster (hud/player_list.gd) and in-world ship nameplates
# (ships/ship_movement.gd) so the two always agree. Was a yellow enemy_color
# that read too close to white/teammate at a glance; red is unambiguous.
var team_color: Color = Color(0.92, 0.95, 1.0)
var enemy_color: Color = Color(0.95, 0.25, 0.25)
# In-world ship nameplate (ships/ship_movement.gd) -- offset from ship center
# so it sits at the bottom-right of the sprite rather than directly under it
# (keeps it clear of the engine flame and doesn't read as a health-bar-like
# element stacked straight below).
var ship_nameplate_offset: Vector2 = Vector2(26, 24)
var ship_nameplate_font_size: int = 18
# One shared knob for the local player's view distance -- Camera2D.zoom below
# 1.0 shows more world per screen pixel (zoomed out), above 1.0 shows less
# (zoomed in). Applied once in ship_movement.gd's _ready() to the owner's own
# Camera2D only; doesn't affect any other peer's view.
var camera_zoom: float = 0.85
# Locally-remembered callsign, pre-fills the name field on the menu. # Locally-remembered callsign, pre-fills the name field on the menu.
# Race/ship/speed are per-peer now — see PlayerRegistry. # Race/ship/speed are per-peer now — see PlayerRegistry.
var player_name: String = "" var player_name: String = ""
+44
View File
@@ -0,0 +1,44 @@
extends Node
# Background music for the pre-game screens (main menu, server select, and
# the in-world "LOADING MATCH..." screen up to a player's first-ever ship
# pick). One shared autoload — rather than a player per-scene — so the track
# keeps playing seamlessly across scene changes instead of restarting on
# every menu->menu transition (main_menu.tscn/server_select.tscn are swapped
# in whole via change_scene_to_file, which frees everything in the old scene).
const MENU_MUSIC: AudioStream = preload("res://assets/sound/menu/MyVeryOwnDeadShip.ogg")
const FADE_TIME := 1.2
const PLAY_VOLUME_DB := -8.0
const FADE_OUT_VOLUME_DB := -40.0
var _player: AudioStreamPlayer
var _fade_tween: Tween
func _ready() -> void:
_player = AudioStreamPlayer.new()
_player.stream = MENU_MUSIC
_player.volume_db = PLAY_VOLUME_DB
add_child(_player)
_player.finished.connect(_player.play) # loop the track
# Idempotent — safe to call from every pre-game screen's _ready() without
# restarting the track if it's already playing (or resuming from a fade the
# player interrupted, e.g. quitting straight back to the menu mid-fade-out).
func play_menu_music() -> void:
if _fade_tween and _fade_tween.is_running():
_fade_tween.kill()
_player.volume_db = PLAY_VOLUME_DB
if not _player.playing:
_player.play()
func stop_menu_music() -> void:
if not _player.playing:
return
if _fade_tween:
_fade_tween.kill()
_fade_tween = create_tween()
_fade_tween.tween_property(_player, "volume_db", FADE_OUT_VOLUME_DB, FADE_TIME)
_fade_tween.tween_callback(_player.stop)
+1
View File
@@ -0,0 +1 @@
uid://b3igqhi4jnw8h
+16 -14
View File
@@ -17,7 +17,7 @@ signal player_joined(peer_id: int)
# race is already overwritten in `players`. # race is already overwritten in `players`.
signal race_changed(peer_id: int, old_race: int, new_race: int) signal race_changed(peer_id: int, old_race: int, new_race: int)
var players: Dictionary = {} # peer_id -> {name, race, ship_path, ship_scale, ship_speed_factor, role} var players: Dictionary = {} # peer_id -> {name, race, ship_path, ship_scale, ship_speed_factor, role, ship_flame_path, ship_sound_path}
func _ready() -> void: func _ready() -> void:
@@ -32,18 +32,18 @@ func get_info(peer_id: int) -> Dictionary:
return players.get(peer_id, {}) return players.get(peer_id, {})
func submit_local_loadout(player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String) -> void: func submit_local_loadout(player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String, ship_flame_path: String = "", ship_sound_path: String = "") -> void:
submit_loadout.rpc(player_name, race, ship_path, ship_scale, ship_speed_factor, role) submit_loadout.rpc(player_name, race, ship_path, ship_scale, ship_speed_factor, role, ship_flame_path, ship_sound_path)
# Server-only: registers a bot's loadout the same way a real player's # Server-only: registers a bot's loadout the same way a real player's
# submit_loadout would (minus the backfill, since a fresh bot has nothing to # submit_loadout would (minus the backfill, since a fresh bot has nothing to
# learn), broadcasting to every connected peer so bots render identically to # learn), broadcasting to every connected peer so bots render identically to
# human ships on every client. See bots/bot_manager.gd. # human ships on every client. See bots/bot_manager.gd.
func register_bot(peer_id: int, bot_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String) -> void: func register_bot(peer_id: int, bot_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String, ship_flame_path: String = "", ship_sound_path: String = "") -> void:
for pid in multiplayer.get_peers(): for pid in multiplayer.get_peers():
_receive_loadout.rpc_id(pid, peer_id, bot_name, race, ship_path, ship_scale, ship_speed_factor, role) _receive_loadout.rpc_id(pid, peer_id, bot_name, race, ship_path, ship_scale, ship_speed_factor, role, ship_flame_path, ship_sound_path)
_store(peer_id, bot_name, race, ship_path, ship_scale, ship_speed_factor, role) _store(peer_id, bot_name, race, ship_path, ship_scale, ship_speed_factor, role, ship_flame_path, ship_sound_path)
func unregister_bot(peer_id: int) -> void: func unregister_bot(peer_id: int) -> void:
@@ -54,32 +54,32 @@ func unregister_bot(peer_id: int) -> void:
# the new loadout to everyone else, and backfills the newcomer with everyone # the new loadout to everyone else, and backfills the newcomer with everyone
# else's already-known loadout. # else's already-known loadout.
@rpc("any_peer", "call_local", "reliable") @rpc("any_peer", "call_local", "reliable")
func submit_loadout(player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String) -> void: func submit_loadout(player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String, ship_flame_path: String = "", ship_sound_path: String = "") -> void:
var sender_id := multiplayer.get_remote_sender_id() var sender_id := multiplayer.get_remote_sender_id()
if sender_id == 0: if sender_id == 0:
sender_id = multiplayer.get_unique_id() sender_id = multiplayer.get_unique_id()
if not multiplayer.is_server(): if not multiplayer.is_server():
_store(sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor, role) _store(sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor, role, ship_flame_path, ship_sound_path)
return return
for peer_id in multiplayer.get_peers(): for peer_id in multiplayer.get_peers():
if peer_id != sender_id: if peer_id != sender_id:
_receive_loadout.rpc_id(peer_id, sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor, role) _receive_loadout.rpc_id(peer_id, sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor, role, ship_flame_path, ship_sound_path)
for peer_id in players: for peer_id in players:
if peer_id != sender_id: if peer_id != sender_id:
var info: Dictionary = players[peer_id] var info: Dictionary = players[peer_id]
_receive_loadout.rpc_id(sender_id, peer_id, info.name, info.race, info.ship_path, info.ship_scale, info.ship_speed_factor, info.role) _receive_loadout.rpc_id(sender_id, peer_id, info.name, info.race, info.ship_path, info.ship_scale, info.ship_speed_factor, info.role, info.get("ship_flame_path", ""), info.get("ship_sound_path", ""))
_store(sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor, role) _store(sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor, role, ship_flame_path, ship_sound_path)
@rpc("authority", "call_remote", "reliable") @rpc("authority", "call_remote", "reliable")
func _receive_loadout(peer_id: int, player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String) -> void: func _receive_loadout(peer_id: int, player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String, ship_flame_path: String = "", ship_sound_path: String = "") -> void:
_store(peer_id, player_name, race, ship_path, ship_scale, ship_speed_factor, role) _store(peer_id, player_name, race, ship_path, ship_scale, ship_speed_factor, role, ship_flame_path, ship_sound_path)
func _store(peer_id: int, player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String) -> void: func _store(peer_id: int, player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String, ship_flame_path: String = "", ship_sound_path: String = "") -> void:
var old_race: int = players.get(peer_id, {}).get("race", 0) var old_race: int = players.get(peer_id, {}).get("race", 0)
players[peer_id] = { players[peer_id] = {
"name": player_name, "name": player_name,
@@ -88,6 +88,8 @@ func _store(peer_id: int, player_name: String, race: int, ship_path: String, shi
"ship_scale": ship_scale, "ship_scale": ship_scale,
"ship_speed_factor": ship_speed_factor, "ship_speed_factor": ship_speed_factor,
"role": role, "role": role,
"ship_flame_path": ship_flame_path,
"ship_sound_path": ship_sound_path,
} }
print("[PlayerRegistry] peer %d loadout: %s" % [peer_id, players[peer_id]]) print("[PlayerRegistry] peer %d loadout: %s" % [peer_id, players[peer_id]])
loadout_updated.emit(peer_id) loadout_updated.emit(peer_id)
+26 -4
View File
@@ -62,7 +62,7 @@ func _reconcile_race(race_id: int) -> void:
var desired: int = max(0, GameConfig.bot_min_team_size - humans) var desired: int = max(0, GameConfig.bot_min_team_size - humans)
var current: Array = _bots_by_race.get(race_id, []) var current: Array = _bots_by_race.get(race_id, [])
while current.size() < desired: while current.size() < desired:
current.append(_spawn_bot(race_id)) current.append(_spawn_bot(race_id, current.size()))
while current.size() > desired: while current.size() > desired:
_despawn_bot(current.pop_back()) _despawn_bot(current.pop_back())
_bots_by_race[race_id] = current _bots_by_race[race_id] = current
@@ -76,15 +76,16 @@ func _count_humans(race_id: int) -> int:
return count return count
func _spawn_bot(race_id: int) -> int: func _spawn_bot(race_id: int, slot_index: int) -> int:
var bot_id := _next_bot_id var bot_id := _next_bot_id
_next_bot_id -= 1 _next_bot_id -= 1
var race := _find_race(race_id) var race := _find_race(race_id)
var ship_def: Dictionary = race.ships[0] # bots always fly the race's fighter (Interceptor) var ship_def: Dictionary = _bot_ship_def(race, slot_index)
PlayerRegistry.register_bot( PlayerRegistry.register_bot(
bot_id, BotNames.random_name(), race_id, bot_id, BotNames.random_name(), race_id,
ship_def.path, ship_def.scale, ship_def.speed_factor, ship_def.role ship_def.path, ship_def.scale, ship_def.speed_factor, ship_def.role,
ship_def.get("flame_path", ""), ship_def.get("sound_path", "")
) )
var ship_node := _world.spawn_peer(bot_id) as Ship var ship_node := _world.spawn_peer(bot_id) as Ship
@@ -93,6 +94,27 @@ func _spawn_bot(race_id: int) -> int:
return bot_id return bot_id
# Bots used to always fly the race's Fighter (ships[0]); now the first-spawned
# bot on a team flies its Tank, the next two fly its Gunner, and every bot
# after that flies its Fighter -- so a fully bot-filled team always has at
# least 1 Tank + 2 Gunner "backbone" instead of reading as an all-Fighter
# swarm. Keyed off slot_index (this bot's position within its race's bot
# array, before it's appended -- see _reconcile_race()) rather than the
# team's current size, so growing/shrinking the roster never reshuffles an
# already-spawned bot's ship: _reconcile_race() only ever adds at the tail
# and removes from the tail (pop_back()), so the Tank/Gunner bots spawned
# into the earliest slots are always the last ones cut as humans join.
# Relies on team_select.gd's RACES ship order being [Fighter, Gunner, Tank]
# for every faction.
func _bot_ship_def(race: Dictionary, slot_index: int) -> Dictionary:
if slot_index == 0:
return race.ships[2] # Tank
elif slot_index <= 2:
return race.ships[1] # Gunner
else:
return race.ships[0] # Fighter
func _despawn_bot(bot_id: int) -> void: func _despawn_bot(bot_id: int) -> void:
_world.despawn_peer(bot_id) _world.despawn_peer(bot_id)
PlayerRegistry.unregister_bot(bot_id) PlayerRegistry.unregister_bot(bot_id)
+32
View File
@@ -0,0 +1,32 @@
extends Label
class_name DamageNumber
# Floating "(amount)" combat text shown only to the attacker when their shot
# lands (see ships/ship_movement.gd's _notify_damage_dealt()) — purely
# cosmetic and spawned locally on whichever peer it's told to appear for,
# same "no networked node needed" approach as effects/explosion.gd.
var amount: int = 0
const BOX_SIZE := Vector2(80, 24)
func _ready() -> void:
text = "(%d)" % amount
add_theme_font_size_override("font_size", GameConfig.damage_number_font_size)
add_theme_color_override("font_color", GameConfig.damage_number_color)
add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.8))
add_theme_constant_override("outline_size", 3)
mouse_filter = Control.MOUSE_FILTER_IGNORE
horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vertical_alignment = VERTICAL_ALIGNMENT_CENTER
custom_minimum_size = BOX_SIZE
size = BOX_SIZE
# global_position was set to the hit point by the spawner; center the box
# on it instead of growing down-right from it.
global_position -= BOX_SIZE / 2.0
var tween := create_tween()
tween.set_parallel(true)
tween.tween_property(self, "position:y", position.y - GameConfig.damage_number_rise_distance, GameConfig.damage_number_duration)
tween.tween_property(self, "modulate:a", 0.0, GameConfig.damage_number_duration)
tween.chain().tween_callback(queue_free)
+1
View File
@@ -0,0 +1 @@
uid://cj8fpi6biykj1
+6
View File
@@ -0,0 +1,6 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://effects/damage_number.gd" id="1"]
[node name="DamageNumber" type="Label"]
script = ExtResource("1")
+30
View File
@@ -5,6 +5,14 @@ class_name Explosion
# one shared knob for resizing every explosion in the game at once). # one shared knob for resizing every explosion in the game at once).
@export var scale_override: float = 0.0 @export var scale_override: float = 0.0
# Ships no longer collide with each other (see ship.tscn's collision layers),
# but flying straight through an explosion's sprite still looked wrong, so
# GhostArea (a child Area2D, scaled along with this node) hides any ship's
# sprite while it overlaps and restores it on exit. Tracked here so a ship
# still inside when this explosion despawns gets un-ghosted rather than
# stuck invisible.
var _ghosted_ships: Array = []
func _ready() -> void: func _ready() -> void:
var s: float = scale_override if scale_override > 0.0 else GameConfig.explosion_scale var s: float = scale_override if scale_override > 0.0 else GameConfig.explosion_scale
@@ -12,3 +20,25 @@ func _ready() -> void:
sprite_frames.set_animation_speed("explode", GameConfig.explosion_fps) sprite_frames.set_animation_speed("explode", GameConfig.explosion_fps)
play("explode") play("explode")
animation_finished.connect(queue_free) animation_finished.connect(queue_free)
var ghost_area := get_node("GhostArea") as Area2D
ghost_area.body_entered.connect(_on_body_entered)
ghost_area.body_exited.connect(_on_body_exited)
func _on_body_entered(body: Node) -> void:
if body.has_method("set_ghosted"):
body.set_ghosted(true)
_ghosted_ships.append(body)
func _on_body_exited(body: Node) -> void:
if body.has_method("set_ghosted"):
body.set_ghosted(false)
_ghosted_ships.erase(body)
func _exit_tree() -> void:
for ship in _ghosted_ships:
if is_instance_valid(ship):
ship.set_ghosted(false)
+10
View File
@@ -14,6 +14,9 @@
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_11.png" id="12_expl11"] [ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_11.png" id="12_expl11"]
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_12.png" id="13_expl12"] [ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_12.png" id="13_expl12"]
[sub_resource type="CircleShape2D" id="CircleShape2D_expl01"]
radius = 45.0
[sub_resource type="SpriteFrames" id="SpriteFrames_expl01"] [sub_resource type="SpriteFrames" id="SpriteFrames_expl01"]
animations = [{ animations = [{
"frames": [{ "frames": [{
@@ -63,3 +66,10 @@ sprite_frames = SubResource("SpriteFrames_expl01")
animation = &"explode" animation = &"explode"
autoplay = "" autoplay = ""
script = ExtResource("1_expl01") script = ExtResource("1_expl01")
[node name="GhostArea" type="Area2D" parent="."]
collision_layer = 0
collision_mask = 2
[node name="CollisionShape2D" type="CollisionShape2D" parent="GhostArea"]
shape = SubResource("CircleShape2D_expl01")
+13 -9
View File
@@ -1,16 +1,20 @@
extends CanvasLayer extends CanvasLayer
# Blue "mirrored" energy bar, top-middle of the screen: the filled region is # "Mirrored" stat bar, top-middle of the screen: the filled region is
# centered in the bar's full extent, so it shrinks toward the middle from # centered in the bar's full extent, so it shrinks toward the middle from
# both edges as energy drains, instead of draining left-to-right like a # both edges as the stat drains, instead of draining left-to-right like a
# normal bar. Driven by Ship._update_hud() calling set_fill() with the local # normal bar. Driven by Ship._update_hud() calling set_fill() with a
# player's energy/max_energy fraction (see ships/ship_movement.gd). # health/max_health or energy/max_energy fraction (see ships/ship_movement.gd).
# Reused for both the HealthBar and EnergyBar instances in world.tscn — fill
# color and vertical stacking position are per-instance overrides of the
# exports below (health above energy).
const BAR_WIDTH := 300.0 const BAR_WIDTH := 300.0
const BAR_HEIGHT := 14.0 const BAR_HEIGHT := 14.0
const MARGIN_TOP := 20.0
const COLOR_BG := Color(0.15, 0.18, 0.25, 0.6) const COLOR_BG := Color(0.15, 0.18, 0.25, 0.6)
const COLOR_FILL := Color(0.25, 0.55, 1.0, 0.95)
@export var margin_top: float = 20.0
@export var fill_color: Color = Color(0.25, 0.55, 1.0, 0.95)
var _fill: ColorRect var _fill: ColorRect
@@ -29,8 +33,8 @@ func _build_ui() -> void:
container.anchor_bottom = 0.0 container.anchor_bottom = 0.0
container.offset_left = -BAR_WIDTH / 2.0 container.offset_left = -BAR_WIDTH / 2.0
container.offset_right = BAR_WIDTH / 2.0 container.offset_right = BAR_WIDTH / 2.0
container.offset_top = MARGIN_TOP container.offset_top = margin_top
container.offset_bottom = MARGIN_TOP + BAR_HEIGHT container.offset_bottom = margin_top + BAR_HEIGHT
container.mouse_filter = Control.MOUSE_FILTER_IGNORE container.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(container) add_child(container)
@@ -44,7 +48,7 @@ func _build_ui() -> void:
container.add_child(bg) container.add_child(bg)
_fill = ColorRect.new() _fill = ColorRect.new()
_fill.color = COLOR_FILL _fill.color = fill_color
_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE _fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
container.add_child(_fill) container.add_child(_fill)
+1
View File
@@ -0,0 +1 @@
uid://dx6kcvxxca00e
+3 -6
View File
@@ -2,18 +2,15 @@ extends CanvasLayer
# Top-left roster of every connected player (human + bot). White entries are # Top-left roster of every connected player (human + bot). White entries are
# on the local player's team (same race — races double as teams, see # on the local player's team (same race — races double as teams, see
# TeamSelect), yellow entries are the enemy team. Bots (negative peer_id, see # TeamSelect), red entries are the enemy team. Bots (negative peer_id, see
# bots/bot_manager.gd) get a trailing " (b)". Rebuilt whenever # bots/bot_manager.gd) get a trailing " (b)". Rebuilt whenever
# PlayerRegistry's roster changes rather than polled every frame. # PlayerRegistry's roster changes rather than polled every frame.
const MARGIN_X := 16.0 const MARGIN_X := 16.0
const MARGIN_TOP := 90.0 # below HUD/HealthBar + HUD/EnergyBar, which occupy y 20-74 const MARGIN_TOP := 90.0
const WIDTH := 220.0 const WIDTH := 220.0
const HEIGHT := 260.0 const HEIGHT := 260.0
const TEAM_COLOR := Color(0.92, 0.95, 1.0)
const ENEMY_COLOR := Color(0.95, 0.85, 0.25)
var _list: RichTextLabel var _list: RichTextLabel
@@ -85,7 +82,7 @@ func _refresh() -> void:
for peer_id in peer_ids: for peer_id in peer_ids:
var info: Dictionary = PlayerRegistry.players[peer_id] var info: Dictionary = PlayerRegistry.players[peer_id]
var is_teammate: bool = info.get("race", 0) == local_race var is_teammate: bool = info.get("race", 0) == local_race
var color := TEAM_COLOR if is_teammate else ENEMY_COLOR var color := GameConfig.team_color if is_teammate else GameConfig.enemy_color
var name_text := _escape_bbcode(String(info.get("name", ""))) var name_text := _escape_bbcode(String(info.get("name", "")))
if peer_id < 0: if peer_id < 0:
name_text += " (b)" name_text += " (b)"
-39
View File
@@ -1,39 +0,0 @@
extends Control
# Simple left-to-right stat bar for the top-left HUD (HealthBar/EnergyBar in
# world.tscn's HUD node) — fill shrinks from the right as the stat depletes.
# Contrast hud/energy_bar.gd's top-middle bar, which drains from both edges
# toward the center instead.
@export var fill_color: Color = Color(0.3, 0.85, 0.35, 0.95)
@export var bg_color: Color = Color(0.12, 0.14, 0.18, 0.6)
var _fill: ColorRect
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
var bg := ColorRect.new()
bg.color = bg_color
bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(bg)
_fill = ColorRect.new()
_fill.color = fill_color
_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
_fill.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(_fill)
set_fill(1.0)
# frac in [0, 1]; anchor_right (not an explicit pixel width) so the fill
# tracks this control's actual size regardless of window width.
func set_fill(frac: float) -> void:
if _fill == null:
return
_fill.anchor_left = 0.0
_fill.anchor_right = clampf(frac, 0.0, 1.0)
_fill.offset_left = 0.0
_fill.offset_right = 0.0
+1
View File
@@ -36,6 +36,7 @@ const NAV_GAP := 4.0
func _ready() -> void: func _ready() -> void:
_build_ui() _build_ui()
MusicManager.play_menu_music()
NetworkManager.connection_succeeded.connect(_on_connected) NetworkManager.connection_succeeded.connect(_on_connected)
NetworkManager.connection_failed.connect(_on_connect_failed) NetworkManager.connection_failed.connect(_on_connect_failed)
MatchmakingClient.match_found.connect(_on_match_found) MatchmakingClient.match_found.connect(_on_match_found)
+1
View File
@@ -37,6 +37,7 @@ var _ping_probes: Array = [] # {udp: PacketPeerUDP, start_msec: int, done: bool
func _ready() -> void: func _ready() -> void:
_build_ui() _build_ui()
MusicManager.play_menu_music()
NetworkManager.connection_succeeded.connect(_on_connected) NetworkManager.connection_succeeded.connect(_on_connected)
NetworkManager.connection_failed.connect(_on_connect_failed) NetworkManager.connection_failed.connect(_on_connect_failed)
_refresh_servers() _refresh_servers()
+22 -1
View File
@@ -18,16 +18,19 @@ const RACES := [
"name": "LANCET", "role": "Fighter", "name": "LANCET", "role": "Fighter",
"desc": "A single, high-precision bolt of blue coherent light. Built to poke from range and vanish before anything can return fire — the sharpest shot in the fleet, if you can land it.", "desc": "A single, high-precision bolt of blue coherent light. Built to poke from range and vanish before anything can return fire — the sharpest shot in the fleet, if you can land it.",
"path": "res://assets/images/ships/apex/lancet.png", "scale": 0.296, "speed_factor": SPEED_BY_ROLE.Fighter, "path": "res://assets/images/ships/apex/lancet.png", "scale": 0.296, "speed_factor": SPEED_BY_ROLE.Fighter,
"sound_path": "res://assets/sound/ships/apex/apex_1.wav",
}, },
{ {
"name": "PULSAR", "role": "Gunner", "name": "PULSAR", "role": "Gunner",
"desc": "A wide, rapid-fire fan of low-damage green plasma pulses. Trades precision for volume — spray enough bolts and something connects.", "desc": "A wide, rapid-fire fan of low-damage green plasma pulses. Trades precision for volume — spray enough bolts and something connects.",
"path": "res://assets/images/ships/apex/pulsar.png", "scale": 0.373, "speed_factor": SPEED_BY_ROLE.Gunner, "path": "res://assets/images/ships/apex/pulsar.png", "scale": 0.373, "speed_factor": SPEED_BY_ROLE.Gunner,
"sound_path": "res://assets/sound/ships/apex/apex_1.wav",
}, },
{ {
"name": "SOVEREIGN", "role": "Tank", "name": "SOVEREIGN", "role": "Tank",
"desc": "Homing micro-missiles alongside dual heavy plasma batteries. Slow and telegraphed, but its lock-on missiles punish anyone who tries to just outrun it.", "desc": "Homing micro-missiles alongside dual heavy plasma batteries. Slow and telegraphed, but its lock-on missiles punish anyone who tries to just outrun it.",
"path": "res://assets/images/ships/apex/sovereign.png", "scale": 0.348, "speed_factor": SPEED_BY_ROLE.Tank, "path": "res://assets/images/ships/apex/sovereign.png", "scale": 0.348, "speed_factor": SPEED_BY_ROLE.Tank,
"sound_path": "res://assets/sound/ships/apex/apex_1.wav",
}, },
], ],
}, },
@@ -39,16 +42,22 @@ const RACES := [
"name": "PATRIOT", "role": "Fighter", "name": "PATRIOT", "role": "Fighter",
"desc": "A single, hard-hitting high-caliber explosive shell. Standard-issue and dependable — a clean, punishing hit if you land it.", "desc": "A single, hard-hitting high-caliber explosive shell. Standard-issue and dependable — a clean, punishing hit if you land it.",
"path": "res://assets/images/ships/isn/patriot.png", "scale": 0.504, "speed_factor": SPEED_BY_ROLE.Fighter, "path": "res://assets/images/ships/isn/patriot.png", "scale": 0.504, "speed_factor": SPEED_BY_ROLE.Fighter,
"sound_path": "res://assets/sound/ships/isn/isn_1.wav",
"flame_path": "res://assets/images/ships/isn/patriot_flame.png",
}, },
{ {
"name": "BARRAGE", "role": "Gunner", "name": "BARRAGE", "role": "Gunner",
"desc": "A relentless quad-barrel spray of fast, low-damage light bullets. Built for sustained suppressing fire rather than any one killing blow.", "desc": "A relentless quad-barrel spray of fast, low-damage light bullets. Built for sustained suppressing fire rather than any one killing blow.",
"path": "res://assets/images/ships/isn/barrage.png", "scale": 0.516, "speed_factor": SPEED_BY_ROLE.Gunner, "path": "res://assets/images/ships/isn/barrage.png", "scale": 0.516, "speed_factor": SPEED_BY_ROLE.Gunner,
"sound_path": "res://assets/sound/ships/isn/isn_1.wav",
"flame_path": "res://assets/images/ships/isn/barrage_flame.png",
}, },
{ {
"name": "BEHEMOTH", "role": "Tank", "name": "BEHEMOTH", "role": "Tank",
"desc": "Heavy tracking torpedoes plus massive physical deck-guns. The navy's flagship-killer — slow to turn, but nothing stands in its way for long.", "desc": "Heavy tracking torpedoes plus massive physical deck-guns. The navy's flagship-killer — slow to turn, but nothing stands in its way for long.",
"path": "res://assets/images/ships/isn/behemoth.png", "scale": 0.331, "speed_factor": SPEED_BY_ROLE.Tank, "path": "res://assets/images/ships/isn/behemoth.png", "scale": 0.331, "speed_factor": SPEED_BY_ROLE.Tank,
"sound_path": "res://assets/sound/ships/isn/isn_1.wav",
"flame_path": "res://assets/images/ships/isn/behemoth_flame.png",
}, },
], ],
}, },
@@ -60,16 +69,22 @@ const RACES := [
"name": "RAIL-JACK", "role": "Fighter", "name": "RAIL-JACK", "role": "Fighter",
"desc": "A single, heavy magnetic railgun slug. Repurposed mining hardware — one slug, but it hits like a dropped asteroid.", "desc": "A single, heavy magnetic railgun slug. Repurposed mining hardware — one slug, but it hits like a dropped asteroid.",
"path": "res://assets/images/ships/orc/rail_jack.png", "scale": 0.492, "speed_factor": SPEED_BY_ROLE.Fighter, "path": "res://assets/images/ships/orc/rail_jack.png", "scale": 0.492, "speed_factor": SPEED_BY_ROLE.Fighter,
"sound_path": "res://assets/sound/ships/orc/orc_1.wav",
"flame_path": "res://assets/images/ships/orc/rail_jack_flame.png",
}, },
{ {
"name": "SCRAP-SPITTER", "role": "Gunner", "name": "SCRAP-SPITTER", "role": "Gunner",
"desc": "A rotary scrap-cannon spraying rapid, low-damage metal shrapnel. Loud, dirty, and built to keep the pressure on.", "desc": "A rotary scrap-cannon spraying rapid, low-damage metal shrapnel. Loud, dirty, and built to keep the pressure on.",
"path": "res://assets/images/ships/orc/scrap_spitter.png", "scale": 0.462, "speed_factor": SPEED_BY_ROLE.Gunner, "path": "res://assets/images/ships/orc/scrap_spitter.png", "scale": 0.462, "speed_factor": SPEED_BY_ROLE.Gunner,
"sound_path": "res://assets/sound/ships/orc/orc_1.wav",
"flame_path": "res://assets/images/ships/orc/scrap_spitter_flame.png",
}, },
{ {
"name": "IRON-CLAD", "role": "Tank", "name": "IRON-CLAD", "role": "Tank",
"desc": "Unguided explosive rockets plus continuous vulcan machine-gun fire. Bolted-together and brutal — built to grind a fight down, not finesse it.", "desc": "Unguided explosive rockets plus continuous vulcan machine-gun fire. Bolted-together and brutal — built to grind a fight down, not finesse it.",
"path": "res://assets/images/ships/orc/iron_clad.png", "scale": 0.495, "speed_factor": SPEED_BY_ROLE.Tank, "path": "res://assets/images/ships/orc/iron_clad.png", "scale": 0.495, "speed_factor": SPEED_BY_ROLE.Tank,
"sound_path": "res://assets/sound/ships/orc/orc_1.wav",
"flame_path": "res://assets/images/ships/orc/iron_clad_flame.png",
}, },
], ],
}, },
@@ -331,8 +346,14 @@ func _show_ship_selection() -> void:
func _on_ship_chosen(ship: Dictionary) -> void: func _on_ship_chosen(ship: Dictionary) -> void:
PlayerRegistry.submit_local_loadout( PlayerRegistry.submit_local_loadout(
GameConfig.player_name, _chosen_race.id, ship.path, ship.scale, ship.speed_factor, ship.role GameConfig.player_name, _chosen_race.id, ship.path, ship.scale, ship.speed_factor, ship.role,
ship.get("flame_path", ""), ship.get("sound_path", "")
) )
# Only the mandatory pre-spawn pick should end the menu music — a
# mid-match reopen (pause menu SELECT TEAM) leaves it alone since it's
# already stopped by this point (see _reopenable).
if not _reopenable:
MusicManager.stop_menu_music()
_reopenable = true _reopenable = true
_close() _close()
+1 -1
View File
@@ -24,13 +24,13 @@ MatchmakingClient="*res://autoload/matchmaking_client.gd"
ChatManager="*res://autoload/chat_manager.gd" ChatManager="*res://autoload/chat_manager.gd"
KillFeedManager="*res://autoload/kill_feed_manager.gd" KillFeedManager="*res://autoload/kill_feed_manager.gd"
BotManager="*res://bots/bot_manager.gd" BotManager="*res://bots/bot_manager.gd"
MusicManager="*res://autoload/music_manager.gd"
[display] [display]
window/size/viewport_width=1280 window/size/viewport_width=1280
window/size/viewport_height=800 window/size/viewport_height=800
window/size/mode=3 window/size/mode=3
window/stretch/mode="disabled"
[input] [input]
+18 -3
View File
@@ -3,12 +3,16 @@ extends Area2D
# Set by World._spawn_bullet() before this node enters the tree. # Set by World._spawn_bullet() before this node enters the tree.
var velocity: Vector2 = Vector2.ZERO var velocity: Vector2 = Vector2.ZERO
var source_peer_id: int = 0 var source_peer_id: int = 0
var damage: int = 100
var _spawn_pos: Vector2
# Only the server detects hits and despawns bullets — that's replicated to # Only the server detects hits and despawns bullets — that's replicated to
# every client automatically since bullets are spawned via BulletSpawner. # every client automatically since bullets are spawned via BulletSpawner.
# Movement itself stays local on every peer (deterministic constant-velocity # Movement itself stays local on every peer (deterministic constant-velocity
# simulation from the replicated spawn state, no further sync needed). # simulation from the replicated spawn state, no further sync needed).
func _ready() -> void: func _ready() -> void:
_spawn_pos = global_position
if multiplayer.is_server(): if multiplayer.is_server():
body_entered.connect(_on_body_entered) body_entered.connect(_on_body_entered)
@@ -18,10 +22,21 @@ func _process(delta: float) -> void:
var bounds = GameConfig.world_bounds.grow(64.0) var bounds = GameConfig.world_bounds.grow(64.0)
if not bounds.has_point(global_position): if not bounds.has_point(global_position):
queue_free() queue_free()
elif global_position.distance_to(_spawn_pos) > GameConfig.bullet_max_range:
queue_free()
func _on_body_entered(body: Node) -> void: func _on_body_entered(body: Node) -> void:
if body.get("peer_id") == source_peer_id: var target_peer_id = body.get("peer_id")
if target_peer_id == source_peer_id:
return return
if body.has_method("take_damage"): # Friendly fire is blocked, not ignored -- the bullet still stops dead on
body.take_damage(GameConfig.bullet_damage, source_peer_id) # a teammate's hull (queue_free below), it just deals no damage. Only a
# real target (has take_damage, i.e. a Ship) is team-checked; hazards/
# walls have neither peer_id nor take_damage and fall straight through
# to queue_free(), same as before.
if target_peer_id != null and body.has_method("take_damage"):
var source_race: int = PlayerRegistry.get_info(source_peer_id).get("race", -1)
var target_race: int = PlayerRegistry.get_info(target_peer_id).get("race", -2)
if source_race != target_race:
body.take_damage(damage, source_peer_id, global_position)
queue_free() queue_free()
+2
View File
@@ -6,6 +6,8 @@
radius = 4.0 radius = 4.0
[node name="Bullet" type="Area2D"] [node name="Bullet" type="Area2D"]
collision_layer = 0
collision_mask = 3
script = ExtResource("1") script = ExtResource("1")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."] [node name="CollisionShape2D" type="CollisionShape2D" parent="."]
+9 -4
View File
@@ -1,19 +1,21 @@
[gd_scene format=3 uid="uid://dygtdlkvo6ofl"] [gd_scene format=3 uid="uid://dygtdlkvo6ofl"]
[ext_resource type="Script" uid="uid://c4kj3i73wjdcj" path="res://ships/ship_movement.gd" id="1_0e48y"] [ext_resource type="Script" uid="uid://c4kj3i73wjdcj" path="res://ships/ship_movement.gd" id="1_0e48y"]
[ext_resource type="Texture2D" path="res://assets/images/ships/terran/warframe.png" id="2_epypp"]
[sub_resource type="CircleShape2D" id="CircleShape2D_2uxl3"] [sub_resource type="CapsuleShape2D" id="CapsuleShape2D_2uxl3"]
radius = 20.0
height = 40.0
[node name="Player" type="CharacterBody2D" unique_id=1973248732] [node name="Player" type="CharacterBody2D" unique_id=1973248732]
collision_layer = 2
collision_mask = 1
script = ExtResource("1_0e48y") script = ExtResource("1_0e48y")
[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=188418093] [node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=188418093]
shape = SubResource("CircleShape2D_2uxl3") shape = SubResource("CapsuleShape2D_2uxl3")
[node name="Sprite2D" type="Sprite2D" parent="." unique_id=2083904209] [node name="Sprite2D" type="Sprite2D" parent="." unique_id=2083904209]
scale = Vector2(0.229, 0.229) scale = Vector2(0.229, 0.229)
texture = ExtResource("2_epypp")
[node name="VisibleOnScreenNotifier2D" type="VisibleOnScreenNotifier2D" parent="." unique_id=1504818491] [node name="VisibleOnScreenNotifier2D" type="VisibleOnScreenNotifier2D" parent="." unique_id=1504818491]
@@ -22,3 +24,6 @@ current = false
rotating = false rotating = false
position_smoothing_enabled = true position_smoothing_enabled = true
position_smoothing_speed = 8.0 position_smoothing_speed = 8.0
[node name="NamePlate" type="Label" parent="."]
top_level = true
+203 -20
View File
@@ -2,6 +2,7 @@ extends CharacterBody2D
class_name Ship class_name Ship
const EXPLOSION_SCENE: PackedScene = preload("res://effects/explosion.tscn") const EXPLOSION_SCENE: PackedScene = preload("res://effects/explosion.tscn")
const DAMAGE_NUMBER_SCENE: PackedScene = preload("res://effects/damage_number.tscn")
# Set by World._spawn_ship() before this node enters the tree. # Set by World._spawn_ship() before this node enters the tree.
var peer_id: int = 0 var peer_id: int = 0
@@ -19,6 +20,7 @@ var _turn_hold_time: float = 0.0
var health: int = 0 var health: int = 0
var energy: float = 0.0 var energy: float = 0.0
var max_energy: float = 100.0 var max_energy: float = 100.0
var _role: String = "Fighter"
var _dead: bool = false var _dead: bool = false
# Last-hit attribution for the kill feed (see autoload/kill_feed_manager.gd). # Last-hit attribution for the kill feed (see autoload/kill_feed_manager.gd).
@@ -30,9 +32,25 @@ var _invincible: bool = false
var _invincible_timer: float = 0.0 var _invincible_timer: float = 0.0
var _ship_speed_factor: float = 1.0 var _ship_speed_factor: float = 1.0
# Idle (no-flame) vs engine-flame sprite swap — see _update_thrust_visual().
# _flame_texture stays null for ships with no flame_path (e.g. Apex), which
# just keeps _update_thrust_visual() from ever touching the sprite.
var _idle_texture: Texture2D
var _flame_texture: Texture2D
var _thrusting: bool = false
# Reference-counted so overlapping explosions (or a ship lingering across two
# blasts) can't have one explosion's exit re-reveal a ship another explosion
# is still covering -- see effects/explosion.gd's GhostArea.
var _ghost_count: int = 0
var _is_owner: bool = false var _is_owner: bool = false
var _fixed_delta: float = 1.0 / 60.0 var _fixed_delta: float = 1.0 / 60.0
# Bottom-right nameplate — see _refresh_nameplate() and ship.tscn's
# top_level Label child.
var _nameplate: Label
# Owner-side prediction: inputs sent to the server but not yet acknowledged, # Owner-side prediction: inputs sent to the server but not yet acknowledged,
# replayed on top of the server's authoritative state on correction. # replayed on top of the server's authoritative state on correction.
const MAX_PENDING_INPUTS := 180 const MAX_PENDING_INPUTS := 180
@@ -59,6 +77,18 @@ func _ready() -> void:
_is_owner = peer_id == multiplayer.get_unique_id() _is_owner = peer_id == multiplayer.get_unique_id()
add_to_group("ships") # lets BotAI find nearby targets without a World reference add_to_group("ships") # lets BotAI find nearby targets without a World reference
# top_level (set in ship.tscn) keeps this from inheriting the ship's own
# rotation, so the name always reads upright regardless of facing.
_nameplate = get_node_or_null("NamePlate") as Label
if _nameplate:
_nameplate.visible = false
_nameplate.mouse_filter = Control.MOUSE_FILTER_IGNORE
_nameplate.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_nameplate.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_nameplate.add_theme_font_size_override("font_size", GameConfig.ship_nameplate_font_size)
_nameplate.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
_nameplate.add_theme_constant_override("outline_size", 3)
# Only ever claim the camera for the local owner. Whichever Camera2D # Only ever claim the camera for the local owner. Whichever Camera2D
# enters the SceneTree first auto-claims the viewport's active camera # enters the SceneTree first auto-claims the viewport's active camera
# regardless of its own `current` value (Godot's built-in "first camera # regardless of its own `current` value (Godot's built-in "first camera
@@ -71,6 +101,7 @@ func _ready() -> void:
if _is_owner: if _is_owner:
var camera := get_node_or_null("Camera2D") as Camera2D var camera := get_node_or_null("Camera2D") as Camera2D
if camera: if camera:
camera.zoom = Vector2.ONE * GameConfig.camera_zoom
camera.call_deferred("make_current") camera.call_deferred("make_current")
PlayerRegistry.loadout_updated.connect(_on_loadout_updated) PlayerRegistry.loadout_updated.connect(_on_loadout_updated)
@@ -85,21 +116,76 @@ func _ready() -> void:
func _on_loadout_updated(updated_peer_id: int) -> void: func _on_loadout_updated(updated_peer_id: int) -> void:
if updated_peer_id == peer_id: if updated_peer_id == peer_id:
_init_player(PlayerRegistry.get_info(peer_id)) _init_player(PlayerRegistry.get_info(peer_id))
elif updated_peer_id == PlayerRegistry.get_local_id():
# The local viewer's own race changed (mid-match team swap) -- every
# other ship's teammate/enemy color depends on that comparison too,
# not just this ship's own info.
_refresh_nameplate()
func _init_player(info: Dictionary) -> void: func _init_player(info: Dictionary) -> void:
var sprite := get_node_or_null("Sprite2D") var sprite := get_node_or_null("Sprite2D") as Sprite2D
var ship_path: String = info.get("ship_path", "") var ship_path: String = info.get("ship_path", "")
if sprite and not ship_path.is_empty(): if sprite and not ship_path.is_empty():
sprite.texture = load(ship_path) _idle_texture = load(ship_path)
sprite.scale = Vector2.ONE * info.get("ship_scale", 1.0) * GameConfig.ship_scale_factor var flame_path: String = info.get("ship_flame_path", "")
_flame_texture = load(flame_path) if not flame_path.is_empty() else null
sprite.texture = _idle_texture
_thrusting = false
var total_scale: float = info.get("ship_scale", 1.0) * GameConfig.ship_scale_factor
sprite.scale = Vector2.ONE * total_scale
_update_collision_shape(_idle_texture, total_scale)
_ship_speed_factor = info.get("ship_speed_factor", 1.0) _ship_speed_factor = info.get("ship_speed_factor", 1.0)
max_energy = GameConfig.ship_max_energy_by_role.get(info.get("role", "Fighter"), 100.0) _role = info.get("role", "Fighter")
max_energy = GameConfig.ship_max_energy_by_role.get(_role, 100.0)
_refresh_nameplate()
set_physics_process(true) set_physics_process(true)
if multiplayer.is_server(): if multiplayer.is_server():
_server_respawn() _server_respawn()
# Bottom-right-of-ship name label — text/color only change on a loadout
# change (this ship's or the local viewer's own, see _on_loadout_updated()),
# not every frame; position/visibility instead track the ship every physics
# tick (see _physics_process()) since those change constantly.
func _refresh_nameplate() -> void:
if _nameplate == null:
return
var info := PlayerRegistry.get_info(peer_id)
var name_text: String = info.get("name", "")
if peer_id < 0:
name_text += " (b)" # bots -- see bots/bot_manager.gd's negative peer_id trick
_nameplate.text = name_text
var local_race: int = PlayerRegistry.get_info(PlayerRegistry.get_local_id()).get("race", 0)
var is_teammate: bool = info.get("race", 0) == local_race
_nameplate.add_theme_color_override("font_color", GameConfig.team_color if is_teammate else GameConfig.enemy_color)
# Sizes the hitbox to the actual sprite being flown, in the same scale-space
# as the visual (see call site above) — every ship previously shared one
# fixed-radius CircleShape2D (Godot's 10px default) regardless of its actual
# on-screen size, which is a fraction of any ship's real silhouette (ships
# render 40-110px tall depending on role). That left the nose and outer
# edges of every ship un-collidable, since only a tiny circle near the
# center actually registered hits. A CapsuleShape2D tracks a ship's own
# rotation for free (it's a child of this CharacterBody2D) and its
# height-along-local-Y shape matches this project's nose-up-at-rotation-0
# sprite convention (ship_movement.gd's Vector2.UP-based movement) far
# better than a circle for the elongated Fighter/Gunner hulls. Shrunk by
# GameConfig.ship_hitbox_scale off the full texture bounds since the source
# art has some anti-aliased/soft-edge margin outside the solid hull.
func _update_collision_shape(texture: Texture2D, total_scale: float) -> void:
var collision := get_node_or_null("CollisionShape2D") as CollisionShape2D
if collision == null:
return
var capsule := collision.shape as CapsuleShape2D
if capsule == null:
return
var size := texture.get_size() * total_scale * GameConfig.ship_hitbox_scale
capsule.radius = size.x / 2.0
capsule.height = maxf(size.y, size.x)
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
if _dead: if _dead:
return return
@@ -120,11 +206,44 @@ func _physics_process(delta: float) -> void:
else: else:
_remote_tick(delta) _remote_tick(delta)
if _nameplate:
_nameplate.visible = visible
_nameplate.global_position = global_position + GameConfig.ship_nameplate_offset
# Swaps the sprite between idle/flame textures based on whether the up/down
# thrust key is *currently held* — not current speed, since this game's
# movement has no drag and a released ship keeps drifting at speed, which
# would otherwise leave the flame on long after letting off the gas. The
# owner predicts this locally the instant it samples input (_owner_tick);
# the server does the same for bots/itself (_server_tick) and broadcasts its
# authoritative value in _receive_state so every other peer's view of a ship
# (which never sees raw input, only replicated state) stays in sync too.
func _set_thrust_sprite(active: bool) -> void:
if _flame_texture == null or active == _thrusting:
return
_thrusting = active
var sprite := get_node_or_null("Sprite2D") as Sprite2D
if sprite:
sprite.texture = _flame_texture if active else _idle_texture
# Purely local/visual -- called by any Explosion whose GhostArea this ship's
# (layer-2) hull overlaps, on every peer independently. Never touches the
# networked `visible` property (that's server-authoritative via
# _receive_state/_apply_death/_apply_respawn), just the sprite underneath it.
func set_ghosted(active: bool) -> void:
_ghost_count = maxi(0, _ghost_count + (1 if active else -1))
var sprite := get_node_or_null("Sprite2D") as Sprite2D
if sprite:
sprite.visible = _ghost_count == 0
# ── Owner client: predict locally, send input to server ───────────────────── # ── Owner client: predict locally, send input to server ─────────────────────
func _owner_tick(delta: float) -> void: func _owner_tick(delta: float) -> void:
var input := _sample_local_input() var input := _sample_local_input()
_set_thrust_sprite(input.get("up", false) or input.get("down", false))
_input_tick += 1 _input_tick += 1
_simulate_step(delta, input, true) _simulate_step(delta, input, true)
@@ -135,7 +254,7 @@ func _owner_tick(delta: float) -> void:
@rpc("authority", "call_remote", "unreliable") @rpc("authority", "call_remote", "unreliable")
func _receive_state(tick: int, pos: Vector2, rot: float, vel: Vector2, hp: int, nrg: float, shown: bool) -> void: func _receive_state(tick: int, pos: Vector2, rot: float, vel: Vector2, hp: int, nrg: float, shown: bool, thrusting: bool) -> void:
# Piggyback current health/energy AND visibility on every state broadcast # Piggyback current health/energy AND visibility on every state broadcast
# (not just the one-shot _apply_health/_apply_death/_apply_respawn RPCs) # (not just the one-shot _apply_health/_apply_death/_apply_respawn RPCs)
# so a peer that connects after another ship already spawned/respawned # so a peer that connects after another ship already spawned/respawned
@@ -158,6 +277,7 @@ func _receive_state(tick: int, pos: Vector2, rot: float, vel: Vector2, hp: int,
_interp_to_rot = rot _interp_to_rot = rot
_interp_elapsed = 0.0 _interp_elapsed = 0.0
velocity = vel velocity = vel
_set_thrust_sprite(thrusting)
func _reconcile(server_tick: int, server_pos: Vector2, server_rot: float, server_vel: Vector2) -> void: func _reconcile(server_tick: int, server_pos: Vector2, server_rot: float, server_vel: Vector2) -> void:
@@ -195,11 +315,17 @@ func _server_tick(delta: float) -> void:
_last_processed_tick = entry.tick _last_processed_tick = entry.tick
input = _last_remote_input input = _last_remote_input
# Updates this server's own local view of the sprite too (matters for a
# listen-server that's also rendering, e.g. hosting-and-playing) — for
# every other connected peer, _receive_state below is what carries this.
var thrusting: bool = input.get("up", false) or input.get("down", false)
_set_thrust_sprite(thrusting)
_simulate_step(delta, input, true) _simulate_step(delta, input, true)
energy = minf(max_energy, energy + GameConfig.ship_energy_regen_rate * delta) energy = minf(max_energy, energy + GameConfig.ship_energy_regen_rate * delta)
_server_process_shoot(delta, input) _server_process_shoot(delta, input)
_receive_state.rpc(_last_processed_tick, global_position, rotation, velocity, health, energy, visible) _receive_state.rpc(_last_processed_tick, global_position, rotation, velocity, health, energy, visible, thrusting)
@rpc("any_peer", "call_remote", "unreliable") @rpc("any_peer", "call_remote", "unreliable")
@@ -301,24 +427,32 @@ func _apply_collision_bounce(previous_velocity: Vector2, apply_side_effects: boo
# Server-only: decides whether this tick's queued input fires a shot, using # Server-only: decides whether this tick's queued input fires a shot, using
# its own authoritative cooldown (never trust a client's fire rate). Requests # its own authoritative cooldown (never trust a client's fire rate). Requests
# World spawn the bullet, which replicates it to every peer. # World spawn the bullet(s), which replicate to every peer. Damage/energy
# cost/bullet count all scale by role (Fighter fires 1 bullet, Gunner/Tank
# fire a side-by-side pair — see GameConfig.ship_bullet_count_by_role).
func _server_process_shoot(delta: float, input: Dictionary) -> void: func _server_process_shoot(delta: float, input: Dictionary) -> void:
_fire_cooldown -= delta _fire_cooldown -= delta
if input.get("shoot", false) and _fire_cooldown <= 0.0 and energy >= GameConfig.bullet_energy_cost: var cost: float = GameConfig.ship_bullet_energy_cost_by_role.get(_role, 75.0)
if input.get("shoot", false) and _fire_cooldown <= 0.0 and energy >= cost:
_fire_cooldown = GameConfig.ship_fire_rate _fire_cooldown = GameConfig.ship_fire_rate
energy -= GameConfig.bullet_energy_cost energy -= cost
var world := get_node_or_null("/root/World") var world := get_node_or_null("/root/World")
if world: if world:
world.request_bullet_spawn( var damage: int = GameConfig.ship_bullet_damage_by_role.get(_role, 100)
peer_id, var count: int = GameConfig.ship_bullet_count_by_role.get(_role, 1)
global_position + Vector2.UP.rotated(rotation) * 40.0, var forward := Vector2.UP.rotated(rotation)
Vector2.UP.rotated(rotation) * GameConfig.bullet_speed + velocity var right := Vector2.RIGHT.rotated(rotation)
) var base_pos := global_position + forward * 40.0
var bullet_vel := forward * GameConfig.bullet_speed + velocity
var spacing := GameConfig.bullet_side_spacing
var start := -spacing * (count - 1) / 2.0
for i in count:
world.request_bullet_spawn(peer_id, base_pos + right * (start + i * spacing), bullet_vel, damage)
# ── Health / death — server-authoritative, broadcast to every peer ────────── # ── Health / death — server-authoritative, broadcast to every peer ──────────
func take_damage(amount: int, source_peer_id: int = 0) -> void: func take_damage(amount: int, source_peer_id: int = 0, hit_pos: Vector2 = Vector2.ZERO) -> void:
if not multiplayer.is_server(): if not multiplayer.is_server():
return return
if _invincible or _dead: if _invincible or _dead:
@@ -326,6 +460,7 @@ func take_damage(amount: int, source_peer_id: int = 0) -> void:
_last_damage_source = source_peer_id _last_damage_source = source_peer_id
health = max(0, health - amount) health = max(0, health - amount)
_apply_health.rpc(health) _apply_health.rpc(health)
_notify_damage_dealt(source_peer_id, amount, hit_pos if hit_pos != Vector2.ZERO else global_position)
if health == 0: if health == 0:
_die() _die()
@@ -336,6 +471,34 @@ func _apply_health(new_health: int) -> void:
_update_hud() _update_hud()
# Floating damage-number feedback for the attacker only -- unlike the kill
# feed (autoload/kill_feed_manager.gd), this is per-shooter combat info, not
# match-wide, so it's a targeted rpc_id() rather than a broadcast .rpc().
# Bots (negative peer_id) and hazard/wall damage (peer_id 0, see
# _apply_collision_bounce()) have no client to show this to. The server's own
# peer id (when hosting-and-playing) takes the direct local-call path instead
# of rpc_id()'ing itself, same pattern as world.gd's decide_offered_races().
func _notify_damage_dealt(source_peer_id: int, amount: int, pos: Vector2) -> void:
if source_peer_id <= 0:
return
if source_peer_id == multiplayer.get_unique_id():
_spawn_damage_number(pos, amount)
else:
_show_damage_number.rpc_id(source_peer_id, amount, pos)
@rpc("authority", "call_remote", "reliable")
func _show_damage_number(amount: int, pos: Vector2) -> void:
_spawn_damage_number(pos, amount)
func _spawn_damage_number(pos: Vector2, amount: int) -> void:
var number := DAMAGE_NUMBER_SCENE.instantiate()
number.global_position = pos
number.amount = amount
get_parent().add_child(number)
func _die() -> void: func _die() -> void:
if not multiplayer.is_server(): if not multiplayer.is_server():
return return
@@ -351,15 +514,37 @@ func _apply_death() -> void:
visible = false visible = false
velocity = Vector2.ZERO velocity = Vector2.ZERO
_spawn_explosion() _spawn_explosion()
# Otherwise the invisible corpse -- still fully solid for the whole
# ship_respawn_delay window -- keeps blocking bullets fired "through" the
# explosion at its position, even though live ships already ghost through
# it visually (see effects/explosion.gd's GhostArea).
_set_collision_enabled(false)
# _physics_process()'s nameplate sync never runs while _dead, so hide it
# here explicitly instead of leaving it stuck at the death position.
if _nameplate:
_nameplate.visible = false
# Deferred since _apply_death/_apply_respawn can run from inside a bullet's
# body_entered physics callback -- same "Can't change state while flushing
# queries" constraint _spawn_explosion's call_deferred works around above.
func _set_collision_enabled(enabled: bool) -> void:
var collision := get_node_or_null("CollisionShape2D") as CollisionShape2D
if collision:
collision.set_deferred("disabled", not enabled)
# Purely cosmetic, so it's spawned locally by every peer off the already # Purely cosmetic, so it's spawned locally by every peer off the already
# broadcast _apply_death RPC above rather than needing its own networked # broadcast _apply_death RPC above rather than needing its own networked
# spawner (contrast bullets/ships, which are gameplay-authoritative). # spawner (contrast bullets/ships, which are gameplay-authoritative).
# call_deferred here since _apply_death (and this) can run from inside a
# bullet's body_entered physics callback -- adding a node with its own
# collider (the explosion's GhostArea) synchronously mid-physics-step trips
# Godot's "Can't change state while flushing queries" error.
func _spawn_explosion() -> void: func _spawn_explosion() -> void:
var explosion := EXPLOSION_SCENE.instantiate() var explosion := EXPLOSION_SCENE.instantiate()
explosion.global_position = global_position explosion.global_position = global_position
get_parent().add_child(explosion) get_parent().call_deferred("add_child", explosion)
func _server_respawn() -> void: func _server_respawn() -> void:
@@ -376,6 +561,7 @@ func _apply_respawn(pos: Vector2) -> void:
_dead = false _dead = false
_last_damage_source = 0 _last_damage_source = 0
visible = true visible = true
_set_collision_enabled(true)
_invincible = true _invincible = true
_invincible_timer = GameConfig.ship_invincibility_time _invincible_timer = GameConfig.ship_invincibility_time
@@ -418,12 +604,9 @@ func _team_spawn_group() -> String:
func _update_hud() -> void: func _update_hud() -> void:
if not _is_owner: if not _is_owner:
return return
var health_bar = get_node_or_null("/root/World/HUD/HealthBar") var health_bar = get_node_or_null("/root/World/HealthBar")
if health_bar and health_bar.has_method("set_fill"): if health_bar and health_bar.has_method("set_fill"):
health_bar.set_fill(float(health) / GameConfig.ship_max_health if GameConfig.ship_max_health > 0 else 0.0) health_bar.set_fill(float(health) / GameConfig.ship_max_health if GameConfig.ship_max_health > 0 else 0.0)
var hud_energy_bar = get_node_or_null("/root/World/HUD/EnergyBar")
if hud_energy_bar and hud_energy_bar.has_method("set_fill"):
hud_energy_bar.set_fill(energy / max_energy if max_energy > 0.0 else 0.0)
var energy_bar = get_node_or_null("/root/World/EnergyBar") var energy_bar = get_node_or_null("/root/World/EnergyBar")
if energy_bar and energy_bar.has_method("set_fill"): if energy_bar and energy_bar.has_method("set_fill"):
energy_bar.set_fill(energy / max_energy if max_energy > 0.0 else 0.0) energy_bar.set_fill(energy / max_energy if max_energy > 0.0 else 0.0)
+31 -2
View File
@@ -78,6 +78,12 @@ func spawn_peer(peer_id: int) -> Node:
func despawn_peer(peer_id: int) -> void: func despawn_peer(peer_id: int) -> void:
var ship := _players.get_node_or_null(str(peer_id)) var ship := _players.get_node_or_null(str(peer_id))
if ship: if ship:
# Stops _server_tick() from emitting any further _receive_state
# broadcasts for this ship the instant despawn is requested — without
# this, a state packet already in flight on the unreliable channel can
# still reach a client after that client has already processed this
# node's despawn replication, causing a "Node not found" RPC error.
ship.set_physics_process(false)
ship.queue_free() ship.queue_free()
@@ -90,10 +96,12 @@ func _spawn_ship(peer_id: int) -> Node:
# Called by a Ship node running on the server when its authoritative fire # Called by a Ship node running on the server when its authoritative fire
# cooldown allows a shot. Replicated to every peer via the bullet spawner. # cooldown allows a shot. Replicated to every peer via the bullet spawner.
func request_bullet_spawn(source_peer_id: int, pos: Vector2, vel: Vector2) -> void: # damage is decided by the firing ship's role (see
# ship_movement.gd's _server_process_shoot()), not a flat global.
func request_bullet_spawn(source_peer_id: int, pos: Vector2, vel: Vector2, damage: int) -> void:
if not multiplayer.is_server(): if not multiplayer.is_server():
return return
_bullet_spawner.spawn({"peer_id": source_peer_id, "pos": pos, "vel": vel}) _bullet_spawner.spawn({"peer_id": source_peer_id, "pos": pos, "vel": vel, "damage": damage})
func _spawn_bullet(data: Dictionary) -> Node: func _spawn_bullet(data: Dictionary) -> Node:
@@ -101,9 +109,30 @@ func _spawn_bullet(data: Dictionary) -> Node:
bullet.global_position = data.pos bullet.global_position = data.pos
bullet.velocity = data.vel bullet.velocity = data.vel
bullet.source_peer_id = data.peer_id bullet.source_peer_id = data.peer_id
bullet.damage = data.damage
_play_shoot_sound(data.peer_id, data.pos)
return bullet return bullet
# Runs once per peer (this spawn_function itself is what MultiplayerSpawner
# replicates, so every client fires this locally, not just the server) —
# each peer's own ship's Camera2D is that peer's audio listener, so
# AudioStreamPlayer2D's max_distance naturally limits who hears the shot to
# ships within GameConfig.ship_shoot_sound_max_distance, with no need to
# track which peers are "in range" server-side.
func _play_shoot_sound(source_peer_id: int, pos: Vector2) -> void:
var sound_path: String = PlayerRegistry.get_info(source_peer_id).get("ship_sound_path", "")
if sound_path.is_empty():
return
var player := AudioStreamPlayer2D.new()
player.stream = load(sound_path)
player.global_position = pos
player.max_distance = GameConfig.ship_shoot_sound_max_distance
player.finished.connect(player.queue_free)
add_child(player)
player.play()
func _on_peer_joined(peer_id: int) -> void: func _on_peer_joined(peer_id: int) -> void:
if multiplayer.is_server(): if multiplayer.is_server():
spawn_peer(peer_id) spawn_peer(peer_id)
+6 -19
View File
@@ -9,7 +9,6 @@
[ext_resource type="PackedScene" uid="uid://cminimap0001" path="res://hud/mini_map.tscn" id="8_mmap"] [ext_resource type="PackedScene" uid="uid://cminimap0001" path="res://hud/mini_map.tscn" id="8_mmap"]
[ext_resource type="PackedScene" path="res://hud/ping_display.tscn" id="9_ping"] [ext_resource type="PackedScene" path="res://hud/ping_display.tscn" id="9_ping"]
[ext_resource type="PackedScene" path="res://hud/energy_bar.tscn" id="10_ebar"] [ext_resource type="PackedScene" path="res://hud/energy_bar.tscn" id="10_ebar"]
[ext_resource type="Script" path="res://hud/stat_bar.gd" id="11_statbar"]
[node name="World" type="Node2D" unique_id=1962020789] [node name="World" type="Node2D" unique_id=1962020789]
script = ExtResource("4_world") script = ExtResource("4_world")
@@ -26,24 +25,6 @@ spawn_path = NodePath("../Players")
[node name="BulletSpawner" type="MultiplayerSpawner" parent="."] [node name="BulletSpawner" type="MultiplayerSpawner" parent="."]
spawn_path = NodePath("../Bullets") spawn_path = NodePath("../Bullets")
[node name="HUD" type="CanvasLayer" parent="."]
[node name="HealthBar" type="Control" parent="HUD"]
offset_left = 20.0
offset_top = 20.0
offset_right = 300.0
offset_bottom = 44.0
script = ExtResource("11_statbar")
fill_color = Color(0.3, 0.85, 0.35, 0.95)
[node name="EnergyBar" type="Control" parent="HUD"]
offset_left = 20.0
offset_top = 50.0
offset_right = 300.0
offset_bottom = 74.0
script = ExtResource("11_statbar")
fill_color = Color(0.25, 0.55, 1.0, 0.95)
[node name="PauseMenu" parent="." instance=ExtResource("2_pm")] [node name="PauseMenu" parent="." instance=ExtResource("2_pm")]
[node name="TeamSelect" parent="." instance=ExtResource("3_ts")] [node name="TeamSelect" parent="." instance=ExtResource("3_ts")]
@@ -58,4 +39,10 @@ fill_color = Color(0.25, 0.55, 1.0, 0.95)
[node name="PingDisplay" parent="." instance=ExtResource("9_ping")] [node name="PingDisplay" parent="." instance=ExtResource("9_ping")]
[node name="HealthBar" parent="." instance=ExtResource("10_ebar")]
margin_top = 20.0
fill_color = Color(0.3, 0.85, 0.35, 0.95)
[node name="EnergyBar" parent="." instance=ExtResource("10_ebar")] [node name="EnergyBar" parent="." instance=ExtResource("10_ebar")]
margin_top = 40.0
fill_color = Color(0.25, 0.55, 1.0, 0.95)