@@ -0,0 +1,264 @@
|
|||||||
|
# Networking Implementation Plan
|
||||||
|
|
||||||
|
Step-by-step breakdown for taking Spacewar from single-player-only to working
|
||||||
|
multiplayer. `tech.md` describes the target architecture; this doc is the
|
||||||
|
ordered checklist for getting there from the current codebase.
|
||||||
|
|
||||||
|
**Current state (as of this doc):** zero networking code exists anywhere in
|
||||||
|
the project. The server browser's JOIN button and the main menu's CASUAL
|
||||||
|
button just set local `GameConfig` fields and load `world.tscn` — no
|
||||||
|
connection is ever made. Everything downstream assumes exactly one player.
|
||||||
|
|
||||||
|
**Guiding principle:** get two peers moving/shooting/dying correctly over
|
||||||
|
ENet *first*, with ugly/no prediction. Polish feel (prediction,
|
||||||
|
reconciliation, lag comp) only after the authoritative loop is proven
|
||||||
|
correct. Don't build matchmaking/Steam/galaxy-war until basic peer-to-peer
|
||||||
|
combat works.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Decisions to make before writing code
|
||||||
|
|
||||||
|
- [ ] Pick topology for early dev: dedicated headless server instance vs.
|
||||||
|
one client acting as host. (Recommend: always run a dedicated server,
|
||||||
|
even locally — avoids a host-migration refactor later, and matches
|
||||||
|
the authoritative-server model in `tech.md`.)
|
||||||
|
- [ ] Decide the connection target for now: hardcoded `127.0.0.1` / LAN IP
|
||||||
|
is fine until Phase 8. Don't build matchmaking yet.
|
||||||
|
- [ ] Confirm max casual match size (25v25 per `overview.md`) — this affects
|
||||||
|
how much you can get away with naive replication before needing
|
||||||
|
interest management / area-of-interest culling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — ENet bootstrap & connect flow
|
||||||
|
|
||||||
|
**Goal:** two Godot instances can connect over ENet and see each other join/leave.
|
||||||
|
|
||||||
|
- [ ] Add a `NetworkManager` autoload (new, alongside `GameConfig` in
|
||||||
|
`autoload/`) that owns the `ENetMultiplayerPeer`, exposes
|
||||||
|
`host_server(port)` / `join_server(ip, port)`, and connects to
|
||||||
|
`multiplayer.peer_connected` / `peer_disconnected` / `connected_to_server`
|
||||||
|
/ `connection_failed`.
|
||||||
|
- [ ] Wire `menu/server_browser.gd`'s `_on_join_pressed` (currently just sets
|
||||||
|
`GameConfig` fields and changes scene) to actually call
|
||||||
|
`NetworkManager.join_server(...)` and only transition to `world.tscn`
|
||||||
|
on `connected_to_server`.
|
||||||
|
- [ ] Add a minimal headless server launch path (`--server` CLI flag or a
|
||||||
|
separate export target) that calls `host_server()` and loads
|
||||||
|
`world.tscn` without a local player.
|
||||||
|
- [ ] Smoke test: launch one headless server + two client instances, confirm
|
||||||
|
`peer_connected` fires on the server for both and each client sees the
|
||||||
|
other's peer_id.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Per-peer player state
|
||||||
|
|
||||||
|
**Goal:** replace the single-player assumption in `GameConfig` with a real
|
||||||
|
per-peer registry.
|
||||||
|
|
||||||
|
- [ ] Split `autoload/game_config.gd`: keep shared/match-wide constants
|
||||||
|
(thrust, speed, fire rate, `world_bounds`, etc.) as-is, but move the
|
||||||
|
per-player fields (`player_name`, `player_race`, `player_ship_path`,
|
||||||
|
`player_ship_scale`, `player_ship_speed_factor`) out of flat globals
|
||||||
|
into a `Dictionary[int, PlayerInfo]` keyed by `peer_id`, on
|
||||||
|
`NetworkManager` or a new `PlayerRegistry` autoload.
|
||||||
|
- [ ] On connect, client sends its chosen name/race/ship to the server via
|
||||||
|
RPC (`@rpc("any_peer", "call_local", "reliable") submit_loadout(...)`);
|
||||||
|
server stores it in the registry and relays to all peers so everyone
|
||||||
|
knows everyone's loadout.
|
||||||
|
- [ ] Update `menu/team_select.gd` (currently sets `GameConfig` fields
|
||||||
|
directly and emits local `team_selected`) to submit the choice via
|
||||||
|
this RPC path instead.
|
||||||
|
- [ ] Update anywhere still reading `GameConfig.player_name` /
|
||||||
|
`player_race` / `player_ship_path` directly to read from the local
|
||||||
|
peer's entry in the registry instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Spawning multiple ships
|
||||||
|
|
||||||
|
**Goal:** N ships exist in `world.tscn`, one per connected peer, each owned
|
||||||
|
by the right client.
|
||||||
|
|
||||||
|
- [ ] `world.tscn` currently has a single hardcoded ship node named "Player"
|
||||||
|
as a direct child. Remove it; replace with an empty `Node2D`
|
||||||
|
container (e.g. `Ships`) that ships get added to at runtime.
|
||||||
|
- [ ] Add a `MultiplayerSpawner` in `world.tscn` pointed at the `Ships`
|
||||||
|
container, with `ship.tscn` in its spawnable scene list.
|
||||||
|
- [ ] Server-side: on `peer_connected` (or once loadout is submitted),
|
||||||
|
instantiate `ship.tscn` for that peer under `Ships`, set
|
||||||
|
`set_multiplayer_authority(peer_id)` on the ship root, name the node
|
||||||
|
by peer_id (e.g. `str(peer_id)`) so it replicates deterministically.
|
||||||
|
- [ ] On `peer_disconnected`, despawn that peer's ship and remove it from
|
||||||
|
the registry.
|
||||||
|
- [ ] **Camera2D fix:** `ship.tscn` currently bakes a `Camera2D` into the
|
||||||
|
scene itself, which breaks with multiple instances. Move the camera
|
||||||
|
out of `ship.tscn`; in `ship_movement.gd`'s `_ready()`, only create/
|
||||||
|
activate a `Camera2D` if `is_multiplayer_authority()` is true (i.e.
|
||||||
|
this is the local player's own ship).
|
||||||
|
- [ ] **HUD fix:** `ship_movement.gd` currently pushes health to
|
||||||
|
`/root/World/HUD/HealthLabel` via a hardcoded absolute path. Gate this
|
||||||
|
the same way — only the locally-authoritative ship should update the
|
||||||
|
local HUD.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Split input from simulation
|
||||||
|
|
||||||
|
**Goal:** stop reading `Input.is_action_pressed()` inside the shared
|
||||||
|
simulation code; every ship's movement should be driven by whoever is
|
||||||
|
authoritative for it (server), fed by input coming from the owning client.
|
||||||
|
|
||||||
|
- [ ] In `ship_movement.gd`, extract the current `_physics_process` block
|
||||||
|
(lines ~45–66, direct `Input.is_action_pressed` calls) into a small
|
||||||
|
input struct/dictionary (`{thrust: bool, rotate: float, firing: bool}`)
|
||||||
|
gathered only when `is_multiplayer_authority()` on the *client* side.
|
||||||
|
- [ ] Add `@rpc("any_peer", "call_remote", "unreliable") send_input(input)`
|
||||||
|
on the ship: client calls it every physics tick with its local input;
|
||||||
|
server receives it, validates the sender is this ship's owning peer,
|
||||||
|
and stores it as "current input" for that ship.
|
||||||
|
- [ ] Server's `_physics_process` runs the actual movement/`move_and_slide`
|
||||||
|
simulation using the last-received input for every ship it owns
|
||||||
|
authority over (the server owns authority over all ships in the
|
||||||
|
dedicated-server model).
|
||||||
|
- [ ] Add a `MultiplayerSynchronizer` per ship replicating `position`,
|
||||||
|
`rotation`, `velocity` from server → clients.
|
||||||
|
- [ ] Get this working *without* prediction first: local ship will feel
|
||||||
|
laggy (input → server → back). That's expected at this stage — fixed
|
||||||
|
in Phase 7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Shooting / bullets over the network
|
||||||
|
|
||||||
|
**Goal:** bullets are server-simulated and replicated, not spawned locally
|
||||||
|
by each client.
|
||||||
|
|
||||||
|
- [ ] `ship_movement.gd`'s fire logic (~line 72) currently does
|
||||||
|
`get_parent().add_child(bullet)` directly on whichever peer runs it.
|
||||||
|
Change so firing is just another bit in the input struct from Phase 4;
|
||||||
|
server decides when a shot is actually fired (respecting fire-rate
|
||||||
|
cooldown server-side, not trusting client timing).
|
||||||
|
- [ ] Server instantiates `bullet.tscn` via a `MultiplayerSpawner` (or
|
||||||
|
manual spawn + RPC) under a shared `Bullets` container in
|
||||||
|
`world.tscn`.
|
||||||
|
- [ ] `bullet.gd` currently self-simulates movement in `_process` and
|
||||||
|
resolves damage locally via `body_entered`. Keep bullet *movement*
|
||||||
|
client-side-predicted for visual smoothness if desired, but damage
|
||||||
|
resolution (`take_damage()` call, ~lines 15–20) must only happen on
|
||||||
|
the server's copy of the bullet.
|
||||||
|
- [ ] Despawn bullets server-side when out of `world_bounds`; replicate
|
||||||
|
despawn to clients.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Server-authoritative health / death / respawn
|
||||||
|
|
||||||
|
**Goal:** no client can kill, heal, or respawn anything except by asking the
|
||||||
|
server.
|
||||||
|
|
||||||
|
- [ ] Move `take_damage` / `_die` / `_respawn` (`ship_movement.gd` lines
|
||||||
|
~104–135) so the actual state mutation only runs where
|
||||||
|
`multiplayer.is_server()` is true. Clients only ever display the
|
||||||
|
replicated result.
|
||||||
|
- [ ] Add an authority check at the top of `take_damage`: reject calls that
|
||||||
|
didn't originate from the server (bullets are already server-spawned
|
||||||
|
after Phase 5, so this mostly falls out naturally — but double check
|
||||||
|
nothing client-side can still call it directly).
|
||||||
|
- [ ] Replicate `health`, `is_dead` (or similar) via the ship's
|
||||||
|
`MultiplayerSynchronizer` from Phase 4 so HUD and visuals update on
|
||||||
|
all clients.
|
||||||
|
- [ ] Respawn: server decides timing/position and re-broadcasts spawn
|
||||||
|
state; don't let respawn timers run independently on each client.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7 — Client-side prediction & reconciliation
|
||||||
|
|
||||||
|
**Goal:** local ship feels responsive despite server round-trip; remote
|
||||||
|
ships move smoothly despite update-rate gaps.
|
||||||
|
|
||||||
|
- [ ] Local client: predict own ship's movement immediately on input
|
||||||
|
(re-run the same movement function locally that the server runs),
|
||||||
|
rather than waiting for the server echo.
|
||||||
|
- [ ] Server periodically sends authoritative position/velocity/tick back to
|
||||||
|
the owning client; client reconciles by snapping/blending toward it
|
||||||
|
if prediction drifted (basic version: hard snap if error exceeds a
|
||||||
|
threshold; polish later with smoothing).
|
||||||
|
- [ ] Remote ships (not locally owned): interpolate between the last two
|
||||||
|
received network states instead of snapping on every update.
|
||||||
|
- [ ] This is the highest-skill, most iterative phase — budget real time for
|
||||||
|
tuning "feel," not just correctness.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8 — Real server browser / connect flow
|
||||||
|
|
||||||
|
**Goal:** menus do what they currently only pretend to do.
|
||||||
|
|
||||||
|
- [ ] `menu/server_browser.gd`'s server list is a single hardcoded
|
||||||
|
"Trench Wars 0/32" entry. Replace with either: (a) a small manual
|
||||||
|
"enter IP" field for direct-connect testing, or (b) if a lightweight
|
||||||
|
server-list service exists by this point, query it.
|
||||||
|
- [ ] `menu/main_menu.gd`'s CASUAL/RANKED buttons currently skip networking
|
||||||
|
entirely and load `world.tscn` locally. Route CASUAL through the same
|
||||||
|
`NetworkManager.join_server` path once a target server is chosen.
|
||||||
|
- [ ] Handle connection failure / timeout UI (currently nothing exists for
|
||||||
|
this — `connection_failed` signal has no handler anywhere).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9 — Testing & hardening
|
||||||
|
|
||||||
|
- [ ] Test with 2 clients, then push toward the real casual target (25v25)
|
||||||
|
to find where naive full-replication breaks down (bandwidth, spawn
|
||||||
|
storms). Consider interest management / relevance culling only if
|
||||||
|
needed at that scale — don't build it preemptively.
|
||||||
|
- [ ] Artificially add latency/packet loss locally (Godot has debug tools
|
||||||
|
for this, or use `tc`/`netem` on Linux) and verify prediction/
|
||||||
|
reconciliation still feels acceptable.
|
||||||
|
- [ ] Verify a client can't cheat: send garbage/rapid-fire input via a
|
||||||
|
modified client and confirm the server-side rate limits / bounds
|
||||||
|
checks (added in Phases 4–6) actually hold.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 10 — Deferred / parallelizable (not blocking core multiplayer)
|
||||||
|
|
||||||
|
These don't block getting ship-vs-ship combat working over the network and
|
||||||
|
can happen in parallel or after Phases 1–9:
|
||||||
|
|
||||||
|
- [ ] Matchmaking backend (queue, MMR, lobby assignment) — see `tech.md`'s
|
||||||
|
Go/Node + Redis + Postgres sketch.
|
||||||
|
- [ ] GodotSteam integration (auth, VAC, lobbies).
|
||||||
|
- [ ] Lag compensation (server-side rewind for hit validation) — only
|
||||||
|
matters once hit-detection precision is actually being contested;
|
||||||
|
skip until basic damage registration is proven reliable.
|
||||||
|
- [ ] Bot fill for casual matches (`bots.md`) — depends on Phases 3–6 being
|
||||||
|
done, since bots need to be simulate-able the same way real players'
|
||||||
|
ships are.
|
||||||
|
- [ ] Galaxy war meta / sector control — orthogonal system, layer on top
|
||||||
|
once match-level multiplayer is solid.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Effort summary
|
||||||
|
|
||||||
|
| Phase | Relative effort | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| 1. ENet bootstrap | Small | 1–2 days |
|
||||||
|
| 2. Per-peer state | Small–Medium | Mechanical, touches every menu script |
|
||||||
|
| 3. Spawning | Medium | Camera/HUD ownership bugs are the sharp edges |
|
||||||
|
| 4. Input/sim split | Medium | The core refactor of `ship_movement.gd` |
|
||||||
|
| 5. Bullets | Small–Medium | Mostly follows the pattern from Phase 4 |
|
||||||
|
| 6. Authoritative health | Medium | Mostly enforcement of what Phase 4/5 set up |
|
||||||
|
| 7. Prediction/reconciliation | Medium–Large | Iterative feel-tuning, not just correctness |
|
||||||
|
| 8. Real menus | Small | UI wiring once NetworkManager exists |
|
||||||
|
| 9. Testing/hardening | Medium | Scales with target match size (25v25) |
|
||||||
|
| 10. Deferred systems | Large, but parallelizable | Doesn't block core multiplayer |
|
||||||
|
|
||||||
|
**Bare working version (Phases 1–6, no prediction polish):** roughly 1–2
|
||||||
|
weeks of focused work. **Feeling good at 25v25 (through Phase 9):** the long
|
||||||
|
pole — budget significantly more for iteration on Phase 7 in particular.
|
||||||
+19
-5
@@ -18,15 +18,18 @@ spacewar/ ← repo root
|
|||||||
│ ├── ship_movement.gd ← player ship logic
|
│ ├── ship_movement.gd ← player ship logic
|
||||||
│ └── bullet.tscn/gd ← projectile
|
│ └── bullet.tscn/gd ← projectile
|
||||||
├── world/
|
├── world/
|
||||||
│ ├── world.tscn ← game world
|
│ ├── world.tscn ← game world; world.gd loads the active map into MapContainer
|
||||||
│ └── tile_map.tscn ← unused prototype tilemap
|
│ ├── world.gd ← picks/instances a map scene from world/maps/
|
||||||
|
│ ├── world_tileset.tres ← shared TileSet resource (walls.png + asteroids.png atlas sources)
|
||||||
|
│ └── maps/
|
||||||
|
│ └── map_01.tscn ← hand-painted map: Walls + Asteroids TileMapLayers, team spawn Marker2Ds
|
||||||
└── assets/
|
└── assets/
|
||||||
├── icon.svg
|
├── icon.svg
|
||||||
└── images/
|
└── images/
|
||||||
├── background/skybox/ ← 6 space background PNGs (1.png – 6.png)
|
├── background/skybox/ ← 6 space background PNGs (1.png – 6.png)
|
||||||
├── effects/ ← explosion1.png
|
├── effects/ ← explosion1.png
|
||||||
├── ships/example_ships/ ← 4 placeholder ship sprites (1.png, 1B.png, 2a.png, 3b.png)
|
├── ships/example_ships/ ← 4 placeholder ship sprites (1.png, 1B.png, 2a.png, 3b.png)
|
||||||
└── tiles/ ← bwQ7rQ.png, used by tile_map.tscn
|
└── tiles/ ← walls.png, asteroids.png, used by world_tileset.tres
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** `asteroid_movement.gd` referenced in an earlier version of this doc does not exist yet — it's still an open task (see `CLAUDE.md`).
|
> **Note:** `asteroid_movement.gd` referenced in an earlier version of this doc does not exist yet — it's still an open task (see `CLAUDE.md`).
|
||||||
@@ -40,8 +43,8 @@ MainMenu (Control) ← main_menu.gd builds all UI in _ready()
|
|||||||
|
|
||||||
### `world/world.tscn` — game world
|
### `world/world.tscn` — game world
|
||||||
```
|
```
|
||||||
World (Node2D)
|
World (Node2D) ← world.gd instances MAP_SCENE into MapContainer on _ready()
|
||||||
├── TextureRect ← static space background (skybox/1.png)
|
├── MapContainer (Node2D) ← holds the instanced map (world/maps/map_01.tscn), background included
|
||||||
├── Player ← instance of ships/ship.tscn
|
├── Player ← instance of ships/ship.tscn
|
||||||
├── HUD (CanvasLayer)
|
├── HUD (CanvasLayer)
|
||||||
│ └── HealthLabel
|
│ └── HealthLabel
|
||||||
@@ -49,6 +52,17 @@ World (Node2D)
|
|||||||
└── TeamSelect (CanvasLayer, layer=20) ← menu/team_select.tscn; queue_free()s after selection
|
└── TeamSelect (CanvasLayer, layer=20) ← menu/team_select.tscn; queue_free()s after selection
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `world/maps/map_01.tscn` — hand-painted map
|
||||||
|
```
|
||||||
|
Map01 (Node2D)
|
||||||
|
├── Background (TextureRect) ← per-map space background (skybox/1.png); visible while editing this scene
|
||||||
|
├── Walls (TileMapLayer) ← painted by hand in the Godot Tile Editor, uses world_tileset.tres
|
||||||
|
├── Asteroids (TileMapLayer) ← painted by hand, same shared tileset
|
||||||
|
└── SpawnPoints (Node2D)
|
||||||
|
├── TeamASpawn1 (Marker2D) ← group "team_a_spawn"
|
||||||
|
└── TeamBSpawn1 (Marker2D) ← group "team_b_spawn"
|
||||||
|
```
|
||||||
|
|
||||||
### `ships/ship.tscn` — player ship
|
### `ships/ship.tscn` — player ship
|
||||||
```
|
```
|
||||||
Player (CharacterBody2D) ← ship_movement.gd
|
Player (CharacterBody2D) ← ship_movement.gd
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 893 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://c7vt2kqf8ugux"
|
||||||
|
path="res://.godot/imported/asteroids.png-ee88b2c6e2e07aea70f4dd513b385c96.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/images/tiles/asteroids.png"
|
||||||
|
dest_files=["res://.godot/imported/asteroids.png-ee88b2c6e2e07aea70f4dd513b385c96.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: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 653 KiB |
+4
-4
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
importer="texture"
|
importer="texture"
|
||||||
type="CompressedTexture2D"
|
type="CompressedTexture2D"
|
||||||
uid="uid://dglp131j5tga6"
|
uid="uid://b0arbt71qfb8l"
|
||||||
path="res://.godot/imported/bwQ7rQ.png-f54459e0d6bfa1fb00ae56276884677f.ctex"
|
path="res://.godot/imported/walls.png-6781334e49c209c81b477da0160ec804.ctex"
|
||||||
metadata={
|
metadata={
|
||||||
"vram_texture": false
|
"vram_texture": false
|
||||||
}
|
}
|
||||||
|
|
||||||
[deps]
|
[deps]
|
||||||
|
|
||||||
source_file="res://assets/images/tiles/bwQ7rQ.png"
|
source_file="res://assets/images/tiles/walls.png"
|
||||||
dest_files=["res://.godot/imported/bwQ7rQ.png-f54459e0d6bfa1fb00ae56276884677f.ctex"]
|
dest_files=["res://.godot/imported/walls.png-6781334e49c209c81b477da0160ec804.ctex"]
|
||||||
|
|
||||||
[params]
|
[params]
|
||||||
|
|
||||||
@@ -15,12 +15,18 @@ var bullet_speed: float = 450.0
|
|||||||
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 ship_wall_damage: int = 1
|
|
||||||
var bullet_damage: int = 40
|
var bullet_damage: int = 40
|
||||||
|
|
||||||
|
# Environment collision (asteroids only) — bounce-back and impact damage scale with impact speed
|
||||||
|
var ship_bounce_restitution: float = 0.45
|
||||||
|
var ship_collision_damage_scale: float = 0.064
|
||||||
|
|
||||||
# Set before entering a game
|
# Set before entering a game
|
||||||
var player_name: String = ""
|
var player_name: String = ""
|
||||||
var player_race: int = 0
|
var player_race: int = 0
|
||||||
var player_ship_path: String = ""
|
var player_ship_path: String = ""
|
||||||
var player_ship_scale: float = 1.0
|
var player_ship_scale: float = 1.0
|
||||||
var player_ship_speed_factor: float = 1.0
|
var player_ship_speed_factor: float = 1.0
|
||||||
|
|
||||||
|
# Current map's play area, in world coordinates. Set by world.gd on load.
|
||||||
|
var world_bounds: Rect2 = Rect2(0, 0, 1152, 648)
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ GameConfig="*res://autoload/game_config.gd"
|
|||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
window/size/viewport_width=1280
|
window/size/viewport_width=2560
|
||||||
window/size/viewport_height=800
|
window/size/viewport_height=1440
|
||||||
window/size/resizable=false
|
window/size/mode=3
|
||||||
|
window/stretch/mode="canvas_items"
|
||||||
|
window/stretch/aspect="expand"
|
||||||
|
|
||||||
[input]
|
[input]
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ func _ready() -> void:
|
|||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
global_position += velocity * delta
|
global_position += velocity * delta
|
||||||
var screen = get_viewport_rect().size
|
var bounds = GameConfig.world_bounds.grow(64.0)
|
||||||
if (global_position.x < 0 or global_position.x > screen.x or
|
if not bounds.has_point(global_position):
|
||||||
global_position.y < 0 or global_position.y > screen.y):
|
|
||||||
queue_free()
|
queue_free()
|
||||||
|
|
||||||
func _on_body_entered(body: Node) -> void:
|
func _on_body_entered(body: Node) -> void:
|
||||||
|
|||||||
@@ -16,3 +16,9 @@ scale = Vector2(0.229, 0.229)
|
|||||||
texture = ExtResource("2_epypp")
|
texture = ExtResource("2_epypp")
|
||||||
|
|
||||||
[node name="VisibleOnScreenNotifier2D" type="VisibleOnScreenNotifier2D" parent="." unique_id=1504818491]
|
[node name="VisibleOnScreenNotifier2D" type="VisibleOnScreenNotifier2D" parent="." unique_id=1504818491]
|
||||||
|
|
||||||
|
[node name="Camera2D" type="Camera2D" parent="."]
|
||||||
|
current = true
|
||||||
|
rotating = false
|
||||||
|
position_smoothing_enabled = true
|
||||||
|
position_smoothing_speed = 8.0
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ func _physics_process(delta: float) -> void:
|
|||||||
|
|
||||||
velocity = velocity.limit_length(max_speed)
|
velocity = velocity.limit_length(max_speed)
|
||||||
|
|
||||||
|
var velocity_before_move := velocity
|
||||||
move_and_slide()
|
move_and_slide()
|
||||||
|
_handle_environment_collisions(velocity_before_move)
|
||||||
|
|
||||||
# 4. SHOOT
|
# 4. SHOOT
|
||||||
_fire_cooldown -= delta
|
_fire_cooldown -= delta
|
||||||
@@ -70,15 +72,34 @@ func _physics_process(delta: float) -> void:
|
|||||||
get_parent().add_child(bullet)
|
get_parent().add_child(bullet)
|
||||||
|
|
||||||
# 5. WALL COLLISION
|
# 5. WALL COLLISION
|
||||||
var screen = get_viewport_rect().size
|
var bounds = GameConfig.world_bounds
|
||||||
if global_position.x < 0 or global_position.x > screen.x:
|
if global_position.x < bounds.position.x or global_position.x > bounds.end.x:
|
||||||
velocity.x = 0
|
velocity.x = 0
|
||||||
global_position.x = clamp(global_position.x, 0, screen.x)
|
global_position.x = clamp(global_position.x, bounds.position.x, bounds.end.x)
|
||||||
take_damage(GameConfig.ship_wall_damage)
|
if global_position.y < bounds.position.y or global_position.y > bounds.end.y:
|
||||||
if global_position.y < 0 or global_position.y > screen.y:
|
|
||||||
velocity.y = 0
|
velocity.y = 0
|
||||||
global_position.y = clamp(global_position.y, 0, screen.y)
|
global_position.y = clamp(global_position.y, bounds.position.y, bounds.end.y)
|
||||||
take_damage(GameConfig.ship_wall_damage)
|
|
||||||
|
func _handle_environment_collisions(previous_velocity: Vector2) -> void:
|
||||||
|
for i in get_slide_collision_count():
|
||||||
|
var collision := get_slide_collision(i)
|
||||||
|
var collider := collision.get_collider() as Node
|
||||||
|
if collider == null:
|
||||||
|
continue
|
||||||
|
var is_hazard: bool = collider.is_in_group("environment_hazard")
|
||||||
|
var is_wall: bool = collider.is_in_group("environment_wall")
|
||||||
|
if not (is_hazard or is_wall):
|
||||||
|
continue
|
||||||
|
var normal := collision.get_normal()
|
||||||
|
var impact_speed := -previous_velocity.dot(normal)
|
||||||
|
if impact_speed <= 0.0:
|
||||||
|
continue
|
||||||
|
velocity = previous_velocity.bounce(normal) * GameConfig.ship_bounce_restitution
|
||||||
|
if is_hazard:
|
||||||
|
var damage := int(impact_speed * GameConfig.ship_collision_damage_scale)
|
||||||
|
if damage > 0:
|
||||||
|
take_damage(damage)
|
||||||
|
|
||||||
|
|
||||||
func take_damage(amount: int) -> void:
|
func take_damage(amount: int) -> void:
|
||||||
if _invincible or _dead:
|
if _invincible or _dead:
|
||||||
@@ -96,8 +117,7 @@ func _die() -> void:
|
|||||||
_respawn()
|
_respawn()
|
||||||
|
|
||||||
func _respawn() -> void:
|
func _respawn() -> void:
|
||||||
var screen = get_viewport_rect().size
|
global_position = _get_spawn_position()
|
||||||
global_position = screen / 2.0
|
|
||||||
velocity = Vector2.ZERO
|
velocity = Vector2.ZERO
|
||||||
rotation = 0.0
|
rotation = 0.0
|
||||||
health = GameConfig.ship_max_health
|
health = GameConfig.ship_max_health
|
||||||
@@ -107,6 +127,13 @@ func _respawn() -> void:
|
|||||||
_invincible_timer = GameConfig.ship_invincibility_time
|
_invincible_timer = GameConfig.ship_invincibility_time
|
||||||
_update_hud()
|
_update_hud()
|
||||||
|
|
||||||
|
func _get_spawn_position() -> Vector2:
|
||||||
|
var markers := get_tree().get_nodes_in_group("team_a_spawn") + get_tree().get_nodes_in_group("team_b_spawn")
|
||||||
|
if markers.size() > 0:
|
||||||
|
return markers[randi() % markers.size()].global_position
|
||||||
|
return GameConfig.world_bounds.get_center()
|
||||||
|
|
||||||
|
|
||||||
func _update_hud() -> void:
|
func _update_hud() -> void:
|
||||||
var label = get_node_or_null("/root/World/HUD/HealthLabel")
|
var label = get_node_or_null("/root/World/HUD/HealthLabel")
|
||||||
if label:
|
if label:
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
[gd_scene format=4 uid="uid://cecn8fghm8h3j"]
|
||||||
|
|
||||||
|
[ext_resource type="TileSet" uid="uid://cufng7112p6a7" path="res://world/world_tileset.tres" id="1_tileset"]
|
||||||
|
[ext_resource type="Texture2D" uid="uid://d0i6jwqh131b1" path="res://assets/images/background/skybox/1.png" id="2_skybox"]
|
||||||
|
|
||||||
|
[node name="Map01" type="Node2D" unique_id=1843060857]
|
||||||
|
|
||||||
|
[node name="Background" type="TextureRect" parent="." unique_id=1935208901]
|
||||||
|
texture_repeat = 2
|
||||||
|
offset_left = -7008.0
|
||||||
|
offset_top = -3000.0
|
||||||
|
offset_right = 7008.0
|
||||||
|
offset_bottom = 3000.0
|
||||||
|
texture = ExtResource("2_skybox")
|
||||||
|
expand_mode = 3
|
||||||
|
|
||||||
|
[node name="Walls" type="TileMapLayer" parent="." unique_id=1965607014]
|
||||||
|
scale = Vector2(0.15, 0.15)
|
||||||
|
tile_map_data = PackedByteArray("AAAFACYAAAABAAAAAAAFABkAAAABAAAAAAAFAA0AAAABAAAAAAAFAAAAAAABAAAAAAAFAPT/AAABAAAAAAARABEAAAABAAEAAAA=")
|
||||||
|
tile_set = ExtResource("1_tileset")
|
||||||
|
|
||||||
|
[node name="Asteroids" type="TileMapLayer" parent="." unique_id=1745417803]
|
||||||
|
scale = Vector2(0.15, 0.15)
|
||||||
|
tile_map_data = PackedByteArray("AAAMAAEAAQAAAAMAAAALAPz/AQAAAAMAAAD6////AQAAAAMAAAD6/wgAAQAAAAMAAAAOAAgAAQAAAAMAAAA=")
|
||||||
|
tile_set = ExtResource("1_tileset")
|
||||||
|
|
||||||
|
[node name="SpawnPoints" type="Node2D" parent="." unique_id=1668900368]
|
||||||
|
|
||||||
|
[node name="TeamASpawn1" type="Marker2D" parent="SpawnPoints" unique_id=946644647 groups=["team_a_spawn"]]
|
||||||
|
position = Vector2(5304, 0)
|
||||||
|
|
||||||
|
[node name="TeamBSpawn1" type="Marker2D" parent="SpawnPoints" unique_id=449830331 groups=["team_b_spawn"]]
|
||||||
|
position = Vector2(-5304, 0)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
|||||||
|
extends Node2D
|
||||||
|
|
||||||
|
const MAP_SCENE: PackedScene = preload("res://world/maps/map_01.tscn")
|
||||||
|
|
||||||
|
@onready var _map_container: Node2D = $MapContainer
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
var map := MAP_SCENE.instantiate()
|
||||||
|
_map_container.add_child(map)
|
||||||
|
var background := map.get_node_or_null("Background") as TextureRect
|
||||||
|
if background:
|
||||||
|
GameConfig.world_bounds = Rect2(background.position, background.size)
|
||||||
|
_build_tile_collisions(map)
|
||||||
|
|
||||||
|
|
||||||
|
# Tiles have no collision shapes of their own, so spawn one StaticBody2D per
|
||||||
|
# occupied cell. Asteroids get round hitboxes to roughly match their sprites.
|
||||||
|
# Walls only bounce the ship; asteroids also deal impact damage.
|
||||||
|
func _build_tile_collisions(map: Node) -> void:
|
||||||
|
for layer in map.find_children("*", "TileMapLayer", true, false):
|
||||||
|
var is_asteroid := layer.name == "Asteroids"
|
||||||
|
var group := "environment_hazard" if is_asteroid else "environment_wall"
|
||||||
|
_add_layer_colliders(layer, is_asteroid, group)
|
||||||
|
|
||||||
|
|
||||||
|
func _add_layer_colliders(layer: TileMapLayer, use_circle: bool, group: String) -> void:
|
||||||
|
var tile_size := Vector2(layer.tile_set.tile_size)
|
||||||
|
for cell in layer.get_used_cells():
|
||||||
|
var body := StaticBody2D.new()
|
||||||
|
body.position = layer.map_to_local(cell)
|
||||||
|
body.add_to_group(group)
|
||||||
|
var shape := CollisionShape2D.new()
|
||||||
|
if use_circle:
|
||||||
|
var circle := CircleShape2D.new()
|
||||||
|
circle.radius = min(tile_size.x, tile_size.y) * 0.5
|
||||||
|
shape.shape = circle
|
||||||
|
else:
|
||||||
|
var rect := RectangleShape2D.new()
|
||||||
|
rect.size = tile_size
|
||||||
|
shape.shape = rect
|
||||||
|
body.add_child(shape)
|
||||||
|
layer.add_child(body)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dqgg811tgnrka
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
[gd_scene format=3 uid="uid://c77s15ns13p6y"]
|
[gd_scene format=3 uid="uid://c77s15ns13p6y"]
|
||||||
|
|
||||||
[ext_resource type="PackedScene" uid="uid://dygtdlkvo6ofl" path="res://ships/ship.tscn" id="1_f3sb7"]
|
[ext_resource type="PackedScene" uid="uid://dygtdlkvo6ofl" path="res://ships/ship.tscn" id="1_f3sb7"]
|
||||||
[ext_resource type="Texture2D" uid="uid://d0i6jwqh131b1" path="res://assets/images/background/skybox/1.png" id="1_fj7yv"]
|
|
||||||
[ext_resource type="PackedScene" uid="uid://cpausemenu01a" path="res://menu/pause_menu.tscn" id="2_pm"]
|
[ext_resource type="PackedScene" uid="uid://cpausemenu01a" path="res://menu/pause_menu.tscn" id="2_pm"]
|
||||||
[ext_resource type="PackedScene" uid="uid://dteamselect01" path="res://menu/team_select.tscn" id="3_ts"]
|
[ext_resource type="PackedScene" uid="uid://dteamselect01" path="res://menu/team_select.tscn" id="3_ts"]
|
||||||
|
[ext_resource type="Script" path="res://world/world.gd" id="4_world"]
|
||||||
|
|
||||||
[node name="World" type="Node2D" unique_id=1962020789]
|
[node name="World" type="Node2D" unique_id=1962020789]
|
||||||
|
script = ExtResource("4_world")
|
||||||
|
|
||||||
[node name="TextureRect" type="TextureRect" parent="." unique_id=1177783968]
|
[node name="MapContainer" type="Node2D" parent="."]
|
||||||
offset_right = 1280.0
|
|
||||||
offset_bottom = 800.0
|
|
||||||
texture = ExtResource("1_fj7yv")
|
|
||||||
|
|
||||||
[node name="Player" parent="." unique_id=2118863138 instance=ExtResource("1_f3sb7")]
|
[node name="Player" parent="." unique_id=2118863138 instance=ExtResource("1_f3sb7")]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
[gd_resource type="TileSet" format=3 uid="uid://cufng7112p6a7"]
|
||||||
|
|
||||||
|
[ext_resource type="Texture2D" uid="uid://b0arbt71qfb8l" path="res://assets/images/tiles/walls.png" id="1_walls"]
|
||||||
|
[ext_resource type="Texture2D" uid="uid://c7vt2kqf8ugux" path="res://assets/images/tiles/asteroids.png" id="2_asteroids"]
|
||||||
|
|
||||||
|
[sub_resource type="TileSetAtlasSource" id="TileSetAtlasSource_walls"]
|
||||||
|
texture = ExtResource("1_walls")
|
||||||
|
texture_region_size = Vector2i(194, 196)
|
||||||
|
2:0/0 = 0
|
||||||
|
3:0/0 = 0
|
||||||
|
4:0/0 = 0
|
||||||
|
5:0/0 = 0
|
||||||
|
1:0/0 = 0
|
||||||
|
0:0/0 = 0
|
||||||
|
0:1/0 = 0
|
||||||
|
0:2/0 = 0
|
||||||
|
0:3/0 = 0
|
||||||
|
1:3/0 = 0
|
||||||
|
2:3/0 = 0
|
||||||
|
3:3/0 = 0
|
||||||
|
4:3/0 = 0
|
||||||
|
5:3/0 = 0
|
||||||
|
5:2/0 = 0
|
||||||
|
4:2/0 = 0
|
||||||
|
3:2/0 = 0
|
||||||
|
2:2/0 = 0
|
||||||
|
1:2/0 = 0
|
||||||
|
1:1/0 = 0
|
||||||
|
2:1/0 = 0
|
||||||
|
3:1/0 = 0
|
||||||
|
4:1/0 = 0
|
||||||
|
5:1/0 = 0
|
||||||
|
|
||||||
|
[sub_resource type="TileSetAtlasSource" id="TileSetAtlasSource_asteroids"]
|
||||||
|
texture = ExtResource("2_asteroids")
|
||||||
|
texture_region_size = Vector2i(194, 196)
|
||||||
|
0:0/0 = 0
|
||||||
|
1:0/0 = 0
|
||||||
|
2:0/0 = 0
|
||||||
|
3:0/0 = 0
|
||||||
|
4:0/0 = 0
|
||||||
|
5:0/0 = 0
|
||||||
|
5:1/0 = 0
|
||||||
|
4:1/0 = 0
|
||||||
|
3:1/0 = 0
|
||||||
|
2:1/0 = 0
|
||||||
|
1:1/0 = 0
|
||||||
|
0:1/0 = 0
|
||||||
|
0:2/0 = 0
|
||||||
|
2:2/0 = 0
|
||||||
|
1:2/0 = 0
|
||||||
|
3:2/0 = 0
|
||||||
|
4:2/0 = 0
|
||||||
|
5:2/0 = 0
|
||||||
|
5:3/0 = 0
|
||||||
|
4:3/0 = 0
|
||||||
|
3:3/0 = 0
|
||||||
|
2:3/0 = 0
|
||||||
|
1:3/0 = 0
|
||||||
|
0:3/0 = 0
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
tile_size = Vector2i(194, 196)
|
||||||
|
physics_layer_0/collision_layer = 1
|
||||||
|
sources/0 = SubResource("TileSetAtlasSource_walls")
|
||||||
|
sources/1 = SubResource("TileSetAtlasSource_asteroids")
|
||||||
Reference in New Issue
Block a user