diff --git a/CLAUDE.md b/CLAUDE.md index a98c3ed..9135a5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,11 +27,13 @@ README for how to run/extend it. 5. **Real race art wired in** — Terran Republic, Mechanos Sovereignty, and Vorg Swarm each have their own 5-ship roster (Interceptor/Gunship/Bomber/Support/Heavy) with real sprites cropped from uploaded concept sheets (`assets/images/ships//`); placeholder `example_ships` removed 6. **Multiplayer networking (movement + combat)** — ENet authoritative server; networked ship spawning with client-side prediction + server reconciliation for movement; server-authoritative bullets, health, death/respawn all broadcast to every peer (see `overview/tech.md`) 7. **Matchmaking API + client wiring** — FastAPI + Postgres service (`matchmaking-api/`) for casual/ranked queueing; main menu CASUAL/RANKED buttons queue via the API, poll for a match, then connect to the assigned server. Currently only one dev server is registered (auto-seeded on API startup) — see Current Tasks +8. **In-game chat** — T focuses "all" chat (whole match, both teams), Y focuses "team" chat (peers sharing the sender's race, since race doubles as team); Enter sends, Esc cancels; last 10 messages shown bottom-left, WoW-style translucent log + input line (`chat/chat_box.gd`); server-relayed and team-filtered via `ChatManager` autoload (`autoload/chat_manager.gd`) +9. **Bot fill for casual** — each of the match's 2 offered races is kept at a minimum of `GameConfig.bot_min_team_size` (7) total humans+bots; bots spawn/despawn reactively as players join/leave (`bots/bot_manager.gd`, server-authoritative autoload). Bots always fly their race's fighter/Interceptor (`race.ships[0]`) and use negative peer_ids, which lets them ride every existing networked-ship system (spawning, loadout replication, position/health sync, bullet attribution) with zero special-casing. AI (`bots/bot_ai.gd`, `class_name BotAI`) is a simple seek-nearest-enemy-and-shoot brain, deliberately isolated from ship networking code so future tuning/behavior changes stay contained to that one file. See `bots.md`. Casual matchmaking now forms with just 1 real player queued (bots pad the rest) — see `matchmaking-api/README.md`. +10. **Borderless fullscreen, no UI/world scaling** — launches at the real screen resolution with stretch mode disabled (1 game pixel = 1 screen pixel), instead of scaling the Steam-Deck-matched 1280×800 design canvas up to fill PC monitors (which looked zoomed in). PC monitors now just reveal more world/HUD instead. Every menu screen (`main_menu.gd`, `team_select.gd`, `chat/chat_box.gd`) was reworked to position itself via anchors relative to the real window instead of hardcoded 1280×800 pixel coordinates — see `structure.md`'s Display section, including a real `push_opposite_anchor` footgun hit along the way. `menu/server_browser.gd` was NOT updated (still hardcoded, but unreachable in the live flow). ## Current Tasks - [ ] Asteroids and environment hazards -- [ ] Bot fill for casual (minimum 7v7, bots fill empty slots — see `bots.md`) — next logical step now that matchmaking works, since `CASUAL_TEAM_SIZE` is set low (1) for solo/duo testing on the assumption bots will pad real matches later - [ ] Sound effects (thrust, shoot, explosion, UI) - [ ] Real game-server pool — servers self-register with the matchmaking API (`POST /servers/register` already exists, nothing calls it yet) instead of one hardcoded dev entry - [ ] Galaxy war meta / sector control for casual (see `multiplayer.md`) diff --git a/matchmaking-api/.env.example b/matchmaking-api/.env.example index 850701b..2d6a06b 100644 --- a/matchmaking-api/.env.example +++ b/matchmaking-api/.env.example @@ -1,6 +1,9 @@ DATABASE_URL=postgresql+asyncpg://matchmaking:matchmaking@postgres:5432/matchmaking # Real-value targets per overview/bots.md (casual min 7v7) and overview/racesclasses.md (ranked 5v5). +# CASUAL_TEAM_SIZE no longer gates when a casual match forms (bot fill means +# 1 real player is enough — see queue_manager.py); it only caps how many real +# players group into one casual match. CASUAL_TEAM_SIZE=7 RANKED_TEAM_SIZE=5 diff --git a/matchmaking-api/README.md b/matchmaking-api/README.md index f32914e..03cecd0 100644 --- a/matchmaking-api/README.md +++ b/matchmaking-api/README.md @@ -33,10 +33,15 @@ rebuilding the image. Rebuild (`docker compose up -d --build`) only when returns the assigned server's `server_ip`/`server_port`/`team`. - `POST /matchmaking/queue/leave?ticket_id=...` — cancel a queued ticket. -A background loop (`app/queue_manager.py`) runs every 2s, and once enough -players are queued for a mode (`CASUAL_TEAM_SIZE`/`RANKED_TEAM_SIZE` × 2) it -forms a match, splits them into two teams, and assigns the first available -`GameServer` for that mode. +A background loop (`app/queue_manager.py`) runs every 2s and forms a match +once enough players are queued for a mode, then splits them into two teams +and assigns the first available `GameServer` for that mode. Casual and +ranked differ here: casual has bot fill (`spacewar/bots/bot_manager.gd` +pads both teams up to `GameConfig.bot_min_team_size` in-game), so 1 queued +player is enough to form a casual match — `CASUAL_TEAM_SIZE` just caps how +many real players can group into one match, it no longer gates formation. +Ranked has no bots, so it still waits for the full `RANKED_TEAM_SIZE × 2` +real players before forming. There's no real game-server pool yet — one dev server is auto-seeded on API startup from `DEV_SEED_SERVER_IP`/`DEV_SEED_SERVER_PORT` (defaults to diff --git a/matchmaking-api/app/queue_manager.py b/matchmaking-api/app/queue_manager.py index bae9a9e..71550c9 100644 --- a/matchmaking-api/app/queue_manager.py +++ b/matchmaking-api/app/queue_manager.py @@ -65,13 +65,12 @@ class QueueManager: tickets.sort(key=lambda t: t.queued_at) return tickets - async def _try_form_match(self, mode: Mode, team_size: int, db: AsyncSession) -> None: - required = team_size * 2 + async def _try_form_match(self, mode: Mode, required: int, cap: int, db: AsyncSession) -> None: async with self._lock: waiting = self._waiting(mode) if len(waiting) < required: return - group = waiting[:required] + group = waiting[:cap] server = ( await db.execute( @@ -99,8 +98,20 @@ class QueueManager: while True: await asyncio.sleep(2) async with async_session() as db: - await self._try_form_match(Mode.casual, settings.casual_team_size, db) - await self._try_form_match(Mode.ranked, settings.ranked_team_size, db) + # Casual has bot fill (spacewar/bots/bot_manager.gd) — bots pad + # both teams up to GameConfig.bot_min_team_size, so a single + # queued player is enough to form a match. Still group in + # anyone else who queues in the same tick, up to the real + # casual_team_size*2 target, rather than capping at 1v1. + # Ranked has no bots, so it still needs the full + # ranked_team_size*2 real players queued before forming. + await self._try_form_match(Mode.casual, required=1, cap=settings.casual_team_size * 2, db=db) + await self._try_form_match( + Mode.ranked, + required=settings.ranked_team_size * 2, + cap=settings.ranked_team_size * 2, + db=db, + ) queue_manager = QueueManager() diff --git a/overview/bots.md b/overview/bots.md index cc51357..4b3bc1a 100644 --- a/overview/bots.md +++ b/overview/bots.md @@ -13,9 +13,26 @@ - Avoids the ghost-town feeling of a 25v25 map with 3 players - Bots are not added beyond 7v7 so human players always dominate strategy -## Implementation Notes (not yet built) +## Implementation Notes -- Bots use simplified AI: thrust toward nearest enemy, shoot when in range -- Bot difficulty: intentionally easy (this is casual — bots are filler, not challenge) -- Bot names: procedurally generated callsigns so they're not obviously bots in the HUD -- Server tracks human vs bot count per team and manages swap-in on player join +- **`bots/bot_manager.gd`** (server-authoritative autoload) owns bot lifecycle. All + spawn/despawn decisions funnel through one function, `_reconcile_race(race_id)`, + triggered by `PlayerRegistry`'s existing `loadout_updated`/`player_removed` + signals (join/leave) and `World.decide_offered_races()` (match start) — no + polling. This is the one place to change for future tweaks (fill curve, per-map + team size, etc.) +- Bots get **negative peer_ids** (`-1, -2, ...`), assigned by `BotManager`. This + lets them reuse every existing networked-ship system for free — spawning + (`MultiplayerSpawner`), loadout replication/backfill (`PlayerRegistry`), + position/health/visibility sync, bullet damage attribution — none of which + cares whether a `peer_id` came from ENet or from `BotManager` +- Bots always fly **the race's fighter/Interceptor** (`race.ships[0]` in + `team_select.gd`'s `RACES` data) +- AI (`bots/bot_ai.gd`, `class_name BotAI`): thrust toward nearest living enemy, + turn to face it, shoot when roughly aimed and in range — simplified + seek-and-shoot, intentionally easy (casual bots are filler, not a challenge). + Fully isolated from `ship_movement.gd`'s networking code so future + difficulty/behavior tuning only ever touches this one file +- Bot names: `bots/bot_names.gd`, a flat pool of procedural callsigns +- Tunables live in `GameConfig` (`bot_min_team_size`, `bot_engage_range`, + `bot_stop_distance`, `bot_aim_tolerance_deg`) diff --git a/overview/chat.md b/overview/chat.md new file mode 100644 index 0000000..1bf3a6b --- /dev/null +++ b/overview/chat.md @@ -0,0 +1,5 @@ +The chat system will be in the game. In order to focus the chat window, the user presses t. He then types a message and enter sends it. + The message will only be in the game that the user is in. The opposing team can see the chat message as well. + If he wants it just for the team, he presses y. It will say [team] infront of it . The chat will show the last 10 messages. It will be positioned on bottom left of screen. The chat input is at the bottom. I want it to look similiar to world of warcrafts chat system. + + \ No newline at end of file diff --git a/overview/structure.md b/overview/structure.md index ff957dd..3131a31 100644 --- a/overview/structure.md +++ b/overview/structure.md @@ -17,6 +17,12 @@ spacewar/ ← repo root │ ├── ship.tscn ← player ship scene (formerly node_2d.tscn) │ ├── ship_movement.gd ← player ship logic │ └── bullet.tscn/gd ← projectile + ├── chat/ + │ └── chat_box.tscn/gd ← WoW-style chat overlay (T=all, Y=team), added to world.tscn + ├── bots/ + │ ├── bot_manager.gd ← autoload; server-only bot fill (join/leave reconciliation) + │ ├── bot_ai.gd ← class_name BotAI; seek-nearest-enemy-and-shoot brain + │ └── bot_names.gd ← class_name BotNames; procedural callsign pool ├── world/ │ ├── world.tscn ← game world; world.gd loads the active map into MapContainer │ ├── world.gd ← picks/instances a map scene from world/maps/ @@ -49,7 +55,8 @@ World (Node2D) ← world.gd instances MAP_SCENE into MapContainer o ├── HUD (CanvasLayer) │ └── HealthLabel ├── PauseMenu (CanvasLayer, layer=10) ← menu/pause_menu.tscn -└── 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 +└── ChatBox (CanvasLayer, layer=5) ← chat/chat_box.tscn ``` ### `world/maps/map_01.tscn` — hand-painted map @@ -81,6 +88,8 @@ Player (CharacterBody2D) ← ship_movement.gd | `move_down` | S | — | | `shoot` | Space | — | | `toggle_pause` | Esc | Start / Options button (JoyButton 6) | +| `chat_all` | T | — | +| `chat_team` | Y | — | ## Game Flow @@ -93,7 +102,32 @@ main_menu.tscn └── RANKED → disabled (coming soon) ``` -> **Note:** `server_browser.tscn` was built (Task 1) and is functional, but the current flow bypasses it — CASUAL goes directly to the world and TeamSelect handles name/race/ship. The server browser will be re-integrated when real multiplayer server listing is built. +> **Note:** `server_browser.tscn` was built (Task 1) and is functional, but the current flow bypasses it — CASUAL goes directly to the world and TeamSelect handles name/race/ship. The server browser will be re-integrated when real multiplayer server listing is built. It still uses the old hardcoded-1280px layout style (see Display below) — not yet updated since it isn't reachable in the live flow. + +## Display + +Launches borderless fullscreen at the real screen resolution (`window/size/mode=3` +in `project.godot`) with stretch mode **disabled** — 1 game pixel = 1 screen +pixel everywhere, no UI/world scaling. Chosen over Godot's default +`canvas_items`+`expand` stretch (which scales the whole 2D canvas to fill the +window) because the game's design canvas is deliberately sized to the Steam +Deck's native 1280×800; scaling that up to fill a PC monitor made everything +look zoomed in. With stretch disabled, PC monitors just reveal more of the +world/HUD at native size instead — the actual map (`world/maps/map_01.tscn`) +is already a fixed-size 14016×6000 arena, not viewport-sized, so there's more +world to reveal. + +This means every menu Control has to position itself relative to the *real* +window size, not a fixed 1280×800 design canvas — anchors (0.0=edge, +0.5=center, 1.0=opposite edge) plus fixed pixel offsets from that anchor, +not raw absolute pixel coordinates. `main_menu.gd` and `team_select.gd` both +have a `_place()`/direct-property-assignment helper for this — **always set +anchor properties directly (`node.anchor_left = ...`) rather than through +sequential `set_anchor_and_offset()` calls**: that method's default +`push_opposite_anchor=true` drags the opposite side's anchor/offset along +whenever two sequential calls momentarily disagree (e.g. left set to a 0.5 +anchor while right is still its 0.0 default), corrupting layout — hit this +exact bug converting the play-mode buttons to be centered. ## Key Autoload — `GameConfig` @@ -113,3 +147,22 @@ main_menu.tscn | `ship_invincibility_time` | float | Tuning constant | | `bullet_damage` | int | Tuning constant | | `ship_wall_damage` | int | Tuning constant | +| `chat_focused` | bool | Set by ChatBox while its input line has keyboard focus; gates ship movement/fire input | +| `bot_min_team_size` | int | Tuning constant — min humans+bots per race, see `bots/bot_manager.gd` | +| `bot_engage_range` | float | Tuning constant — bot max shoot distance | +| `bot_stop_distance` | float | Tuning constant — bot stops closing distance below this | +| `bot_aim_tolerance_deg` | float | Tuning constant — how precisely a bot must face a target to fire | + +## Key Autoload — `BotManager` + +Server-authoritative bot fill for casual (`bots/bot_manager.gd`). Keeps each of the +match's 2 offered races at `GameConfig.bot_min_team_size` total humans+bots, +reacting to `PlayerRegistry.loadout_updated`/`player_removed` and +`World.decide_offered_races()`. See `overview/bots.md`. + +## Key Autoload — `ChatManager` + +Server-relayed chat (`autoload/chat_manager.gd`). `send_chat(text, team_only)` sends; +`message_received(sender_name, sender_race, team_only, text)` signal delivers +incoming messages to `ChatBox`. "Team" messages are filtered server-side to +peers sharing the sender's race (race doubles as team — see `PlayerRegistry`). diff --git a/overview/task.md b/overview/task.md index 5fead31..da18552 100644 --- a/overview/task.md +++ b/overview/task.md @@ -12,17 +12,18 @@ | 5 | Main menu | CASUAL / RANKED (disabled), callsign input, rank badge, quit button | | 6 | In-game pause menu | ESC / controller Start; game runs behind it; working: RESUME, QUIT TO MENU, QUIT TO DESKTOP | | 7 | Team & ship selection | On world load; 2 random races offered; ship grid with real sprites; player spawns after | +| 8 | Replace placeholder races with chosen 3 (Terran Republic, Mechanos Sovereignty, Vorg Swarm) | Done | +| 9 | Real ship sprites for all 3 races (5 ships each) | Done | +| 13 | Multiplayer networking — ENet authoritative server | Done | +| 18 | In-game chat (T=all, Y=team, last 10 messages, bottom-left) | `chat/chat_box.gd` + `autoload/chat_manager.gd` — see `chat.md` | +| 11 | Bot fill for casual (min 7v7) | `bots/bot_manager.gd` + `bots/bot_ai.gd` — see `bots.md` | ## Up Next | # | Task | Priority | |---|------|----------| -| 8 | Replace placeholder races with chosen 3 (Terran Republic, Mechanos Sovereignty, Void Wraiths) | High | -| 9 | Real ship sprites for all 3 races (5 ships each) | High | | 10 | Asteroids + environment hazards | Medium | -| 11 | Bot fill for casual (min 7v7) | Medium — see `bots.md` | | 12 | Sound effects (thrust, shoot, explosion, UI clicks) | Medium | -| 13 | Multiplayer networking — ENet authoritative server | High (big) | | 14 | Galaxy war meta + sector control | Low (post-networking) | | 15 | Ranked matchmaking + MMR | Low (post-networking) | | 16 | Settings screen (audio, controls, display) | Low | diff --git a/spacewar/autoload/chat_manager.gd b/spacewar/autoload/chat_manager.gd new file mode 100644 index 0000000..0276c39 --- /dev/null +++ b/spacewar/autoload/chat_manager.gd @@ -0,0 +1,68 @@ +extends Node + +# Server-relayed chat. "All" messages reach every peer in the match; "team" +# messages only reach peers who share the sender's chosen race (races double +# as the match's two teams — see TeamSelect/PlayerRegistry). + +signal message_received(sender_name: String, sender_race: int, team_only: bool, text: String) + +const MAX_MESSAGE_LENGTH := 140 + + +func send_chat(raw_text: String, team_only: bool) -> void: + var text := raw_text.strip_edges().left(MAX_MESSAGE_LENGTH) + if text.is_empty(): + return + + # Echo to ourselves immediately — no need to wait on a round trip to see + # our own message, and this sidesteps relying on "call_local" local + # execution semantics for a targeted rpc_id() call (unlike a broadcast + # .rpc(), those do NOT also run locally on the caller in Godot). + var local_id := multiplayer.get_unique_id() + var local_info := PlayerRegistry.get_info(local_id) + message_received.emit(local_info.get("name", "Player"), local_info.get("race", 0), team_only, text) + + if multiplayer.is_server(): + _relay(local_id, text, team_only) + else: + # Must target the server explicitly (id 1), not a bare broadcast + # .rpc(): ENetMultiplayerPeer's default server_relay transparently + # forwards a client's broadcast RPC to every other client too, which + # would let every peer run this unfiltered — silently leaking "team" + # chat to the other team before the server ever gets a chance to + # filter it. + submit_chat.rpc_id(1, text, team_only) + + +# Client -> server only. The server is the sole authority on who a "team" +# message is allowed to reach. +@rpc("any_peer", "call_remote", "reliable") +func submit_chat(text: String, team_only: bool) -> void: + if not multiplayer.is_server(): + return + _relay(multiplayer.get_remote_sender_id(), text, team_only) + + +func _relay(sender_id: int, raw_text: String, team_only: bool) -> void: + var text := raw_text.strip_edges().left(MAX_MESSAGE_LENGTH) + if text.is_empty(): + return + + var sender_info := PlayerRegistry.get_info(sender_id) + var sender_name: String = sender_info.get("name", "Player") + var sender_race: int = sender_info.get("race", 0) + + for peer_id in multiplayer.get_peers(): + if peer_id != sender_id and _should_receive(peer_id, sender_race, team_only): + _receive_chat.rpc_id(peer_id, sender_name, sender_race, team_only, text) + + +@rpc("authority", "call_remote", "reliable") +func _receive_chat(sender_name: String, sender_race: int, team_only: bool, text: String) -> void: + message_received.emit(sender_name, sender_race, team_only, text) + + +func _should_receive(receiver_id: int, sender_race: int, team_only: bool) -> bool: + if not team_only: + return true + return PlayerRegistry.get_info(receiver_id).get("race", -1) == sender_race diff --git a/spacewar/autoload/chat_manager.gd.uid b/spacewar/autoload/chat_manager.gd.uid new file mode 100644 index 0000000..d7a1b3c --- /dev/null +++ b/spacewar/autoload/chat_manager.gd.uid @@ -0,0 +1 @@ +uid://c70wvme1k38y3 diff --git a/spacewar/autoload/game_config.gd b/spacewar/autoload/game_config.gd index 8641890..2b2f57d 100644 --- a/spacewar/autoload/game_config.gd +++ b/spacewar/autoload/game_config.gd @@ -25,3 +25,13 @@ var player_name: String = "" # Current map's play area, in world coordinates. Set by world.gd on load. var world_bounds: Rect2 = Rect2(0, 0, 1152, 648) + +# True while the chat input box has keyboard focus — gates ship movement/fire +# input so typing (e.g. the letter "w") doesn't also move the ship. +var chat_focused: bool = false + +# Bots (casual fill) — see bots/bot_manager.gd and overview/bots.md +var bot_min_team_size: int = 7 # mirrors matchmaking-api's CASUAL_TEAM_SIZE default +var bot_engage_range: float = 500.0 +var bot_stop_distance: float = 160.0 +var bot_aim_tolerance_deg: float = 6.0 diff --git a/spacewar/autoload/player_registry.gd b/spacewar/autoload/player_registry.gd index 226fd6d..ce66b77 100644 --- a/spacewar/autoload/player_registry.gd +++ b/spacewar/autoload/player_registry.gd @@ -5,7 +5,7 @@ extends Node # their own. signal loadout_updated(peer_id: int) -signal player_removed(peer_id: int) +signal player_removed(peer_id: int, info: Dictionary) var players: Dictionary = {} # peer_id -> {name, race, ship_path, ship_scale, ship_speed_factor} @@ -26,6 +26,20 @@ func submit_local_loadout(player_name: String, race: int, ship_path: String, shi submit_loadout.rpc(player_name, race, ship_path, ship_scale, ship_speed_factor) +# 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) -> void: + for pid in multiplayer.get_peers(): + _receive_loadout.rpc_id(pid, peer_id, bot_name, race, ship_path, ship_scale, ship_speed_factor) + _store(peer_id, bot_name, race, ship_path, ship_scale, ship_speed_factor) + + +func unregister_bot(peer_id: int) -> void: + _remove(peer_id) + + # Client -> server (and locally to self via call_local). Server then relays # the new loadout to everyone else, and backfills the newcomer with everyone # else's already-known loadout. @@ -68,5 +82,10 @@ func _store(peer_id: int, player_name: String, race: int, ship_path: String, shi func _on_peer_left(peer_id: int) -> void: + _remove(peer_id) + + +func _remove(peer_id: int) -> void: + var info: Dictionary = players.get(peer_id, {}) if players.erase(peer_id): - player_removed.emit(peer_id) + player_removed.emit(peer_id, info) diff --git a/spacewar/bots/bot_ai.gd b/spacewar/bots/bot_ai.gd new file mode 100644 index 0000000..1e75ec0 --- /dev/null +++ b/spacewar/bots/bot_ai.gd @@ -0,0 +1,56 @@ +class_name BotAI +extends RefCounted + +# Simplified "seek and shoot" bot AI (see overview/bots.md): thrust toward +# the nearest living enemy, turn to face it, fire once roughly aimed and in +# range. Kept intentionally simple — casual bots are filler, not a challenge +# — and isolated from ship_movement.gd's networking code entirely so future +# difficulty/behavior tweaks only ever touch this file. + +var _ship: Ship +var _race_id: int + + +func _init(ship: Ship, race_id: int) -> void: + _ship = ship + _race_id = race_id + + +func compute_input(_delta: float) -> Dictionary: + var target := _find_nearest_enemy() + if target == null: + return {"up": false, "down": false, "left": false, "right": false, "shoot": false} + + var to_target: Vector2 = target.global_position - _ship.global_position + var distance: float = to_target.length() + # Ship forward is Vector2.UP.rotated(rotation) (see ship_movement.gd + # _simulate_step), so facing `to_target` requires offsetting by the angle + # between Vector2.UP and the zero-rotation forward vector (PI/2). + var desired_rot: float = to_target.angle() + PI / 2.0 + var diff: float = angle_difference(_ship.rotation, desired_rot) + var aim_tolerance: float = deg_to_rad(GameConfig.bot_aim_tolerance_deg) + + return { + "up": distance > GameConfig.bot_stop_distance, + "down": false, + "left": diff < -aim_tolerance, + "right": diff > aim_tolerance, + "shoot": absf(diff) <= aim_tolerance and distance <= GameConfig.bot_engage_range, + } + + +func _find_nearest_enemy() -> Ship: + var best: Ship = null + var best_dist := INF + for node in _ship.get_tree().get_nodes_in_group("ships"): + var candidate := node as Ship + if candidate == null or candidate == _ship or not candidate.visible: + continue + var info: Dictionary = PlayerRegistry.get_info(candidate.peer_id) + if info.get("race", -1) == _race_id: + continue + var dist: float = _ship.global_position.distance_squared_to(candidate.global_position) + if dist < best_dist: + best_dist = dist + best = candidate + return best diff --git a/spacewar/bots/bot_ai.gd.uid b/spacewar/bots/bot_ai.gd.uid new file mode 100644 index 0000000..ae265ff --- /dev/null +++ b/spacewar/bots/bot_ai.gd.uid @@ -0,0 +1 @@ +uid://b4dxa42o6vq8k diff --git a/spacewar/bots/bot_manager.gd b/spacewar/bots/bot_manager.gd new file mode 100644 index 0000000..38abaed --- /dev/null +++ b/spacewar/bots/bot_manager.gd @@ -0,0 +1,95 @@ +extends Node + +# Server-authoritative bot fill for casual matches (see overview/bots.md): +# keeps each of the match's 2 offered races at a minimum of +# GameConfig.bot_min_team_size total (humans + bots), spawning/despawning +# bots as players join and leave. +# +# All spawn/despawn logic funnels through _reconcile_race() so every future +# tweak (different fill curve, per-map team size, bot difficulty swaps, +# etc.) has one place to change instead of being scattered across +# join/leave handlers. + +var _world = null # World instance; untyped like ship_movement.gd's own + # world ref, since World has no class_name of its own +var _bots_by_race: Dictionary = {} # race_id -> Array[int] (bot peer_ids) +var _next_bot_id: int = -1 + + +func _ready() -> void: + PlayerRegistry.loadout_updated.connect(_on_loadout_updated) + PlayerRegistry.player_removed.connect(_on_player_removed) + + +# Called once by World.decide_offered_races() when the match's 2 races are +# decided, to fill both teams with bots before any human has picked a race. +func on_match_start(world, offered_race_ids: Array) -> void: + if not multiplayer.is_server(): + return + _world = world + _bots_by_race.clear() + _next_bot_id = -1 + for race_id in offered_race_ids: + _reconcile_race(race_id) + + +func _on_loadout_updated(peer_id: int) -> void: + if not multiplayer.is_server() or _world == null or peer_id < 0: + return + _reconcile_race(PlayerRegistry.get_info(peer_id).get("race", 0)) + + +func _on_player_removed(peer_id: int, info: Dictionary) -> void: + if not multiplayer.is_server() or _world == null or peer_id < 0: + return + _reconcile_race(info.get("race", 0)) + + +func _reconcile_race(race_id: int) -> void: + if race_id <= 0: + return + var humans := _count_humans(race_id) + 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)) + while current.size() > desired: + _despawn_bot(current.pop_back()) + _bots_by_race[race_id] = current + + +func _count_humans(race_id: int) -> int: + var count := 0 + for peer_id in PlayerRegistry.players: + if peer_id > 0 and PlayerRegistry.players[peer_id].race == race_id: + count += 1 + return count + + +func _spawn_bot(race_id: 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) + PlayerRegistry.register_bot( + bot_id, BotNames.random_name(), race_id, + ship_def.path, ship_def.scale, ship_def.speed_factor + ) + + var ship_node := _world.spawn_peer(bot_id) as Ship + if ship_node: + ship_node.bot_ai = BotAI.new(ship_node, race_id) + return bot_id + + +func _despawn_bot(bot_id: int) -> void: + _world.despawn_peer(bot_id) + PlayerRegistry.unregister_bot(bot_id) + + +func _find_race(race_id: int) -> Dictionary: + for race in TeamSelect.RACES: + if race.id == race_id: + return race + return TeamSelect.RACES[0] diff --git a/spacewar/bots/bot_manager.gd.uid b/spacewar/bots/bot_manager.gd.uid new file mode 100644 index 0000000..a2655f4 --- /dev/null +++ b/spacewar/bots/bot_manager.gd.uid @@ -0,0 +1 @@ +uid://ddrjywcfsrjdq diff --git a/spacewar/bots/bot_names.gd b/spacewar/bots/bot_names.gd new file mode 100644 index 0000000..a3a9800 --- /dev/null +++ b/spacewar/bots/bot_names.gd @@ -0,0 +1,18 @@ +class_name BotNames +extends RefCounted + +# Procedurally-flavored callsigns so bots aren't obviously bots in the HUD +# or chat (see overview/bots.md). Flat pool, picked randomly — a name can +# repeat across a long casual match, which is fine for filler. +const NAMES := [ + "Raptor", "Vagrant", "Nomad", "Cutlass", "Wraith", "Ghostfire", "Talon", + "Drifter", "Ironclad", "Nightshade", "Rustwing", "Voidrunner", "Ashback", + "Comet", "Hollow", "Fangtooth", "Static", "Sable", "Ember", "Backdraft", + "Grim", "Hexbolt", "Ironsight", "Junker", "Lowbeam", "Mirage", "Nullbyte", + "Outlaw", "Payload", "Quickfuse", "Redline", "Scrapheap", "Torque", + "Undertow", "Vector", "Whiplash", "Yardbird", "Zerofall", +] + + +static func random_name() -> String: + return NAMES[randi() % NAMES.size()] diff --git a/spacewar/bots/bot_names.gd.uid b/spacewar/bots/bot_names.gd.uid new file mode 100644 index 0000000..c680c24 --- /dev/null +++ b/spacewar/bots/bot_names.gd.uid @@ -0,0 +1 @@ +uid://s42nhrttgq4b diff --git a/spacewar/chat/chat_box.gd b/spacewar/chat/chat_box.gd new file mode 100644 index 0000000..df78cb6 --- /dev/null +++ b/spacewar/chat/chat_box.gd @@ -0,0 +1,165 @@ +extends CanvasLayer + +# WoW-style chat overlay. T focuses "all" chat (visible to both teams), Y +# focuses "team" chat (visible only to players who share the sender's race — +# races double as teams, see TeamSelect). Enter sends, Escape cancels. Last +# MAX_LINES messages are kept, positioned bottom-left per overview/chat.md. + +const MAX_LINES := 10 +const HISTORY_W := 460.0 +const HISTORY_H := 160.0 +const INPUT_H := 30.0 +const MARGIN_X := 16.0 +const MARGIN_BOTTOM := 16.0 +const GAP := 4.0 + +var _history: RichTextLabel +var _input_line: LineEdit +var _team_mode: bool = false +var _lines: Array[String] = [] + +@onready var _pause_menu = get_node_or_null("/root/World/PauseMenu") + + +func _ready() -> void: + layer = 5 + _build_ui() + ChatManager.message_received.connect(_on_message_received) + + +func _build_ui() -> void: + # Positioned bottom-left via anchor 1.0 (actual window bottom edge), not + # a hardcoded 800px design height — stretch mode is disabled (see + # project.godot), so nothing scales/repositions automatically. + var input_y2 := -MARGIN_BOTTOM + var input_y1 := input_y2 - INPUT_H + var history_y2 := input_y1 - GAP + var history_y1 := history_y2 - HISTORY_H + var x1 := MARGIN_X + var x2 := MARGIN_X + HISTORY_W + + var panel := PanelContainer.new() + var ps := StyleBoxFlat.new() + ps.bg_color = Color(0.02, 0.03, 0.06, 0.55) + ps.set_corner_radius_all(6) + panel.add_theme_stylebox_override("panel", ps) + panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + # Anchors/offsets set as plain property assignments, not sequential + # set_anchor_and_offset() calls — that method's default + # push_opposite_anchor=true drags the opposite side along when the two + # calls momentarily disagree (e.g. top set to anchor 1.0 while bottom is + # still its 0.0 default), corrupting layout. See team_select.gd's + # _place() for the fuller writeup of this bug. + panel.anchor_left = 0.0 + panel.anchor_right = 0.0 + panel.anchor_top = 1.0 + panel.anchor_bottom = 1.0 + panel.offset_left = x1 + panel.offset_right = x2 + panel.offset_top = history_y1 + panel.offset_bottom = history_y2 + add_child(panel) + + _history = RichTextLabel.new() + _history.bbcode_enabled = true + _history.scroll_active = true + _history.scroll_following = true + _history.mouse_filter = Control.MOUSE_FILTER_IGNORE + _history.add_theme_font_size_override("normal_font_size", 15) + _history.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _history.offset_left = 8 + _history.offset_top = 4 + _history.offset_right = -8 + _history.offset_bottom = -4 + panel.add_child(_history) + + _input_line = LineEdit.new() + _input_line.anchor_left = 0.0 + _input_line.anchor_right = 0.0 + _input_line.anchor_top = 1.0 + _input_line.anchor_bottom = 1.0 + _input_line.offset_left = x1 + _input_line.offset_right = x2 + _input_line.offset_top = input_y1 + _input_line.offset_bottom = input_y2 + _input_line.max_length = ChatManager.MAX_MESSAGE_LENGTH + var ib := StyleBoxFlat.new() + ib.bg_color = Color(0.02, 0.03, 0.06, 0.85) + ib.set_corner_radius_all(4) + ib.set_border_width_all(1) + ib.border_color = Color(0.3, 0.5, 0.75, 0.8) + _input_line.add_theme_stylebox_override("normal", ib) + _input_line.add_theme_stylebox_override("focus", ib) + _input_line.add_theme_font_size_override("font_size", 15) + _input_line.caret_blink = true + _input_line.caret_blink_interval = 0.5 + _input_line.caret_force_displayed = true + _input_line.add_theme_color_override("caret_color", Color(0.7, 0.9, 1.0)) + _input_line.add_theme_constant_override("caret_width", 9) + _input_line.visible = false + _input_line.text_submitted.connect(_on_text_submitted) + _input_line.focus_exited.connect(_close_chat) + add_child(_input_line) + + +func _input(event: InputEvent) -> void: + if not _input_line.visible: + if _pause_menu and _pause_menu.is_paused(): + return + if event.is_action_pressed("chat_all"): + _open_chat(false) + get_viewport().set_input_as_handled() + elif event.is_action_pressed("chat_team"): + _open_chat(true) + get_viewport().set_input_as_handled() + elif event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE: + _close_chat() + get_viewport().set_input_as_handled() + + +func _open_chat(team_only: bool) -> void: + _team_mode = team_only + _input_line.placeholder_text = "[TEAM] Message..." if team_only else "Message..." + _input_line.text = "" + _input_line.visible = true + _input_line.grab_focus() + GameConfig.chat_focused = true + + +func _close_chat() -> void: + _input_line.visible = false + if _input_line.has_focus(): + _input_line.release_focus() + GameConfig.chat_focused = false + + +func _on_text_submitted(text: String) -> void: + ChatManager.send_chat(text, _team_mode) + _close_chat() + + +func _on_message_received(sender_name: String, sender_race: int, team_only: bool, text: String) -> void: + var name_color := _race_color(sender_race).to_html(false) + var safe_name := _escape_bbcode(sender_name) + var safe_text := _escape_bbcode(text) + var line: String + if team_only: + line = "[color=#7fdc7f][TEAM][/color] [color=#%s]%s[/color]: %s" % [name_color, safe_name, safe_text] + else: + line = "[color=#%s]%s[/color]: %s" % [name_color, safe_name, safe_text] + + _lines.append(line) + if _lines.size() > MAX_LINES: + _lines.pop_front() + _history.text = "\n".join(_lines) + + +func _race_color(race_id: int) -> Color: + for race in TeamSelect.RACES: + if race.id == race_id: + return race.color + return Color(0.85, 0.87, 0.92) + + +func _escape_bbcode(s: String) -> String: + return s.replace("[", "[lb]") diff --git a/spacewar/chat/chat_box.gd.uid b/spacewar/chat/chat_box.gd.uid new file mode 100644 index 0000000..463304a --- /dev/null +++ b/spacewar/chat/chat_box.gd.uid @@ -0,0 +1 @@ +uid://c4tvxxo8uvqrt diff --git a/spacewar/chat/chat_box.tscn b/spacewar/chat/chat_box.tscn new file mode 100644 index 0000000..f436954 --- /dev/null +++ b/spacewar/chat/chat_box.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://cchatbox01a"] + +[ext_resource type="Script" path="res://chat/chat_box.gd" id="1_script"] + +[node name="ChatBox" type="CanvasLayer"] +script = ExtResource("1_script") diff --git a/spacewar/menu/main_menu.gd b/spacewar/menu/main_menu.gd index 46e2651..745e552 100644 --- a/spacewar/menu/main_menu.gd +++ b/spacewar/menu/main_menu.gd @@ -75,11 +75,15 @@ func _add_play_section() -> void: const BTN_W := 480.0 const BTN_H := 230.0 const GAP := 40.0 - const LEFT_X := (1280.0 - (BTN_W * 2.0 + GAP)) / 2.0 # 140 const TOP_Y := 220.0 + # The whole row (both buttons + gap) is centered on the actual screen + # width via anchor 0.5, not a hardcoded 1280px design width — offsets + # below are all relative to that center point, not the left edge. + const HALF_GAP := GAP / 2.0 + const ROW_HALF_W := (BTN_W * 2.0 + GAP) / 2.0 _casual_btn = _make_mode_btn( - LEFT_X, TOP_Y, BTN_W, BTN_H, + -ROW_HALF_W, TOP_Y, -HALF_GAP, TOP_Y + BTN_H, "CASUAL", "25 vs 25", "Drop in, drop out anytime.\nNo rank at stake.", @@ -90,7 +94,7 @@ func _add_play_section() -> void: add_child(_casual_btn) _ranked_btn = _make_mode_btn( - LEFT_X + BTN_W + GAP, TOP_Y, BTN_W, BTN_H, + HALF_GAP, TOP_Y, ROW_HALF_W, TOP_Y + BTN_H, "RANKED", "5 vs 5", "Competitive ladder.\nYour rank is on the line.", @@ -103,10 +107,7 @@ func _add_play_section() -> void: var coming_soon := Label.new() coming_soon.text = "— COMING SOON —" - coming_soon.set_anchor_and_offset(SIDE_LEFT, 0.0, LEFT_X + BTN_W + GAP) - coming_soon.set_anchor_and_offset(SIDE_TOP, 0.0, TOP_Y + BTN_H - 36.0) - coming_soon.set_anchor_and_offset(SIDE_RIGHT, 0.0, LEFT_X + BTN_W * 2.0 + GAP) - coming_soon.set_anchor_and_offset(SIDE_BOTTOM, 0.0, TOP_Y + BTN_H - 8.0) + _place(coming_soon, 0.5, HALF_GAP, 0.5, ROW_HALF_W, TOP_Y + BTN_H - 36.0, TOP_Y + BTN_H - 8.0) coming_soon.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER coming_soon.vertical_alignment = VERTICAL_ALIGNMENT_CENTER coming_soon.add_theme_font_size_override("font_size", 11) @@ -117,10 +118,7 @@ func _add_play_section() -> void: # Callsign label var lbl := Label.new() lbl.text = "CALLSIGN" - lbl.set_anchor_and_offset(SIDE_LEFT, 0.0, LEFT_X) - lbl.set_anchor_and_offset(SIDE_TOP, 0.0, TOP_Y + BTN_H + 26.0) - lbl.set_anchor_and_offset(SIDE_RIGHT, 0.0, LEFT_X + 95.0) - lbl.set_anchor_and_offset(SIDE_BOTTOM, 0.0, TOP_Y + BTN_H + 62.0) + _place(lbl, 0.5, -ROW_HALF_W, 0.5, -ROW_HALF_W + 95.0, TOP_Y + BTN_H + 26.0, TOP_Y + BTN_H + 62.0) lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER lbl.add_theme_font_size_override("font_size", 12) lbl.add_theme_color_override("font_color", Color(0.45, 0.55, 0.70)) @@ -128,10 +126,7 @@ func _add_play_section() -> void: _name_input = LineEdit.new() _name_input.placeholder_text = "Enter your callsign..." - _name_input.set_anchor_and_offset(SIDE_LEFT, 0.0, LEFT_X + 100.0) - _name_input.set_anchor_and_offset(SIDE_TOP, 0.0, TOP_Y + BTN_H + 26.0) - _name_input.set_anchor_and_offset(SIDE_RIGHT, 0.0, LEFT_X + BTN_W * 2.0 + GAP) - _name_input.set_anchor_and_offset(SIDE_BOTTOM, 0.0, TOP_Y + BTN_H + 62.0) + _place(_name_input, 0.5, -ROW_HALF_W + 100.0, 0.5, ROW_HALF_W, TOP_Y + BTN_H + 26.0, TOP_Y + BTN_H + 62.0) _name_input.add_theme_font_size_override("font_size", 15) if GameConfig.player_name != "": _name_input.text = GameConfig.player_name @@ -140,10 +135,7 @@ func _add_play_section() -> void: _status_lbl = Label.new() _status_lbl.text = "" - _status_lbl.set_anchor_and_offset(SIDE_LEFT, 0.0, LEFT_X) - _status_lbl.set_anchor_and_offset(SIDE_TOP, 0.0, TOP_Y + BTN_H + 68.0) - _status_lbl.set_anchor_and_offset(SIDE_RIGHT, 0.0, LEFT_X + BTN_W * 2.0 + GAP) - _status_lbl.set_anchor_and_offset(SIDE_BOTTOM, 0.0, TOP_Y + BTN_H + 92.0) + _place(_status_lbl, 0.5, -ROW_HALF_W, 0.5, ROW_HALF_W, TOP_Y + BTN_H + 68.0, TOP_Y + BTN_H + 92.0) _status_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _status_lbl.add_theme_font_size_override("font_size", 13) _status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4)) @@ -153,16 +145,13 @@ func _add_play_section() -> void: func _make_mode_btn( - x: float, y: float, w: float, h: float, + left: float, top: float, right: float, bottom: float, mode: String, fmt: String, desc: String, bg: Color, accent: Color) -> Button: var btn := Button.new() btn.text = "" - btn.set_anchor_and_offset(SIDE_LEFT, 0.0, x) - btn.set_anchor_and_offset(SIDE_TOP, 0.0, y) - btn.set_anchor_and_offset(SIDE_RIGHT, 0.0, x + w) - btn.set_anchor_and_offset(SIDE_BOTTOM, 0.0, y + h) + _place(btn, 0.5, left, 0.5, right, top, bottom) var sn := _flat(bg.lightened(0.05), 12) sn.set_border_width_all(2) @@ -325,6 +314,25 @@ func _add_version_label() -> void: add_child(lbl) +# Sets all 4 anchors then all 4 offsets as plain property assignments — +# NOT via sequential set_anchor_and_offset() calls, whose default +# push_opposite_anchor=true drags the opposite side's anchor/offset along +# whenever the two calls momentarily disagree (e.g. left set to 0.5 while +# right is still its 0.0 default), corrupting layout for any non-0.0 anchor +# (0.5-centered elements hit this hardest). See _make_mode_btn's git history +# for how this was found — buttons rendered flush against the left edge +# instead of centered until this fix. +func _place(node: Control, anchor_left: float, left: float, anchor_right: float, right: float, top: float, bottom: float) -> void: + node.anchor_left = anchor_left + node.anchor_right = anchor_right + node.anchor_top = 0.0 + node.anchor_bottom = 0.0 + node.offset_left = left + node.offset_right = right + node.offset_top = top + node.offset_bottom = bottom + + func _flat(col: Color, radius: int = 0) -> StyleBoxFlat: var s := StyleBoxFlat.new() s.bg_color = col diff --git a/spacewar/menu/pause_menu.gd b/spacewar/menu/pause_menu.gd index 74bc7ca..0f3ad65 100644 --- a/spacewar/menu/pause_menu.gd +++ b/spacewar/menu/pause_menu.gd @@ -10,11 +10,15 @@ func _ready() -> void: func _input(event: InputEvent) -> void: - if event.is_action_pressed("toggle_pause"): + if event.is_action_pressed("toggle_pause") and not GameConfig.chat_focused: _toggle() get_viewport().set_input_as_handled() +func is_paused() -> bool: + return _root.visible + + func _toggle() -> void: _root.visible = not _root.visible diff --git a/spacewar/menu/team_select.gd b/spacewar/menu/team_select.gd index 0287556..d89bae8 100644 --- a/spacewar/menu/team_select.gd +++ b/spacewar/menu/team_select.gd @@ -107,9 +107,11 @@ const RACES := [ }, ] -# Row layout constants (screen is 1280×800) -const ROW_X1 := 120.0 -const ROW_X2 := 1160.0 +# Row layout constants — ROW_X1/ROW_X2_MARGIN are left/right margins (not +# absolute x-coordinates), so rows stretch to fill the actual window width; +# stretch mode is disabled (project.godot), so nothing scales automatically. +const ROW_X1 := 120.0 +const ROW_X2_MARGIN := 120.0 const ROW_H := 115.0 const ROW_GAP := 6.0 const ROW_START := 175.0 @@ -166,7 +168,7 @@ func _build_root() -> void: _root.add_child(overlay) _loading_lbl = _label(_root, "LOADING MATCH...", 20, Color(0.5, 0.6, 0.75), - 0, 380, 1280, 420, HORIZONTAL_ALIGNMENT_CENTER) + 0, 380, 0, 420, HORIZONTAL_ALIGNMENT_CENTER, 0.0, 1.0) func _swap_content() -> Control: @@ -185,18 +187,24 @@ func _show_race_selection() -> void: var c := _swap_content() _label(c, "CHOOSE YOUR FACTION", 32, Color(0.42, 0.87, 1.0), - 0, 105, 1280, 160, HORIZONTAL_ALIGNMENT_CENTER) + 0, 105, 0, 160, HORIZONTAL_ALIGNMENT_CENTER, 0.0, 1.0) _label(c, "This match: %s vs %s" % [_offered[0].name, _offered[1].name], 13, Color(0.45, 0.58, 0.72), - 0, 170, 1280, 200, HORIZONTAL_ALIGNMENT_CENTER) + 0, 170, 0, 200, HORIZONTAL_ALIGNMENT_CENTER, 0.0, 1.0) + + # Both race buttons are a fixed 440px wide with a 100px gap, centered on + # the actual screen width via anchor 0.5 — not a hardcoded 1280px design + # width, since stretch mode is disabled (see project.godot). + const BTN_W := 440.0 + const HALF_GAP := 50.0 var left_btn := _make_race_btn(_offered[0]) - _place(left_btn, 150, 225, 590, 540) + _place(left_btn, -(BTN_W + HALF_GAP), 225, -HALF_GAP, 540, 0.5, 0.5) left_btn.pressed.connect(_on_race_chosen.bind(_offered[0])) c.add_child(left_btn) var right_btn := _make_race_btn(_offered[1]) - _place(right_btn, 690, 225, 1130, 540) + _place(right_btn, HALF_GAP, 225, BTN_W + HALF_GAP, 540, 0.5, 0.5) right_btn.pressed.connect(_on_race_chosen.bind(_offered[1])) c.add_child(right_btn) @@ -212,15 +220,19 @@ func _show_ship_selection() -> void: var c := _swap_content() _label(c, "SELECT YOUR SHIP", 30, Color(0.42, 0.87, 1.0), - 0, 85, 1280, 130, HORIZONTAL_ALIGNMENT_CENTER) + 0, 85, 0, 130, HORIZONTAL_ALIGNMENT_CENTER, 0.0, 1.0) _label(c, "Playing as — " + _chosen_race.name, 14, _chosen_race.color, - 0, 135, 1280, 165, HORIZONTAL_ALIGNMENT_CENTER) + 0, 135, 0, 165, HORIZONTAL_ALIGNMENT_CENTER, 0.0, 1.0) var ships: Array = _chosen_race.ships for i in ships.size(): var y := ROW_START + i * (ROW_H + ROW_GAP) var btn := _make_ship_row(ships[i], _chosen_race.color) - _place(btn, ROW_X1, y, ROW_X2, y + ROW_H) + # ROW_X1 margin from the left edge, ROW_X2_MARGIN margin from the + # actual right edge (anchor_right=1.0) — the row stretches to fill + # whatever width is available instead of stopping at a hardcoded + # 1280px design width. + _place(btn, ROW_X1, y, -ROW_X2_MARGIN, y + ROW_H, 0.0, 1.0) btn.pressed.connect(_on_ship_chosen.bind(ships[i])) c.add_child(btn) @@ -368,23 +380,44 @@ func _make_ship_row(ship: Dictionary, accent: Color) -> Button: func _label(parent: Control, text: String, font_size: int, color: Color, x1: float, y1: float, x2: float, y2: float, - align: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT) -> Label: + align: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT, + anchor_left: float = 0.0, anchor_right: float = 0.0) -> Label: var lbl := Label.new() lbl.text = text lbl.horizontal_alignment = align lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER lbl.add_theme_font_size_override("font_size", font_size) lbl.add_theme_color_override("font_color", color) - _place(lbl, x1, y1, x2, y2) + _place(lbl, x1, y1, x2, y2, anchor_left, anchor_right) parent.add_child(lbl) return lbl -func _place(node: Control, x1: float, y1: float, x2: float, y2: float) -> void: - node.set_anchor_and_offset(SIDE_LEFT, 0.0, x1) - node.set_anchor_and_offset(SIDE_TOP, 0.0, y1) - node.set_anchor_and_offset(SIDE_RIGHT, 0.0, x2) - node.set_anchor_and_offset(SIDE_BOTTOM, 0.0, y2) +# anchor_left/anchor_right default to 0.0 (pixels measured from the left +# edge), matching the design-resolution layout this screen was originally +# built at. Pass 1.0 for anchor_right to make x2 a margin from the actual +# right edge instead (stretches with window width), or 0.5 for both to +# center a fixed-size element regardless of window width — see call sites +# below for which each element needs (stretch mode is disabled, so nothing +# scales automatically the way it used to under canvas_items stretch). +# +# Anchors and offsets are set as plain property assignments, NOT via +# sequential set_anchor_and_offset() calls — that method's default +# push_opposite_anchor=true drags the opposite side's anchor/offset along +# whenever the two calls momentarily disagree (e.g. left set to 0.5 while +# right is still its 0.0 default), corrupting layout for any non-0.0 anchor. +# Found via main_menu.gd's play buttons rendering flush against the left +# edge instead of centered. +func _place(node: Control, x1: float, y1: float, x2: float, y2: float, + anchor_left: float = 0.0, anchor_right: float = 0.0) -> void: + node.anchor_left = anchor_left + node.anchor_right = anchor_right + node.anchor_top = 0.0 + node.anchor_bottom = 0.0 + node.offset_left = x1 + node.offset_top = y1 + node.offset_right = x2 + node.offset_bottom = y2 func _flat(col: Color, radius: int = 0) -> StyleBoxFlat: diff --git a/spacewar/project.godot b/spacewar/project.godot index ce4a86a..0bcfba5 100644 --- a/spacewar/project.godot +++ b/spacewar/project.godot @@ -21,13 +21,15 @@ GameConfig="*res://autoload/game_config.gd" NetworkManager="*res://autoload/network_manager.gd" PlayerRegistry="*res://autoload/player_registry.gd" MatchmakingClient="*res://autoload/matchmaking_client.gd" +ChatManager="*res://autoload/chat_manager.gd" +BotManager="*res://bots/bot_manager.gd" [display] window/size/viewport_width=1280 window/size/viewport_height=800 -window/stretch/mode="canvas_items" -window/stretch/aspect="expand" +window/size/mode=3 +window/stretch/mode="disabled" [input] @@ -62,6 +64,16 @@ toggle_pause={ , Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":6,"pressure":0.0,"pressed":false,"script":null) ] } +chat_all={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":84,"key_label":0,"unicode":116,"location":0,"echo":false,"script":null) +] +} +chat_team={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":89,"key_label":0,"unicode":121,"location":0,"echo":false,"script":null) +] +} [physics] diff --git a/spacewar/ships/ship_movement.gd b/spacewar/ships/ship_movement.gd index 136eda3..144faa2 100644 --- a/spacewar/ships/ship_movement.gd +++ b/spacewar/ships/ship_movement.gd @@ -1,8 +1,17 @@ extends CharacterBody2D +class_name Ship # Set by World._spawn_ship() before this node enters the tree. var peer_id: int = 0 +# Bots use a negative peer_id (see bots/bot_manager.gd) so they ride every +# piece of existing networked-ship infrastructure — spawning, loadout, +# position/health/visibility sync, bullet attribution — for free, all of +# which is already keyed off peer_id and none of which cares whether it came +# from ENet or from BotManager. Set by BotManager right after spawning, only +# ever non-null on the server (bots have no owner/remote client). +var bot_ai: BotAI = null + var _fire_cooldown: float = 0.0 var health: int = 0 var _dead: bool = false @@ -37,6 +46,7 @@ var _interp_elapsed: float = 0.0 func _ready() -> void: _fixed_delta = 1.0 / Engine.physics_ticks_per_second _is_owner = peer_id == multiplayer.get_unique_id() + add_to_group("ships") # lets BotAI find nearby targets without a World reference # Only ever claim the camera for the local owner. Whichever Camera2D # enters the SceneTree first auto-claims the viewport's active camera @@ -160,7 +170,9 @@ func _reconcile(server_tick: int, server_pos: Vector2, server_rot: float, server func _server_tick(delta: float) -> void: var input: Dictionary - if _is_owner: + if peer_id < 0: + input = bot_ai.compute_input(delta) if bot_ai else {} + elif _is_owner: input = _sample_local_input() else: if not _remote_inputs.is_empty(): @@ -198,6 +210,8 @@ func _remote_tick(delta: float) -> void: # ── Shared movement step (deterministic — replayed during reconciliation) ─── func _sample_local_input() -> Dictionary: + if GameConfig.chat_focused: + return {"up": false, "down": false, "left": false, "right": false, "shoot": false} return { "up": Input.is_action_pressed("move_up"), "down": Input.is_action_pressed("move_down"), diff --git a/spacewar/world/world.gd b/spacewar/world/world.gd index 59f6b25..e637f9a 100644 --- a/spacewar/world/world.gd +++ b/spacewar/world/world.gd @@ -31,19 +31,25 @@ func _ready() -> void: NetworkManager.peer_left.connect(_on_peer_left) if multiplayer.is_server(): - _spawn_peer(multiplayer.get_unique_id()) + spawn_peer(multiplayer.get_unique_id()) for peer_id in multiplayer.get_peers(): - _spawn_peer(peer_id) + spawn_peer(peer_id) # Picks the match's 2 offered races once and caches them, so every caller # (the server's own TeamSelect and every remote client's request) converges -# on the same pair regardless of call order. +# on the same pair regardless of call order. Also kicks off bot fill for +# those 2 races — see bots/bot_manager.gd. func decide_offered_races() -> Array: if _offered_race_ids.is_empty(): var ids := range(1, TeamSelect.RACES.size() + 1) ids.shuffle() _offered_race_ids = [ids[0], ids[1]] + # Deferred: this can run from TeamSelect._ready(), which (as World's + # child) fires before World's own _ready() — before _players/_spawner + # are assigned. Deferring runs it after the whole scene's _ready + # cascade finishes, once World is actually spawn-ready. + BotManager.call_deferred("on_match_start", self, _offered_race_ids) return _offered_race_ids @@ -60,10 +66,19 @@ func _deliver_offered_races(race_ids: Array) -> void: _team_select.offer_races(race_ids) -func _spawn_peer(peer_id: int) -> void: +# Spawns (or returns the already-spawned) ship for a given peer_id — real +# (positive) or bot (negative, see bots/bot_manager.gd). Replicates to every +# client automatically via MultiplayerSpawner. +func spawn_peer(peer_id: int) -> Node: if _players.has_node(str(peer_id)): - return - _spawner.spawn(peer_id) + return _players.get_node(str(peer_id)) + return _spawner.spawn(peer_id) + + +func despawn_peer(peer_id: int) -> void: + var ship := _players.get_node_or_null(str(peer_id)) + if ship: + ship.queue_free() func _spawn_ship(peer_id: int) -> Node: @@ -91,15 +106,13 @@ func _spawn_bullet(data: Dictionary) -> Node: func _on_peer_joined(peer_id: int) -> void: if multiplayer.is_server(): - _spawn_peer(peer_id) + spawn_peer(peer_id) func _on_peer_left(peer_id: int) -> void: if not multiplayer.is_server(): return - var ship := _players.get_node_or_null(str(peer_id)) - if ship: - ship.queue_free() + despawn_peer(peer_id) # Tiles have no collision shapes of their own, so spawn one StaticBody2D per diff --git a/spacewar/world/world.tscn b/spacewar/world/world.tscn index 0d1bf14..7b46fe6 100644 --- a/spacewar/world/world.tscn +++ b/spacewar/world/world.tscn @@ -3,6 +3,7 @@ [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="Script" path="res://world/world.gd" id="4_world"] +[ext_resource type="PackedScene" uid="uid://cchatbox01a" path="res://chat/chat_box.tscn" id="5_chat"] [node name="World" type="Node2D" unique_id=1962020789] script = ExtResource("4_world") @@ -32,3 +33,5 @@ theme_override_font_sizes/font_size = 24 [node name="PauseMenu" parent="." instance=ExtResource("2_pm")] [node name="TeamSelect" parent="." instance=ExtResource("3_ts")] + +[node name="ChatBox" parent="." instance=ExtResource("5_chat")]