diff --git a/CLAUDE.md b/CLAUDE.md index 6e05d41..c3661c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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"`. 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 - [ ] Asteroids and environment hazards diff --git a/spacewar/assets/images/banners/70fb9114-c6c8-459c-ad2f-8111f30c9b82.jpeg b/spacewar/assets/images/banners/70fb9114-c6c8-459c-ad2f-8111f30c9b82.jpeg new file mode 100644 index 0000000..e831e23 Binary files /dev/null and b/spacewar/assets/images/banners/70fb9114-c6c8-459c-ad2f-8111f30c9b82.jpeg differ diff --git a/spacewar/assets/images/banners/70fb9114-c6c8-459c-ad2f-8111f30c9b82.jpeg.import b/spacewar/assets/images/banners/70fb9114-c6c8-459c-ad2f-8111f30c9b82.jpeg.import new file mode 100644 index 0000000..4c4722b --- /dev/null +++ b/spacewar/assets/images/banners/70fb9114-c6c8-459c-ad2f-8111f30c9b82.jpeg.import @@ -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 diff --git a/spacewar/assets/images/banners/ed212cd5-530a-4520-ba00-23795b40f32c.jpeg b/spacewar/assets/images/banners/ed212cd5-530a-4520-ba00-23795b40f32c.jpeg new file mode 100644 index 0000000..5cb2ed4 Binary files /dev/null and b/spacewar/assets/images/banners/ed212cd5-530a-4520-ba00-23795b40f32c.jpeg differ diff --git a/spacewar/assets/images/banners/ed212cd5-530a-4520-ba00-23795b40f32c.jpeg.import b/spacewar/assets/images/banners/ed212cd5-530a-4520-ba00-23795b40f32c.jpeg.import new file mode 100644 index 0000000..569c1fd --- /dev/null +++ b/spacewar/assets/images/banners/ed212cd5-530a-4520-ba00-23795b40f32c.jpeg.import @@ -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 diff --git a/spacewar/assets/images/banners/f6790c6b-65b5-4701-ad12-75db071f4969.jpeg b/spacewar/assets/images/banners/f6790c6b-65b5-4701-ad12-75db071f4969.jpeg new file mode 100644 index 0000000..4e1edb7 Binary files /dev/null and b/spacewar/assets/images/banners/f6790c6b-65b5-4701-ad12-75db071f4969.jpeg differ diff --git a/spacewar/assets/images/banners/f6790c6b-65b5-4701-ad12-75db071f4969.jpeg.import b/spacewar/assets/images/banners/f6790c6b-65b5-4701-ad12-75db071f4969.jpeg.import new file mode 100644 index 0000000..3cdf5f1 --- /dev/null +++ b/spacewar/assets/images/banners/f6790c6b-65b5-4701-ad12-75db071f4969.jpeg.import @@ -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 diff --git a/spacewar/assets/images/effects/explosion/explosion_01.png b/spacewar/assets/images/effects/explosion/explosion_01.png index f7ed840..340824f 100644 Binary files a/spacewar/assets/images/effects/explosion/explosion_01.png and b/spacewar/assets/images/effects/explosion/explosion_01.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_02.png b/spacewar/assets/images/effects/explosion/explosion_02.png index 08103eb..5ce8230 100644 Binary files a/spacewar/assets/images/effects/explosion/explosion_02.png and b/spacewar/assets/images/effects/explosion/explosion_02.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_03.png b/spacewar/assets/images/effects/explosion/explosion_03.png index ea8484e..08f05ac 100644 Binary files a/spacewar/assets/images/effects/explosion/explosion_03.png and b/spacewar/assets/images/effects/explosion/explosion_03.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_04.png b/spacewar/assets/images/effects/explosion/explosion_04.png index be13993..d43b3c6 100644 Binary files a/spacewar/assets/images/effects/explosion/explosion_04.png and b/spacewar/assets/images/effects/explosion/explosion_04.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_05.png b/spacewar/assets/images/effects/explosion/explosion_05.png index db1c207..26d12fa 100644 Binary files a/spacewar/assets/images/effects/explosion/explosion_05.png and b/spacewar/assets/images/effects/explosion/explosion_05.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_06.png b/spacewar/assets/images/effects/explosion/explosion_06.png index 02f3c23..68a6ffa 100644 Binary files a/spacewar/assets/images/effects/explosion/explosion_06.png and b/spacewar/assets/images/effects/explosion/explosion_06.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_07.png b/spacewar/assets/images/effects/explosion/explosion_07.png index a8d5f49..d64d75d 100644 Binary files a/spacewar/assets/images/effects/explosion/explosion_07.png and b/spacewar/assets/images/effects/explosion/explosion_07.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_08.png b/spacewar/assets/images/effects/explosion/explosion_08.png index e9b8441..06bd999 100644 Binary files a/spacewar/assets/images/effects/explosion/explosion_08.png and b/spacewar/assets/images/effects/explosion/explosion_08.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_09.png b/spacewar/assets/images/effects/explosion/explosion_09.png index 44cc6ea..d00ebda 100644 Binary files a/spacewar/assets/images/effects/explosion/explosion_09.png and b/spacewar/assets/images/effects/explosion/explosion_09.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_10.png b/spacewar/assets/images/effects/explosion/explosion_10.png index 33dad11..dbfac78 100644 Binary files a/spacewar/assets/images/effects/explosion/explosion_10.png and b/spacewar/assets/images/effects/explosion/explosion_10.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_11.png b/spacewar/assets/images/effects/explosion/explosion_11.png index a725f1b..a399edb 100644 Binary files a/spacewar/assets/images/effects/explosion/explosion_11.png and b/spacewar/assets/images/effects/explosion/explosion_11.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_12.png b/spacewar/assets/images/effects/explosion/explosion_12.png index dad13c7..aa77217 100644 Binary files a/spacewar/assets/images/effects/explosion/explosion_12.png and b/spacewar/assets/images/effects/explosion/explosion_12.png differ diff --git a/spacewar/assets/images/ships/isn/barrage.png b/spacewar/assets/images/ships/isn/barrage.png index 7875444..ad44d7a 100644 Binary files a/spacewar/assets/images/ships/isn/barrage.png and b/spacewar/assets/images/ships/isn/barrage.png differ diff --git a/spacewar/assets/images/ships/isn/barrage_flame.png b/spacewar/assets/images/ships/isn/barrage_flame.png new file mode 100644 index 0000000..ee49041 Binary files /dev/null and b/spacewar/assets/images/ships/isn/barrage_flame.png differ diff --git a/spacewar/assets/images/ships/isn/barrage_flame.png.import b/spacewar/assets/images/ships/isn/barrage_flame.png.import new file mode 100644 index 0000000..231b4ac --- /dev/null +++ b/spacewar/assets/images/ships/isn/barrage_flame.png.import @@ -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 diff --git a/spacewar/assets/images/ships/isn/behemoth.png b/spacewar/assets/images/ships/isn/behemoth.png index dac35e7..ca4af5a 100644 Binary files a/spacewar/assets/images/ships/isn/behemoth.png and b/spacewar/assets/images/ships/isn/behemoth.png differ diff --git a/spacewar/assets/images/ships/isn/behemoth_flame.png b/spacewar/assets/images/ships/isn/behemoth_flame.png new file mode 100644 index 0000000..9765d73 Binary files /dev/null and b/spacewar/assets/images/ships/isn/behemoth_flame.png differ diff --git a/spacewar/assets/images/ships/isn/behemoth_flame.png.import b/spacewar/assets/images/ships/isn/behemoth_flame.png.import new file mode 100644 index 0000000..36951fe --- /dev/null +++ b/spacewar/assets/images/ships/isn/behemoth_flame.png.import @@ -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 diff --git a/spacewar/assets/images/ships/isn/patriot.png b/spacewar/assets/images/ships/isn/patriot.png index dcbdae5..1182cdc 100644 Binary files a/spacewar/assets/images/ships/isn/patriot.png and b/spacewar/assets/images/ships/isn/patriot.png differ diff --git a/spacewar/assets/images/ships/isn/patriot_flame.png b/spacewar/assets/images/ships/isn/patriot_flame.png new file mode 100644 index 0000000..083845e Binary files /dev/null and b/spacewar/assets/images/ships/isn/patriot_flame.png differ diff --git a/spacewar/assets/images/ships/isn/patriot_flame.png.import b/spacewar/assets/images/ships/isn/patriot_flame.png.import new file mode 100644 index 0000000..e112c7d --- /dev/null +++ b/spacewar/assets/images/ships/isn/patriot_flame.png.import @@ -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 diff --git a/spacewar/assets/images/ships/orc/iron_clad_flame.png b/spacewar/assets/images/ships/orc/iron_clad_flame.png new file mode 100644 index 0000000..955e72e Binary files /dev/null and b/spacewar/assets/images/ships/orc/iron_clad_flame.png differ diff --git a/spacewar/assets/images/ships/orc/iron_clad_flame.png.import b/spacewar/assets/images/ships/orc/iron_clad_flame.png.import new file mode 100644 index 0000000..ad7b976 --- /dev/null +++ b/spacewar/assets/images/ships/orc/iron_clad_flame.png.import @@ -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 diff --git a/spacewar/assets/images/ships/orc/rail_jack_flame.png b/spacewar/assets/images/ships/orc/rail_jack_flame.png new file mode 100644 index 0000000..5353ac5 Binary files /dev/null and b/spacewar/assets/images/ships/orc/rail_jack_flame.png differ diff --git a/spacewar/assets/images/ships/orc/rail_jack_flame.png.import b/spacewar/assets/images/ships/orc/rail_jack_flame.png.import new file mode 100644 index 0000000..c7c1ea9 --- /dev/null +++ b/spacewar/assets/images/ships/orc/rail_jack_flame.png.import @@ -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 diff --git a/spacewar/assets/images/ships/orc/scrap_spitter_flame.png b/spacewar/assets/images/ships/orc/scrap_spitter_flame.png new file mode 100644 index 0000000..fd06599 Binary files /dev/null and b/spacewar/assets/images/ships/orc/scrap_spitter_flame.png differ diff --git a/spacewar/assets/images/ships/orc/scrap_spitter_flame.png.import b/spacewar/assets/images/ships/orc/scrap_spitter_flame.png.import new file mode 100644 index 0000000..d29b3f4 --- /dev/null +++ b/spacewar/assets/images/ships/orc/scrap_spitter_flame.png.import @@ -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 diff --git a/spacewar/assets/sound/menu/MyVeryOwnDeadShip.mmpz b/spacewar/assets/sound/menu/MyVeryOwnDeadShip.mmpz new file mode 100644 index 0000000..98cc15b Binary files /dev/null and b/spacewar/assets/sound/menu/MyVeryOwnDeadShip.mmpz differ diff --git a/spacewar/assets/sound/menu/MyVeryOwnDeadShip.ogg b/spacewar/assets/sound/menu/MyVeryOwnDeadShip.ogg new file mode 100644 index 0000000..bcffe4f Binary files /dev/null and b/spacewar/assets/sound/menu/MyVeryOwnDeadShip.ogg differ diff --git a/spacewar/assets/sound/menu/MyVeryOwnDeadShip.ogg.import b/spacewar/assets/sound/menu/MyVeryOwnDeadShip.ogg.import new file mode 100644 index 0000000..5f8a07b --- /dev/null +++ b/spacewar/assets/sound/menu/MyVeryOwnDeadShip.ogg.import @@ -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 diff --git a/spacewar/assets/sound/ships/apex/apex_1.wav b/spacewar/assets/sound/ships/apex/apex_1.wav new file mode 100644 index 0000000..7d6fc0e Binary files /dev/null and b/spacewar/assets/sound/ships/apex/apex_1.wav differ diff --git a/spacewar/assets/sound/ships/apex/apex_1.wav.import b/spacewar/assets/sound/ships/apex/apex_1.wav.import new file mode 100644 index 0000000..242f150 --- /dev/null +++ b/spacewar/assets/sound/ships/apex/apex_1.wav.import @@ -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 diff --git a/spacewar/assets/sound/ships/isn/isn_1.wav b/spacewar/assets/sound/ships/isn/isn_1.wav new file mode 100644 index 0000000..0b85efb Binary files /dev/null and b/spacewar/assets/sound/ships/isn/isn_1.wav differ diff --git a/spacewar/assets/sound/ships/isn/isn_1.wav.import b/spacewar/assets/sound/ships/isn/isn_1.wav.import new file mode 100644 index 0000000..1b0ab6f --- /dev/null +++ b/spacewar/assets/sound/ships/isn/isn_1.wav.import @@ -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 diff --git a/spacewar/assets/sound/ships/orc/orc_1.wav b/spacewar/assets/sound/ships/orc/orc_1.wav new file mode 100644 index 0000000..5e4ea27 Binary files /dev/null and b/spacewar/assets/sound/ships/orc/orc_1.wav differ diff --git a/spacewar/assets/sound/ships/orc/orc_1.wav.import b/spacewar/assets/sound/ships/orc/orc_1.wav.import new file mode 100644 index 0000000..9961777 --- /dev/null +++ b/spacewar/assets/sound/ships/orc/orc_1.wav.import @@ -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 diff --git a/spacewar/autoload/game_config.gd b/spacewar/autoload/game_config.gd index 5a90df2..45fab6b 100644 --- a/spacewar/autoload/game_config.gd +++ b/spacewar/autoload/game_config.gd @@ -10,22 +10,34 @@ var ship_rotation_speed: float = 1.0 var ship_rotation_ramp_delay: float = 0.15 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 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 var ship_max_health: int = 100 var ship_respawn_delay: float = 3.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 # (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 # top off from empty than smaller ones. 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 # 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. 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 in the game at once; explosion_fps controls playback speed of the # 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_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. # Race/ship/speed are per-peer now — see PlayerRegistry. var player_name: String = "" diff --git a/spacewar/autoload/music_manager.gd b/spacewar/autoload/music_manager.gd new file mode 100644 index 0000000..2b27481 --- /dev/null +++ b/spacewar/autoload/music_manager.gd @@ -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) diff --git a/spacewar/autoload/music_manager.gd.uid b/spacewar/autoload/music_manager.gd.uid new file mode 100644 index 0000000..73e673b --- /dev/null +++ b/spacewar/autoload/music_manager.gd.uid @@ -0,0 +1 @@ +uid://b3igqhi4jnw8h diff --git a/spacewar/autoload/player_registry.gd b/spacewar/autoload/player_registry.gd index 6fedbbc..5418731 100644 --- a/spacewar/autoload/player_registry.gd +++ b/spacewar/autoload/player_registry.gd @@ -17,7 +17,7 @@ signal player_joined(peer_id: int) # race is already overwritten in `players`. 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: @@ -32,18 +32,18 @@ func get_info(peer_id: int) -> Dictionary: 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: - submit_loadout.rpc(player_name, race, ship_path, ship_scale, ship_speed_factor, role) +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, ship_flame_path, ship_sound_path) # 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 # learn), broadcasting to every connected peer so bots render identically to # 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(): - _receive_loadout.rpc_id(pid, 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) + _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, ship_flame_path, ship_sound_path) 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 # else's already-known loadout. @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() if sender_id == 0: sender_id = multiplayer.get_unique_id() 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 for peer_id in multiplayer.get_peers(): 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: if peer_id != sender_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") -func _receive_loadout(peer_id: int, player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String) -> void: - _store(peer_id, player_name, race, ship_path, ship_scale, ship_speed_factor, role) +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, 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) players[peer_id] = { "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_speed_factor": ship_speed_factor, "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]]) loadout_updated.emit(peer_id) diff --git a/spacewar/bots/bot_manager.gd b/spacewar/bots/bot_manager.gd index 3a5215a..57f1f28 100644 --- a/spacewar/bots/bot_manager.gd +++ b/spacewar/bots/bot_manager.gd @@ -62,7 +62,7 @@ func _reconcile_race(race_id: int) -> void: var desired: int = max(0, GameConfig.bot_min_team_size - humans) var current: Array = _bots_by_race.get(race_id, []) while current.size() < desired: - current.append(_spawn_bot(race_id)) + current.append(_spawn_bot(race_id, current.size())) while current.size() > desired: _despawn_bot(current.pop_back()) _bots_by_race[race_id] = current @@ -76,15 +76,16 @@ func _count_humans(race_id: int) -> int: 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 _next_bot_id -= 1 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( 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 @@ -93,6 +94,27 @@ func _spawn_bot(race_id: int) -> int: 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: _world.despawn_peer(bot_id) PlayerRegistry.unregister_bot(bot_id) diff --git a/spacewar/effects/damage_number.gd b/spacewar/effects/damage_number.gd new file mode 100644 index 0000000..fab2748 --- /dev/null +++ b/spacewar/effects/damage_number.gd @@ -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) diff --git a/spacewar/effects/damage_number.gd.uid b/spacewar/effects/damage_number.gd.uid new file mode 100644 index 0000000..368689c --- /dev/null +++ b/spacewar/effects/damage_number.gd.uid @@ -0,0 +1 @@ +uid://cj8fpi6biykj1 diff --git a/spacewar/effects/damage_number.tscn b/spacewar/effects/damage_number.tscn new file mode 100644 index 0000000..f62dd28 --- /dev/null +++ b/spacewar/effects/damage_number.tscn @@ -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") diff --git a/spacewar/effects/explosion.gd b/spacewar/effects/explosion.gd index 4bd338c..1c7b1f4 100644 --- a/spacewar/effects/explosion.gd +++ b/spacewar/effects/explosion.gd @@ -5,6 +5,14 @@ class_name Explosion # one shared knob for resizing every explosion in the game at once). @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: 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) play("explode") 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) diff --git a/spacewar/effects/explosion.tscn b/spacewar/effects/explosion.tscn index 52469f4..53cf3ed 100644 --- a/spacewar/effects/explosion.tscn +++ b/spacewar/effects/explosion.tscn @@ -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_12.png" id="13_expl12"] +[sub_resource type="CircleShape2D" id="CircleShape2D_expl01"] +radius = 45.0 + [sub_resource type="SpriteFrames" id="SpriteFrames_expl01"] animations = [{ "frames": [{ @@ -63,3 +66,10 @@ sprite_frames = SubResource("SpriteFrames_expl01") animation = &"explode" autoplay = "" 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") diff --git a/spacewar/hud/energy_bar.gd b/spacewar/hud/energy_bar.gd index 6a895f6..f3346cf 100644 --- a/spacewar/hud/energy_bar.gd +++ b/spacewar/hud/energy_bar.gd @@ -1,16 +1,20 @@ 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 -# both edges as energy drains, instead of draining left-to-right like a -# normal bar. Driven by Ship._update_hud() calling set_fill() with the local -# player's energy/max_energy fraction (see ships/ship_movement.gd). +# 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 a +# 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_HEIGHT := 14.0 -const MARGIN_TOP := 20.0 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 @@ -29,8 +33,8 @@ func _build_ui() -> void: container.anchor_bottom = 0.0 container.offset_left = -BAR_WIDTH / 2.0 container.offset_right = BAR_WIDTH / 2.0 - container.offset_top = MARGIN_TOP - container.offset_bottom = MARGIN_TOP + BAR_HEIGHT + container.offset_top = margin_top + container.offset_bottom = margin_top + BAR_HEIGHT container.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(container) @@ -44,7 +48,7 @@ func _build_ui() -> void: container.add_child(bg) _fill = ColorRect.new() - _fill.color = COLOR_FILL + _fill.color = fill_color _fill.mouse_filter = Control.MOUSE_FILTER_IGNORE container.add_child(_fill) diff --git a/spacewar/hud/energy_bar.gd.uid b/spacewar/hud/energy_bar.gd.uid new file mode 100644 index 0000000..e4ac616 --- /dev/null +++ b/spacewar/hud/energy_bar.gd.uid @@ -0,0 +1 @@ +uid://dx6kcvxxca00e diff --git a/spacewar/hud/player_list.gd b/spacewar/hud/player_list.gd index bd3be61..0ba5a33 100644 --- a/spacewar/hud/player_list.gd +++ b/spacewar/hud/player_list.gd @@ -2,18 +2,15 @@ extends CanvasLayer # 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 -# 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 # PlayerRegistry's roster changes rather than polled every frame. 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 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 @@ -85,7 +82,7 @@ func _refresh() -> void: for peer_id in peer_ids: var info: Dictionary = PlayerRegistry.players[peer_id] 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", ""))) if peer_id < 0: name_text += " (b)" diff --git a/spacewar/hud/stat_bar.gd b/spacewar/hud/stat_bar.gd deleted file mode 100644 index 4ac887e..0000000 --- a/spacewar/hud/stat_bar.gd +++ /dev/null @@ -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 diff --git a/spacewar/menu/main_menu.gd b/spacewar/menu/main_menu.gd index bcebe72..013150c 100644 --- a/spacewar/menu/main_menu.gd +++ b/spacewar/menu/main_menu.gd @@ -36,6 +36,7 @@ const NAV_GAP := 4.0 func _ready() -> void: _build_ui() + MusicManager.play_menu_music() NetworkManager.connection_succeeded.connect(_on_connected) NetworkManager.connection_failed.connect(_on_connect_failed) MatchmakingClient.match_found.connect(_on_match_found) diff --git a/spacewar/menu/server_select.gd b/spacewar/menu/server_select.gd index a6bac83..08c09b1 100644 --- a/spacewar/menu/server_select.gd +++ b/spacewar/menu/server_select.gd @@ -37,6 +37,7 @@ var _ping_probes: Array = [] # {udp: PacketPeerUDP, start_msec: int, done: bool func _ready() -> void: _build_ui() + MusicManager.play_menu_music() NetworkManager.connection_succeeded.connect(_on_connected) NetworkManager.connection_failed.connect(_on_connect_failed) _refresh_servers() diff --git a/spacewar/menu/team_select.gd b/spacewar/menu/team_select.gd index 2eba1e8..3895270 100644 --- a/spacewar/menu/team_select.gd +++ b/spacewar/menu/team_select.gd @@ -18,16 +18,19 @@ const RACES := [ "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.", "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", "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, + "sound_path": "res://assets/sound/ships/apex/apex_1.wav", }, { "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.", "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", "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, + "sound_path": "res://assets/sound/ships/isn/isn_1.wav", + "flame_path": "res://assets/images/ships/isn/patriot_flame.png", }, { "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.", "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", "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, + "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", "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, + "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", "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, + "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", "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, + "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: 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 _close() diff --git a/spacewar/project.godot b/spacewar/project.godot index b654e71..d50dd06 100644 --- a/spacewar/project.godot +++ b/spacewar/project.godot @@ -24,13 +24,13 @@ MatchmakingClient="*res://autoload/matchmaking_client.gd" ChatManager="*res://autoload/chat_manager.gd" KillFeedManager="*res://autoload/kill_feed_manager.gd" BotManager="*res://bots/bot_manager.gd" +MusicManager="*res://autoload/music_manager.gd" [display] window/size/viewport_width=1280 window/size/viewport_height=800 window/size/mode=3 -window/stretch/mode="disabled" [input] diff --git a/spacewar/ships/bullet.gd b/spacewar/ships/bullet.gd index cf4fe06..55f6d43 100644 --- a/spacewar/ships/bullet.gd +++ b/spacewar/ships/bullet.gd @@ -3,12 +3,16 @@ extends Area2D # Set by World._spawn_bullet() before this node enters the tree. var velocity: Vector2 = Vector2.ZERO 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 # every client automatically since bullets are spawned via BulletSpawner. # Movement itself stays local on every peer (deterministic constant-velocity # simulation from the replicated spawn state, no further sync needed). func _ready() -> void: + _spawn_pos = global_position if multiplayer.is_server(): body_entered.connect(_on_body_entered) @@ -18,10 +22,21 @@ func _process(delta: float) -> void: var bounds = GameConfig.world_bounds.grow(64.0) if not bounds.has_point(global_position): queue_free() + elif global_position.distance_to(_spawn_pos) > GameConfig.bullet_max_range: + queue_free() 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 - if body.has_method("take_damage"): - body.take_damage(GameConfig.bullet_damage, source_peer_id) + # Friendly fire is blocked, not ignored -- the bullet still stops dead on + # 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() diff --git a/spacewar/ships/bullet.tscn b/spacewar/ships/bullet.tscn index 5e2671a..c9e708e 100644 --- a/spacewar/ships/bullet.tscn +++ b/spacewar/ships/bullet.tscn @@ -6,6 +6,8 @@ radius = 4.0 [node name="Bullet" type="Area2D"] +collision_layer = 0 +collision_mask = 3 script = ExtResource("1") [node name="CollisionShape2D" type="CollisionShape2D" parent="."] diff --git a/spacewar/ships/ship.tscn b/spacewar/ships/ship.tscn index f43cb5b..7b8c812 100644 --- a/spacewar/ships/ship.tscn +++ b/spacewar/ships/ship.tscn @@ -1,19 +1,21 @@ [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="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] +collision_layer = 2 +collision_mask = 1 script = ExtResource("1_0e48y") [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] scale = Vector2(0.229, 0.229) -texture = ExtResource("2_epypp") [node name="VisibleOnScreenNotifier2D" type="VisibleOnScreenNotifier2D" parent="." unique_id=1504818491] @@ -22,3 +24,6 @@ current = false rotating = false position_smoothing_enabled = true position_smoothing_speed = 8.0 + +[node name="NamePlate" type="Label" parent="."] +top_level = true diff --git a/spacewar/ships/ship_movement.gd b/spacewar/ships/ship_movement.gd index 0bd1e05..6b25b0b 100644 --- a/spacewar/ships/ship_movement.gd +++ b/spacewar/ships/ship_movement.gd @@ -2,6 +2,7 @@ extends CharacterBody2D class_name Ship 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. var peer_id: int = 0 @@ -19,6 +20,7 @@ var _turn_hold_time: float = 0.0 var health: int = 0 var energy: float = 0.0 var max_energy: float = 100.0 +var _role: String = "Fighter" var _dead: bool = false # 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 _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 _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, # replayed on top of the server's authoritative state on correction. const MAX_PENDING_INPUTS := 180 @@ -59,6 +77,18 @@ func _ready() -> void: _is_owner = peer_id == multiplayer.get_unique_id() 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 # enters the SceneTree first auto-claims the viewport's active camera # regardless of its own `current` value (Godot's built-in "first camera @@ -71,6 +101,7 @@ func _ready() -> void: if _is_owner: var camera := get_node_or_null("Camera2D") as Camera2D if camera: + camera.zoom = Vector2.ONE * GameConfig.camera_zoom camera.call_deferred("make_current") PlayerRegistry.loadout_updated.connect(_on_loadout_updated) @@ -85,21 +116,76 @@ func _ready() -> void: func _on_loadout_updated(updated_peer_id: int) -> void: if updated_peer_id == 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: - var sprite := get_node_or_null("Sprite2D") + var sprite := get_node_or_null("Sprite2D") as Sprite2D var ship_path: String = info.get("ship_path", "") if sprite and not ship_path.is_empty(): - sprite.texture = load(ship_path) - sprite.scale = Vector2.ONE * info.get("ship_scale", 1.0) * GameConfig.ship_scale_factor + _idle_texture = load(ship_path) + 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) - 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) if multiplayer.is_server(): _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: if _dead: return @@ -120,11 +206,44 @@ func _physics_process(delta: float) -> void: else: _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 ───────────────────── func _owner_tick(delta: float) -> void: var input := _sample_local_input() + _set_thrust_sprite(input.get("up", false) or input.get("down", false)) _input_tick += 1 _simulate_step(delta, input, true) @@ -135,7 +254,7 @@ func _owner_tick(delta: float) -> void: @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 # (not just the one-shot _apply_health/_apply_death/_apply_respawn RPCs) # 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_elapsed = 0.0 velocity = vel + _set_thrust_sprite(thrusting) 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 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) energy = minf(max_energy, energy + GameConfig.ship_energy_regen_rate * delta) _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") @@ -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 # 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: _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 - energy -= GameConfig.bullet_energy_cost + energy -= cost var world := get_node_or_null("/root/World") if world: - world.request_bullet_spawn( - peer_id, - global_position + Vector2.UP.rotated(rotation) * 40.0, - Vector2.UP.rotated(rotation) * GameConfig.bullet_speed + velocity - ) + var damage: int = GameConfig.ship_bullet_damage_by_role.get(_role, 100) + var count: int = GameConfig.ship_bullet_count_by_role.get(_role, 1) + var forward := Vector2.UP.rotated(rotation) + 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 ────────── -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(): return 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 health = max(0, health - amount) _apply_health.rpc(health) + _notify_damage_dealt(source_peer_id, amount, hit_pos if hit_pos != Vector2.ZERO else global_position) if health == 0: _die() @@ -336,6 +471,34 @@ func _apply_health(new_health: int) -> void: _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: if not multiplayer.is_server(): return @@ -351,15 +514,37 @@ func _apply_death() -> void: visible = false velocity = Vector2.ZERO _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 # broadcast _apply_death RPC above rather than needing its own networked # 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: var explosion := EXPLOSION_SCENE.instantiate() explosion.global_position = global_position - get_parent().add_child(explosion) + get_parent().call_deferred("add_child", explosion) func _server_respawn() -> void: @@ -376,6 +561,7 @@ func _apply_respawn(pos: Vector2) -> void: _dead = false _last_damage_source = 0 visible = true + _set_collision_enabled(true) _invincible = true _invincible_timer = GameConfig.ship_invincibility_time @@ -418,12 +604,9 @@ func _team_spawn_group() -> String: func _update_hud() -> void: if not _is_owner: 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"): 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") 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) diff --git a/spacewar/world/world.gd b/spacewar/world/world.gd index e637f9a..b1b05ca 100644 --- a/spacewar/world/world.gd +++ b/spacewar/world/world.gd @@ -78,6 +78,12 @@ func spawn_peer(peer_id: int) -> Node: func despawn_peer(peer_id: int) -> void: var ship := _players.get_node_or_null(str(peer_id)) 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() @@ -90,10 +96,12 @@ func _spawn_ship(peer_id: int) -> Node: # 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. -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(): 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: @@ -101,9 +109,30 @@ func _spawn_bullet(data: Dictionary) -> Node: bullet.global_position = data.pos bullet.velocity = data.vel bullet.source_peer_id = data.peer_id + bullet.damage = data.damage + _play_shoot_sound(data.peer_id, data.pos) 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: if multiplayer.is_server(): spawn_peer(peer_id) diff --git a/spacewar/world/world.tscn b/spacewar/world/world.tscn index 22d3849..774798e 100644 --- a/spacewar/world/world.tscn +++ b/spacewar/world/world.tscn @@ -9,7 +9,6 @@ [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/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] script = ExtResource("4_world") @@ -26,24 +25,6 @@ spawn_path = NodePath("../Players") [node name="BulletSpawner" type="MultiplayerSpawner" parent="."] 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="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="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")] +margin_top = 40.0 +fill_color = Color(0.25, 0.55, 1.0, 0.95)