diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..30cf57e
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,10 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Ignored default folder with query files
+/queries/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 0000000..a55e7a1
--- /dev/null
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml
new file mode 100644
index 0000000..48f4f77
--- /dev/null
+++ b/.idea/dictionaries/project.xml
@@ -0,0 +1,7 @@
+
+
+
+ keys
+
+
+
\ No newline at end of file
diff --git a/.idea/examples.iml b/.idea/examples.iml
new file mode 100644
index 0000000..c956989
--- /dev/null
+++ b/.idea/examples.iml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..03d9549
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..704ff0e
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/prettier.xml b/.idea/prettier.xml
new file mode 100644
index 0000000..b0c1c68
--- /dev/null
+++ b/.idea/prettier.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/raid-survival/.dockerignore b/raid-survival/.dockerignore
new file mode 100644
index 0000000..c2658d7
--- /dev/null
+++ b/raid-survival/.dockerignore
@@ -0,0 +1 @@
+node_modules/
diff --git a/raid-survival/.gitignore b/raid-survival/.gitignore
new file mode 100644
index 0000000..ba6da87
--- /dev/null
+++ b/raid-survival/.gitignore
@@ -0,0 +1,134 @@
+### VisualStudioCode template
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+!.vscode/*.code-snippets
+
+# Local History for Visual Studio Code
+.history/
+
+# Built Visual Studio Code Extensions
+*.vsix
+
+### JetBrains template
+
+# User-specific stuff
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+
+# Generated files
+.idea/**/contentModel.xml
+
+# Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+
+# File-based project format
+*.iws
+
+# Editor-based Rest Client
+.idea/httpRequests
+
+### Node template
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+web_modules/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional stylelint cache
+.stylelintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variable files
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# yarn v2
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+
+### NanoForge template
+# Compilation directories
+.nanoforge/client
+.nanoforge/server
diff --git a/raid-survival/.idea/.gitignore b/raid-survival/.idea/.gitignore
new file mode 100644
index 0000000..30cf57e
--- /dev/null
+++ b/raid-survival/.idea/.gitignore
@@ -0,0 +1,10 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Ignored default folder with query files
+/queries/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/raid-survival/.idea/inspectionProfiles/Project_Default.xml b/raid-survival/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..03d9549
--- /dev/null
+++ b/raid-survival/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/raid-survival/.idea/modules.xml b/raid-survival/.idea/modules.xml
new file mode 100644
index 0000000..40cdb67
--- /dev/null
+++ b/raid-survival/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/raid-survival/.idea/prettier.xml b/raid-survival/.idea/prettier.xml
new file mode 100644
index 0000000..b0c1c68
--- /dev/null
+++ b/raid-survival/.idea/prettier.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/raid-survival/.idea/raid-survival.iml b/raid-survival/.idea/raid-survival.iml
new file mode 100644
index 0000000..2155317
--- /dev/null
+++ b/raid-survival/.idea/raid-survival.iml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/raid-survival/.idea/vcs.xml b/raid-survival/.idea/vcs.xml
new file mode 100644
index 0000000..6c0b863
--- /dev/null
+++ b/raid-survival/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/raid-survival/.nanoforge/client.save.json b/raid-survival/.nanoforge/client.save.json
new file mode 100644
index 0000000..a1b3e67
--- /dev/null
+++ b/raid-survival/.nanoforge/client.save.json
@@ -0,0 +1,77 @@
+{
+ "libraries": [
+ {
+ "id": "assetManagerLibrary",
+ "type": "asset-manager",
+ "name": "AssetManagerLibrary",
+ "path": "@nanoforge-dev/asset-manager",
+ "params": []
+ },
+ {
+ "id": "ecsLibrary",
+ "type": "component-system",
+ "name": "ECSClientLibrary",
+ "path": "@nanoforge-dev/ecs-client",
+ "params": []
+ },
+ {
+ "id": "graphicsLibrary",
+ "type": "graphics",
+ "name": "Graphics2DLibrary",
+ "path": "@nanoforge-dev/graphics-2d",
+ "params": []
+ },
+ {
+ "id": "inputLibrary",
+ "type": "input",
+ "name": "InputLibrary",
+ "path": "@nanoforge-dev/input",
+ "params": []
+ },
+ {
+ "id": "musicLibrary",
+ "type": "music",
+ "name": "MusicLibrary",
+ "path": "@nanoforge-dev/music",
+ "params": []
+ },
+ {
+ "id": "networkLibrary",
+ "type": "network",
+ "name": "NetworkClientLibrary",
+ "path": "@nanoforge-dev/network-client",
+ "params": []
+ },
+ {
+ "id": "soundLibrary",
+ "type": "sound",
+ "name": "SoundLibrary",
+ "path": "@nanoforge-dev/sound",
+ "params": []
+ }
+ ],
+ "components": [
+ {
+ "name": "ExampleComponent",
+ "path": "./components/example.component.ts",
+ "paramsNames": ["paramA", "paramB", "paramC"]
+ }
+ ],
+ "systems": [
+ {
+ "name": "exampleSystem",
+ "path": "./systems/example.system.ts"
+ }
+ ],
+ "entities": [
+ {
+ "id": "exampleEntity",
+ "components": {
+ "ExampleComponent": {
+ "paramA": "example",
+ "paramB": 10
+ }
+ }
+ }
+ ]
+}
diff --git a/raid-survival/.nanoforge/server.save.json b/raid-survival/.nanoforge/server.save.json
new file mode 100644
index 0000000..56f214b
--- /dev/null
+++ b/raid-survival/.nanoforge/server.save.json
@@ -0,0 +1,49 @@
+{
+ "libraries": [
+ {
+ "id": "assetManagerLibrary",
+ "type": "asset-manager",
+ "name": "AssetManagerLibrary",
+ "path": "@nanoforge-dev/asset-manager",
+ "params": []
+ },
+ {
+ "id": "ecsLibrary",
+ "type": "component-system",
+ "name": "ECSServerLibrary",
+ "path": "@nanoforge-dev/ecs-server",
+ "params": []
+ },
+ {
+ "id": "networkLibrary",
+ "type": "network",
+ "name": "NetworkServerLibrary",
+ "path": "@nanoforge-dev/network-server",
+ "params": []
+ }
+ ],
+ "components": [
+ {
+ "name": "ExampleComponent",
+ "path": "./components/example.component.ts",
+ "paramsNames": ["paramA", "paramB", "paramC"]
+ }
+ ],
+ "systems": [
+ {
+ "name": "exampleSystem",
+ "path": "./systems/example.system.ts"
+ }
+ ],
+ "entities": [
+ {
+ "id": "exampleEntity",
+ "components": {
+ "ExampleComponent": {
+ "paramA": "example",
+ "paramB": 10
+ }
+ }
+ }
+ ]
+}
diff --git a/raid-survival/.prettierignore b/raid-survival/.prettierignore
new file mode 100644
index 0000000..2f0ede7
--- /dev/null
+++ b/raid-survival/.prettierignore
@@ -0,0 +1,8 @@
+# Ignore files for PNPM, NPM and YARN
+pnpm-lock.yaml
+package-lock.json
+yarn.lock
+bun.lock
+
+/node_modules
+/.nanoforge
diff --git a/raid-survival/Dockerfile b/raid-survival/Dockerfile
new file mode 100644
index 0000000..4c4cd10
--- /dev/null
+++ b/raid-survival/Dockerfile
@@ -0,0 +1,18 @@
+FROM node:25-slim
+
+ENV PNPM_HOME="/pnpm"
+ENV PATH="$PNPM_HOME:$PATH"
+RUN npm install -g pnpm
+
+WORKDIR /app
+
+COPY package.json ./
+COPY pnpm-lock.yaml ./
+
+RUN pnpm install --frozen-lockfile --allow-build=bun
+
+COPY . .
+
+RUN pnpm run build
+
+CMD ["pnpm", "run", "start"]
diff --git a/raid-survival/README.md b/raid-survival/README.md
new file mode 100644
index 0000000..b1a3d4e
--- /dev/null
+++ b/raid-survival/README.md
@@ -0,0 +1,158 @@
+# raid-survival
+
+---
+
+## Prerequisites
+
+- [Node.js](https://nodejs.org/) 18+
+- Package manager: **pnpm**
+- [NanoForge CLI](https://www.npmjs.com/package/@nanoforge-dev/cli): `pnpm install -g @nanoforge-dev/cli`
+
+---
+
+## Installation
+
+```bash
+pnpm install
+```
+
+---
+
+## Development
+
+Start the game in watch mode with hot reload:
+
+```bash
+nf dev
+```
+
+---
+
+## Project structure
+
+```
+raid-survival/
+├── .nanoforge/ # NanoForge internal save files (do not edit manually)
+├── client/ # Client-side code (runs in the browser)
+│ ├── static/ # Static assets
+│ ├── components/ # ECS components
+│ ├── systems/ # ECS systems
+├── server/ # Server-side code
+│ ├── static/
+│ ├── components/
+│ ├── systems/
+├── package.json
+├── tsconfig.json
+└── nanoforge.config.json # Game configuration
+```
+
+---
+
+## Modifying the game
+
+### Add a component
+
+```bash
+nf generate component --part client
+```
+
+A component is a plain class that holds data for an entity. Edit the generated file in `client/components/`:
+
+```ts
+export class MyComponent {
+ constructor(public speed: number) {}
+}
+
+export default MyComponent.name;
+```
+
+### Add a system
+
+```bash
+nf generate system --part client
+```
+
+A system is a function that runs every tick and operates on entities that have specific components. Edit the generated file in `client/systems/`:
+
+```ts
+import { type Context } from "@nanoforge-dev/common";
+import { type Registry } from "@nanoforge-dev/ecs-client";
+import { MyComponent } from "../components/my.component";
+
+export const mySystem = (registry: Registry, ctx: Context) => {
+ const entities = registry.getZipper([MyComponent]);
+
+ entities.forEach((entity) => {
+ entity.MyComponent.speed += 1;
+ });
+};
+
+export default mySystem.name;
+```
+
+
+### Server-side code
+
+Server components and systems live in `server/` and are imported from `@nanoforge-dev/ecs-server`. They follow the same pattern as the client side.
+
+Configure the server connection in `.env`:
+
+| Variable | Default | Description |
+|---------------------------------------|-------------|---------------------------------|
+| `NANOFORGE_CLIENT_SERVER_ADDRESS` | `127.0.0.1` | Server address (client-side) |
+| `NANOFORGE_CLIENT_SERVER_TCP_PORT` | `4444` | TCP port the client connects to |
+| `NANOFORGE_CLIENT_SERVER_UDP_PORT` | `4445` | UDP port the client connects to |
+| `NANOFORGE_SERVER_LISTENING_TCP_PORT` | `4444` | TCP port the server listens on |
+| `NANOFORGE_SERVER_LISTENING_UDP_PORT` | `4445` | UDP port the server listens on |
+
+---
+
+## Build
+
+Compile the game for production:
+
+```bash
+pnpm run build
+# or
+nf build
+```
+
+Output is written to the `.nanoforge/` directory.
+
+---
+
+## Run
+
+Start the game server and open it in a browser:
+
+```bash
+pnpm run start
+# or
+nf start
+```
+
+---
+
+## Code quality
+
+```bash
+# Check formatting and linting
+pnpm run lint
+
+# Auto-fix formatting and linting
+pnpm run format
+```
+
+---
+
+## CLI reference
+
+| Command | Description |
+|--------------------------------|-------------------------------------------|
+| `nf dev` | Start in development mode with hot reload |
+| `nf build` | Build for production |
+| `nf start` | Run the built game |
+| `nf generate component ` | Scaffold a new component |
+| `nf generate system ` | Scaffold a new system |
+
+Full CLI documentation:
diff --git a/raid-survival/client/building-catalog.ts b/raid-survival/client/building-catalog.ts
new file mode 100644
index 0000000..a99c723
--- /dev/null
+++ b/raid-survival/client/building-catalog.ts
@@ -0,0 +1,43 @@
+// Mirrors server/building-catalog.ts - client and server are separate bundles, so this can't be
+// a shared import; keep the keys and costs in sync manually. Only carries what the client needs
+// to render/build with: cost (build bar affordability + the placement preview) and a display
+// color (the server doesn't care about color).
+export const BUILDING_CATALOG = {
+ wall: { cost: 20, color: "#6B5B4A", label: "Wall" },
+} as const;
+
+export type BuildingType = keyof typeof BUILDING_CATALOG;
+
+// World-space axis-aligned box - anything that can block a placement (the lobby, an existing
+// building).
+export interface OccupiedBox {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+// Identical to server/building-catalog.ts's canPlaceBuilding - must produce the exact same
+// answer given the same inputs, or the preview would lie about what's actually placeable. See
+// that file for why this checks real box overlap (the lobby's footprint isn't tile-aligned)
+// rather than tile-index equality.
+export function canPlaceBuilding(
+ tileX: number,
+ tileY: number,
+ tileSize: number,
+ cols: number,
+ rows: number,
+ isTreeCell: (col: number, row: number) => boolean,
+ obstacles: OccupiedBox[],
+): boolean {
+ if (!Number.isInteger(tileX) || !Number.isInteger(tileY)) return false;
+ if (tileX < 0 || tileY < 0 || tileX >= cols || tileY >= rows) return false;
+ if (isTreeCell(tileX, tileY)) return false;
+
+ const x = tileX * tileSize;
+ const y = tileY * tileSize;
+
+ return !obstacles.some(
+ (box) => x < box.x + box.width && x + tileSize > box.x && y < box.y + box.height && y + tileSize > box.y,
+ );
+}
diff --git a/raid-survival/client/components/ammo-hud.component.ts b/raid-survival/client/components/ammo-hud.component.ts
new file mode 100644
index 0000000..efbc3ce
--- /dev/null
+++ b/raid-survival/client/components/ammo-hud.component.ts
@@ -0,0 +1,13 @@
+import { Text } from "@nanoforge-dev/graphics-2d";
+
+// Ref to the konva Text node built once at game start
+// (client/systems/packet-handlers/start-game-packet.handler.ts) and mutated in place by
+// ammo-packet.handler.ts on every ammo packet - same pattern as MoneyHudComponent.
+export class AmmoHudComponent {
+ name = this.constructor.name;
+
+ constructor(public text: Text) {}
+}
+
+// * Required to generate code
+export default AmmoHudComponent.name;
diff --git a/raid-survival/client/components/build-mode.component.ts b/raid-survival/client/components/build-mode.component.ts
new file mode 100644
index 0000000..f646082
--- /dev/null
+++ b/raid-survival/client/components/build-mode.component.ts
@@ -0,0 +1,42 @@
+import { Rect, Shape, Text } from "@nanoforge-dev/graphics-2d";
+import { type BuildingType } from "../building-catalog";
+
+export interface BuildBarButton {
+ buildingType: BuildingType;
+ rect: Rect;
+ text: Text;
+}
+
+// Screen-space (hudLayer-local, which is unscaled/unpositioned so local === screen) bounds of
+// the whole build bar - build-mode.system.ts checks the cursor against this before treating a
+// click as a world-space placement click, so clicking a bar button doesn't also place a
+// building on whatever tile happens to be behind it.
+export interface ScreenBox {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+// Singleton - built once at game start (client/systems/packet-handlers/
+// start-game-packet.handler.ts) and owned entirely by build-mode.system.ts from then on.
+export class BuildModeComponent {
+ name = this.constructor.name;
+
+ active: boolean = false;
+ selectedBuildingType: BuildingType | null = null;
+ // Edge-detection for level-triggered inputs (isKeyPressed reports "held", not "just pressed") -
+ // without these, holding the key/mouse button down would re-fire every single frame.
+ wasTogglePressed: boolean = false;
+ wasPlaceClickPressed: boolean = false;
+
+ constructor(
+ public gridShape: Shape,
+ public previewRect: Rect,
+ public barButtons: BuildBarButton[],
+ public barBounds: ScreenBox,
+ ) {}
+}
+
+// * Required to generate code
+export default BuildModeComponent.name;
diff --git a/raid-survival/client/components/building.component.ts b/raid-survival/client/components/building.component.ts
new file mode 100644
index 0000000..c89f06b
--- /dev/null
+++ b/raid-survival/client/components/building.component.ts
@@ -0,0 +1,12 @@
+import { type BuildingType } from "../building-catalog";
+
+// Marker + which catalog entry - used by build-mode's occupancy preview to know where existing
+// buildings already are.
+export class Building {
+ name = this.constructor.name;
+
+ constructor(public buildingType: BuildingType) {}
+}
+
+// * Required to generate code
+export default Building.name;
diff --git a/raid-survival/client/components/children.component.ts b/raid-survival/client/components/children.component.ts
new file mode 100644
index 0000000..42c7a05
--- /dev/null
+++ b/raid-survival/client/components/children.component.ts
@@ -0,0 +1,16 @@
+import {Vector2d} from "@nanoforge-dev/graphics-2d";
+
+export interface ChildrenOptions {
+ LocalTransform?: Vector2d;
+}
+
+export class ChildrenComponent {
+ name = this.constructor.name;
+ parentId: number;
+ options: ChildrenOptions;
+
+ constructor(parentId: number, options: ChildrenOptions) {
+ this.parentId = parentId;
+ this.options = options;
+ }
+}
diff --git a/raid-survival/client/components/cursor.component.ts b/raid-survival/client/components/cursor.component.ts
new file mode 100644
index 0000000..747e5df
--- /dev/null
+++ b/raid-survival/client/components/cursor.component.ts
@@ -0,0 +1,8 @@
+// Marker on the custom crosshair HUD sprite entity (built once per game - see launchGame in
+// start-game-packet.handler.ts) - lets cursor.system.ts find it uniquely.
+export class CursorComponent {
+ name = this.constructor.name;
+}
+
+// * Required to generate code
+export default CursorComponent.name;
diff --git a/raid-survival/client/components/direction-rotator.component.ts b/raid-survival/client/components/direction-rotator.component.ts
new file mode 100644
index 0000000..e64439b
--- /dev/null
+++ b/raid-survival/client/components/direction-rotator.component.ts
@@ -0,0 +1,15 @@
+export class DirectionRotatorComponent {
+ name = this.constructor.name;
+ enable: boolean = true;
+ offset: number;
+ // A sprite that rotates through the full circle (the held weapon) reads upside-down for half
+ // its arc unless mirrored vertically while aiming left - see rotate-to-direction.system.ts.
+ // Off by default (e.g. the hand) since not every rotating sprite needs it.
+ mirrorWhenFacingLeft: boolean;
+
+ constructor(offset: number = 0, enable: boolean = true, mirrorWhenFacingLeft: boolean = false) {
+ this.offset = offset;
+ this.enable = enable;
+ this.mirrorWhenFacingLeft = mirrorWhenFacingLeft;
+ }
+}
diff --git a/raid-survival/client/components/direction.component.ts b/raid-survival/client/components/direction.component.ts
new file mode 100644
index 0000000..2c14047
--- /dev/null
+++ b/raid-survival/client/components/direction.component.ts
@@ -0,0 +1,11 @@
+export class Direction {
+ name = this.constructor.name;
+
+ constructor(
+ public x: number,
+ public y: number,
+ ) {}
+}
+
+// * Required to generate code
+export default Direction.name;
diff --git a/raid-survival/client/components/essentials/transform.component.ts b/raid-survival/client/components/essentials/transform.component.ts
new file mode 100644
index 0000000..6fef173
--- /dev/null
+++ b/raid-survival/client/components/essentials/transform.component.ts
@@ -0,0 +1,12 @@
+export class TransformComponent {
+ name = this.constructor.name;
+
+ constructor(
+ public x: number = 0,
+ public y: number = 0,
+ public rotation: number = 0,
+ ) {}
+}
+
+// * Required to generate code
+export default TransformComponent.name;
diff --git a/raid-survival/client/components/essentials/velocity.component.ts b/raid-survival/client/components/essentials/velocity.component.ts
new file mode 100644
index 0000000..d9afbfb
--- /dev/null
+++ b/raid-survival/client/components/essentials/velocity.component.ts
@@ -0,0 +1,11 @@
+export class Velocity {
+ name = this.constructor.name;
+
+ constructor(
+ public x: number,
+ public y: number,
+ ) {}
+}
+
+// * Required to generate code
+export default Velocity.name;
diff --git a/raid-survival/client/components/essentials/z-index.component.ts b/raid-survival/client/components/essentials/z-index.component.ts
new file mode 100644
index 0000000..0f673a4
--- /dev/null
+++ b/raid-survival/client/components/essentials/z-index.component.ts
@@ -0,0 +1,7 @@
+export class ZIndexComponent {
+ name = this.constructor.name;
+
+ constructor(
+ public value: number = 0
+ ) {}
+}
\ No newline at end of file
diff --git a/raid-survival/client/components/floating-text.component.ts b/raid-survival/client/components/floating-text.component.ts
new file mode 100644
index 0000000..2a13b5b
--- /dev/null
+++ b/raid-survival/client/components/floating-text.component.ts
@@ -0,0 +1,14 @@
+// Marks a free-standing (no ChildrenComponent - it must outlive whatever it's about, so it must
+// NOT be swept by kill-packet.handler.ts's child cascade) Text entity as self-timed: rises and
+// fades over `duration` seconds, then floating-text.system.ts destroys and kills it itself. Never
+// touched by a server "kill" packet - this is purely client-local and ephemeral.
+export class FloatingTextComponent {
+ name = this.constructor.name;
+
+ constructor(public duration: number) {}
+
+ elapsed: number = 0;
+}
+
+// * Required to generate code
+export default FloatingTextComponent.name;
diff --git a/raid-survival/client/components/health-bar-fill.component.ts b/raid-survival/client/components/health-bar-fill.component.ts
new file mode 100644
index 0000000..750df58
--- /dev/null
+++ b/raid-survival/client/components/health-bar-fill.component.ts
@@ -0,0 +1,12 @@
+export class HealthBarFill {
+ name = this.constructor.name;
+
+ // The fill's cavity-left-edge X, in its parent's local coordinate space (frameLocalX + the
+ // cavity's own inset). Constant per health bar instance regardless of current health -
+ // needed to recompute LocalTransform.x on a hit without also needing to know the parent's
+ // sprite width (player vs lobby) again.
+ constructor(public cavityLocalX: number) {}
+}
+
+// * Required to generate code
+export default HealthBarFill.name;
diff --git a/raid-survival/client/components/health.component.ts b/raid-survival/client/components/health.component.ts
new file mode 100644
index 0000000..fd2451d
--- /dev/null
+++ b/raid-survival/client/components/health.component.ts
@@ -0,0 +1,11 @@
+export class Health {
+ name = this.constructor.name;
+
+ constructor(
+ public current: number,
+ public max: number,
+ ) {}
+}
+
+// * Required to generate code
+export default Health.name;
diff --git a/raid-survival/client/components/lobby/lobby-status.ts b/raid-survival/client/components/lobby/lobby-status.ts
new file mode 100644
index 0000000..27e539b
--- /dev/null
+++ b/raid-survival/client/components/lobby/lobby-status.ts
@@ -0,0 +1,27 @@
+export interface LobbyPlayer {
+ id: string;
+ username: string;
+}
+
+export enum LobbyState {
+ UNJOINED,
+ LOADING,
+ JOINED,
+}
+
+export enum LobbyAction {
+ EMPTY,
+ JOIN_LOBBY,
+ START_GAME
+}
+
+export class LobbyStatusComponent {
+ name = this.constructor.name;
+ username = "";
+ state: LobbyState = LobbyState.UNJOINED;
+ action: LobbyAction = LobbyAction.EMPTY;
+ players: LobbyPlayer[] = [];
+ // Set by join-lobby-packet.handler.ts on a rejected join ("full" / "in game"), rendered by
+ // MenuScene's join widget, cleared on the next successful join.
+ error: string | null = null;
+}
\ No newline at end of file
diff --git a/raid-survival/client/components/lobby/lobby.component.ts b/raid-survival/client/components/lobby/lobby.component.ts
new file mode 100644
index 0000000..87cc323
--- /dev/null
+++ b/raid-survival/client/components/lobby/lobby.component.ts
@@ -0,0 +1,6 @@
+export class Lobby {
+ name = this.constructor.name;
+}
+
+// * Required to generate code
+export default Lobby.name;
diff --git a/raid-survival/client/components/money-hud.component.ts b/raid-survival/client/components/money-hud.component.ts
new file mode 100644
index 0000000..a29f327
--- /dev/null
+++ b/raid-survival/client/components/money-hud.component.ts
@@ -0,0 +1,19 @@
+import { Text } from "@nanoforge-dev/graphics-2d";
+
+// Ref to the konva Text node built once at game start
+// (client/systems/packet-handlers/start-game-packet.handler.ts) and mutated in place by
+// money-packet.handler.ts on every money packet - same pattern as WaveHudComponent. `amount` is
+// tracked as a plain number too (not just baked into the display text) so other systems can read
+// the current balance without parsing it back out - build-mode.system.ts needs it to know
+// whether a placement is affordable.
+export class MoneyHudComponent {
+ name = this.constructor.name;
+
+ constructor(
+ public text: Text,
+ public amount: number,
+ ) {}
+}
+
+// * Required to generate code
+export default MoneyHudComponent.name;
diff --git a/raid-survival/client/components/move-controller.component.ts b/raid-survival/client/components/move-controller.component.ts
new file mode 100644
index 0000000..f42aef5
--- /dev/null
+++ b/raid-survival/client/components/move-controller.component.ts
@@ -0,0 +1,23 @@
+import { InputEnum } from "@nanoforge-dev/input";
+
+export class MoveController {
+ name = this.constructor.name;
+ public keyUp: InputEnum;
+ public keyDown: InputEnum;
+ public keyLeft: InputEnum;
+ public keyRight: InputEnum;
+ public movingUp: boolean = false;
+ public movingDown: boolean = false;
+ public movingLeft: boolean = false;
+ public movingRight: boolean = false;
+ public lastMoveKeys: string[] = []
+
+ constructor(clientConfig: {
+ keybinds: { up: InputEnum; left: InputEnum; down: InputEnum; right: InputEnum };
+ } = { keybinds: { up: InputEnum.KeyW, left: InputEnum.KeyA, down: InputEnum.KeyS, right: InputEnum.KeyD } }) {
+ this.keyUp = clientConfig.keybinds.up;
+ this.keyDown = clientConfig.keybinds.down;
+ this.keyLeft = clientConfig.keybinds.left;
+ this.keyRight = clientConfig.keybinds.right;
+ }
+}
diff --git a/raid-survival/client/components/network-id.component.ts b/raid-survival/client/components/network-id.component.ts
new file mode 100644
index 0000000..0c91a82
--- /dev/null
+++ b/raid-survival/client/components/network-id.component.ts
@@ -0,0 +1,10 @@
+export class NetworkId {
+ name = this.constructor.name;
+
+ constructor(
+ public id: number,
+ ) {}
+}
+
+// * Required to generate code
+export default NetworkId.name;
diff --git a/raid-survival/client/components/player.component.ts b/raid-survival/client/components/player.component.ts
new file mode 100644
index 0000000..4907730
--- /dev/null
+++ b/raid-survival/client/components/player.component.ts
@@ -0,0 +1,8 @@
+// Marker - identifies a player entity for systems that need to single players out (e.g.
+// player-death.system.ts), the same way Zombie/Lobby mark their own entities.
+export class Player {
+ name = this.constructor.name;
+}
+
+// * Required to generate code
+export default Player.name;
diff --git a/raid-survival/client/components/reload-indicator.component.ts b/raid-survival/client/components/reload-indicator.component.ts
new file mode 100644
index 0000000..98c8b92
--- /dev/null
+++ b/raid-survival/client/components/reload-indicator.component.ts
@@ -0,0 +1,9 @@
+// Marker on the "RELOAD!" HUD sprite entity (built once alongside the ammo HUD - see
+// buildAmmoHud in start-game-packet.handler.ts) - lets reload-indicator.system.ts find it without
+// mixing it up with any other SpriteComponent on screen.
+export class ReloadIndicatorComponent {
+ name = this.constructor.name;
+}
+
+// * Required to generate code
+export default ReloadIndicatorComponent.name;
diff --git a/raid-survival/client/components/renderable/group.component.ts b/raid-survival/client/components/renderable/group.component.ts
new file mode 100644
index 0000000..8b1c934
--- /dev/null
+++ b/raid-survival/client/components/renderable/group.component.ts
@@ -0,0 +1,15 @@
+import { GroupConfig, Group, Container } from "@nanoforge-dev/graphics-2d";
+import { SpriteComponent } from "./sprite.component";
+
+export class GroupComponent {
+ name = this.constructor.name;
+ group: Group;
+
+ constructor(parent: Container, options: GroupConfig) {
+ this.group = new Group(options);
+ parent.add(this.group);
+ }
+}
+
+// * Required to generate code
+export default SpriteComponent.name;
diff --git a/raid-survival/client/components/renderable/rect.component.ts b/raid-survival/client/components/renderable/rect.component.ts
new file mode 100644
index 0000000..28a84fa
--- /dev/null
+++ b/raid-survival/client/components/renderable/rect.component.ts
@@ -0,0 +1,15 @@
+import { Container, Rect, RectConfig } from "@nanoforge-dev/graphics-2d";
+import { SpriteComponent } from "./sprite.component";
+
+export class RectComponent {
+ name = this.constructor.name;
+ rect: Rect;
+
+ constructor(parent: Container, options: RectConfig) {
+ this.rect = new Rect(options);
+ parent.add(this.rect);
+ }
+}
+
+// * Required to generate code
+export default SpriteComponent.name;
\ No newline at end of file
diff --git a/raid-survival/client/components/renderable/sprite.component.ts b/raid-survival/client/components/renderable/sprite.component.ts
new file mode 100644
index 0000000..2e3fe40
--- /dev/null
+++ b/raid-survival/client/components/renderable/sprite.component.ts
@@ -0,0 +1,83 @@
+import { Layer, Sprite, Vector2d } from "@nanoforge-dev/graphics-2d";
+
+interface SpriteComponentOptions {
+ animationsKey?: string;
+ layer?: Layer;
+ scale?: Vector2d;
+ currentAnimation?: string;
+}
+
+export class SpriteComponent {
+ name = this.constructor.name;
+ spriteKey: string;
+ sprite: Sprite | undefined;
+ animationsKey?: string;
+ layer: Layer | undefined;
+ loading: boolean = false;
+
+ private _scale: Vector2d = { x: 1, y: 1 };
+ private _currentAnimation: string = "idle";
+ private _flipped: boolean = false;
+ private _flippedY: boolean = false;
+
+ constructor(spriteKey: string, options?: SpriteComponentOptions) {
+ this.spriteKey = spriteKey;
+ if (options?.animationsKey) this.animationsKey = options.animationsKey;
+ if (options?.scale) this._scale = options.scale;
+ if (options?.currentAnimation) this._currentAnimation = options.currentAnimation;
+ if (options?.layer) this.layer = options.layer;
+ }
+
+ getAnimation(): string {
+ return this._currentAnimation;
+ }
+
+ setAnimation(animation: string) {
+ if (animation === this._currentAnimation) return;
+ this._currentAnimation = animation;
+ this.sprite?.animation(animation);
+ }
+
+ getScale(): Vector2d {
+ return this._scale;
+ }
+
+ setScale(scale: Vector2d) {
+ this._scale = scale;
+ this.sprite?.scale(scale);
+ }
+
+ isFlipped() {
+ return this._flipped;
+ }
+
+ flip() {
+ this._flipped = true;
+ this.sprite?.scaleX(this._scale.x * -1);
+ }
+
+ unflip() {
+ this._flipped = false;
+ this.sprite?.scaleX(this._scale.x);
+ }
+
+ // Vertical mirror (scaleY), distinct from flip()/unflip()'s horizontal one - used by a
+ // continuously-rotating sprite (rotate-to-direction.system.ts) to stay right-side-up while
+ // aiming left, instead of a static left/right-facing sprite mirroring horizontally.
+ isFlippedY() {
+ return this._flippedY;
+ }
+
+ flipY() {
+ this._flippedY = true;
+ this.sprite?.scaleY(this._scale.y * -1);
+ }
+
+ unflipY() {
+ this._flippedY = false;
+ this.sprite?.scaleY(this._scale.y);
+ }
+}
+
+// * Required to generate code
+export default SpriteComponent.name;
\ No newline at end of file
diff --git a/raid-survival/client/components/renderable/text.component.ts b/raid-survival/client/components/renderable/text.component.ts
new file mode 100644
index 0000000..986ce2a
--- /dev/null
+++ b/raid-survival/client/components/renderable/text.component.ts
@@ -0,0 +1,14 @@
+import { Container, Text, TextConfig } from "@nanoforge-dev/graphics-2d";
+
+export class TextComponent {
+ name = this.constructor.name;
+ text: Text;
+
+ constructor(parent: Container, options: TextConfig) {
+ this.text = new Text(options);
+ parent.add(this.text);
+ }
+}
+
+// * Required to generate code
+export default TextComponent.name;
\ No newline at end of file
diff --git a/raid-survival/client/components/renderable/textarea.component.ts b/raid-survival/client/components/renderable/textarea.component.ts
new file mode 100644
index 0000000..a170413
--- /dev/null
+++ b/raid-survival/client/components/renderable/textarea.component.ts
@@ -0,0 +1,134 @@
+import {
+ Container,
+ document,
+ Group,
+ Layer,
+ Stage,
+ Text,
+ TextConfig,
+} from "@nanoforge-dev/graphics-2d";
+
+export class TextAreaComponent {
+ name = this.constructor.name;
+ text: Text;
+ wrapper: HTMLDivElement;
+ textarea: HTMLTextAreaElement;
+ value: string = "";
+ private readonly clipAncestor: Group | Layer | Stage | null;
+
+ constructor(parent: Container, options: TextConfig) {
+ this.text = new Text(options);
+ parent.add(this.text);
+ this.text.hide();
+
+ this.clipAncestor = this.findClipAncestor(parent);
+
+ this.wrapper = document.createElement("div");
+ this.wrapper.style.position = "absolute";
+ this.wrapper.style.overflow = "hidden";
+ this.wrapper.style.pointerEvents = "none";
+ document.body.appendChild(this.wrapper);
+
+ this.textarea = document.createElement("textarea");
+ this.textarea.value = this.text.text();
+ this.textarea.style.position = "absolute";
+ this.textarea.style.border = "none";
+ this.textarea.style.padding = "0px";
+ this.textarea.style.margin = "0px";
+ this.textarea.style.overflow = "hidden";
+ this.textarea.style.background = "none";
+ this.textarea.style.outline = "none";
+ this.textarea.style.resize = "none";
+ this.textarea.style.transformOrigin = "left top";
+ this.textarea.style.pointerEvents = "auto";
+ this.applyTextStyle();
+ this.wrapper.appendChild(this.textarea);
+
+ this.textarea.addEventListener("input", () => {
+ this.text.text(this.textarea.value);
+ });
+
+ this.sync();
+ }
+
+ private applyTextStyle(): void {
+ this.textarea.style.fontSize = this.text.fontSize() + "px";
+ this.textarea.style.lineHeight = this.text.lineHeight().toString();
+ this.textarea.style.fontFamily = this.text.fontFamily();
+ this.textarea.style.textAlign = this.text.align();
+ this.textarea.style.color = (this.text.fill() as string) ?? "#000";
+ this.textarea.style.width = this.text.width() - this.text.padding() * 2 + "px";
+ this.textarea.style.height = this.text.height() - this.text.padding() * 2 + 5 + "px";
+ }
+
+ private findClipAncestor(node: Container): Group | Layer | Stage | null {
+ let current: Container | null = node;
+ while (current) {
+ const clipWidth = current.clipWidth?.();
+ if (clipWidth) return current as Group | Layer | Stage;
+ current = current.getParent();
+ }
+ return null;
+ }
+
+ sync(): void {
+ const stage = this.text.getStage();
+ if (!stage) return;
+
+ const containerRect = stage.container().getBoundingClientRect();
+ const textAbs = this.text.getAbsolutePosition();
+ const scale = stage.getAbsoluteScale();
+
+ const pageX = containerRect.left + textAbs.x * scale.x;
+ const pageY = containerRect.top + textAbs.y * scale.y;
+
+ if (this.clipAncestor) {
+ const clipRect = this.getAbsoluteClipRect(this.clipAncestor, containerRect, scale);
+
+ this.wrapper.style.left = clipRect.x + "px";
+ this.wrapper.style.top = clipRect.y + "px";
+ this.wrapper.style.width = clipRect.width + "px";
+ this.wrapper.style.height = clipRect.height + "px";
+
+ this.textarea.style.left = pageX - clipRect.x + "px";
+ this.textarea.style.top = pageY - clipRect.y + "px";
+ } else {
+ this.wrapper.style.left = "0px";
+ this.wrapper.style.top = "0px";
+ this.wrapper.style.width = "100vw";
+ this.wrapper.style.height = "100vh";
+ this.textarea.style.left = pageX + "px";
+ this.textarea.style.top = pageY + "px";
+ }
+
+ this.value = this.textarea.value;
+ }
+
+ private getAbsoluteClipRect(
+ node: Group | Layer | Stage,
+ containerRect: DOMRect,
+ scale: { x: number; y: number },
+ ): { x: number; y: number; width: number; height: number } {
+ const n = node;
+ const cx = n.clipX?.() ?? 0;
+ const cy = n.clipY?.() ?? 0;
+ const cw = n.clipWidth?.() ?? node.width();
+ const ch = n.clipHeight?.() ?? node.height();
+
+ const topLeft = node.getAbsoluteTransform().point({ x: cx, y: cy });
+ const nodeScale = node.getAbsoluteScale();
+
+ return {
+ x: containerRect.left + topLeft.x * scale.x,
+ y: containerRect.top + topLeft.y * scale.y,
+ width: cw * nodeScale.x,
+ height: ch * nodeScale.y,
+ };
+ }
+
+ destroy(): void {
+ this.text.destroy();
+ this.textarea.remove();
+ this.wrapper.remove();
+ }
+}
diff --git a/raid-survival/client/components/shoot.controller.ts b/raid-survival/client/components/shoot.controller.ts
new file mode 100644
index 0000000..4ff6ad1
--- /dev/null
+++ b/raid-survival/client/components/shoot.controller.ts
@@ -0,0 +1,43 @@
+import { Vector2d } from "@nanoforge-dev/graphics-2d";
+import { InputEnum } from "@nanoforge-dev/input";
+
+export class ShootController {
+ name = this.constructor.name;
+ public position: Vector2d = { x: 0, y: 0 };
+
+ public aimingMode: "mouse" | "arrows" | "joystick";
+ public keyShootMainWeapon: InputEnum;
+ public keyShootSecondWeapon: InputEnum;
+ public mainWeaponShooting: boolean = false;
+ public secondWeaponShooting: boolean = false;
+ // Last value actually sent to the server - lets sendShootControl only send on change, same
+ // dedup move-control.senders.system.ts already does for move keys.
+ public lastSentMainWeaponShooting: boolean = false;
+ // Edge-detected "R" state (isKeyPressed is level/held, not "just pressed").
+ public wasReloadKeyPressed: boolean = false;
+ // One-shot - set true on a fresh R press, sent once by sendShootControl then cleared.
+ public reloadRequested: boolean = false;
+
+ constructor(
+ clientConfig: {
+ keybinds: {
+ aimingMode: "mouse" | "arrows" | "joystick";
+ shootMainWeapon: InputEnum;
+ shootSecondWeapon: InputEnum;
+ };
+ } = {
+ keybinds: {
+ aimingMode: "mouse",
+ shootMainWeapon: InputEnum.MouseLeft,
+ shootSecondWeapon: InputEnum.MouseRight,
+ },
+ },
+ ) {
+ this.aimingMode = clientConfig.keybinds.aimingMode;
+ this.keyShootMainWeapon = clientConfig.keybinds.shootMainWeapon;
+ this.keyShootSecondWeapon = clientConfig.keybinds.shootSecondWeapon;
+ }
+}
+
+// * Required to generate code
+export default ShootController.name;
diff --git a/raid-survival/client/components/wave-hud.component.ts b/raid-survival/client/components/wave-hud.component.ts
new file mode 100644
index 0000000..f7a5841
--- /dev/null
+++ b/raid-survival/client/components/wave-hud.component.ts
@@ -0,0 +1,19 @@
+import { Rect, Text } from "@nanoforge-dev/graphics-2d";
+
+// Refs to the konva nodes built once at game start
+// (client/systems/packet-handlers/start-game-packet.handler.ts) and mutated in place by
+// wave-info-packet.handler.ts on every waveInfo packet - same "build once, patch node fields on
+// update" pattern as HealthBarFill.
+export class WaveHudComponent {
+ name = this.constructor.name;
+
+ constructor(
+ public waveText: Text,
+ public progressTrack: Rect,
+ public progressFill: Rect,
+ public aliveText: Text,
+ ) {}
+}
+
+// * Required to generate code
+export default WaveHudComponent.name;
diff --git a/raid-survival/client/components/weapon.component.ts b/raid-survival/client/components/weapon.component.ts
new file mode 100644
index 0000000..6e21960
--- /dev/null
+++ b/raid-survival/client/components/weapon.component.ts
@@ -0,0 +1,19 @@
+// Marker on the weapon child entity (parented to a player the same way the hand is - see
+// buildPlayer in start-game-packet.handler.ts) - lets weapon-state-packet.handler.ts and
+// weapon-reload-animation.system.ts find "this player's weapon sprite" via the same
+// ChildrenComponent.parentId cross-reference pattern already used for health bars.
+export class Weapon {
+ name = this.constructor.name;
+
+ // The DirectionRotatorComponent offset this weapon normally rotates with (aim-tracking) -
+ // weapon-reload-animation.system.ts adds an oscillating delta on top of this while reloading,
+ // then restores it exactly once done, rather than hardcoding the base offset in two places.
+ constructor(public baseRotationOffset: number) {}
+
+ reloading: boolean = false;
+ // Local animation clock - only meaningful while reloading, reset each time it starts.
+ reloadElapsed: number = 0;
+}
+
+// * Required to generate code
+export default Weapon.name;
diff --git a/raid-survival/client/main.ts b/raid-survival/client/main.ts
new file mode 100644
index 0000000..d7e1a8b
--- /dev/null
+++ b/raid-survival/client/main.ts
@@ -0,0 +1,123 @@
+import { type IRunOptions } from "@nanoforge-dev/common";
+import { NanoforgeFactory } from "@nanoforge-dev/core";
+
+import { AssetManagerLibrary } from "@nanoforge-dev/asset-manager";
+import { ECSClientLibrary } from "@nanoforge-dev/ecs-client";
+import { Graphics2DLibrary } from "@nanoforge-dev/graphics-2d";
+import { InputEnum, InputLibrary } from "@nanoforge-dev/input";
+import { MusicLibrary } from "@nanoforge-dev/music";
+import { NetworkClientLibrary } from "@nanoforge-dev/network-client";
+import { SoundLibrary } from "@nanoforge-dev/sound";
+import { moveSystem } from "./systems/move.system";
+import { spriteAnimator } from "./systems/renderable/sprite-animator.system";
+import { playerDeathSystem } from "./systems/player-death.system";
+import { spriteSystem } from "./systems/renderable/sprite.system";
+import { cameraFollowSystem } from "./systems/camera-follow.system";
+import { moveControl } from "./systems/move-control.system";
+import { shootControl } from "./systems/shoot-control.system";
+import { buildModeSystem } from "./systems/build-mode.system";
+import { sendMoveControl } from "./systems/packet-senders/move-control.senders.system";
+import { sendShootControl } from "./systems/packet-senders/shoot-control.sender.system";
+import { packetHandler } from "./systems/packet-handler.system";
+import {SceneManager} from "./scenes/SceneManager";
+import {MenuScene} from "./scenes/MenuScene";
+import {textareaSystem} from "./systems/renderable/textarea";
+import { sceneSystem } from "./systems/scene";
+import { lobbyActionSenders } from "./systems/packet-senders/lobby-action.senders";
+import { transformChildrenToParentSystem } from "./systems/transform-children-to-parent.system";
+import { rotateToDirectionSystem } from "./systems/rotate-to-direction.system";
+import { weaponReloadAnimationSystem } from "./systems/weapon-reload-animation.system";
+import { reloadIndicatorSystem } from "./systems/reload-indicator.system";
+import { cursorSystem } from "./systems/cursor.system";
+import { floatingTextSystem } from "./systems/floating-text.system";
+import { zOrderSystem } from "./systems/essentials/z-order.system";
+
+export let clientConfig: {
+ keybinds: {
+ aimingMode: "mouse" | "arrows" | "joystick";
+ shootMainWeapon: InputEnum;
+ shootSecondWeapon: InputEnum;
+ up: InputEnum;
+ left: InputEnum;
+ down: InputEnum;
+ right: InputEnum;
+ };
+ login: string;
+};
+
+export let sceneManager: SceneManager;
+export let playerId: number;
+
+export function setPlayerId(id: number) {
+ playerId = id;
+}
+
+export async function main(options: IRunOptions) {
+ const app = NanoforgeFactory.createClient();
+
+ const assetManagerLibrary = new AssetManagerLibrary();
+ const ecsLibrary = new ECSClientLibrary();
+ const graphicsLibrary = new Graphics2DLibrary();
+ const inputLibrary = new InputLibrary();
+ const musicLibrary = new MusicLibrary();
+ const networkLibrary = new NetworkClientLibrary();
+ const soundLibrary = new SoundLibrary();
+
+ app.useAssetManager(assetManagerLibrary);
+ app.useComponentSystem(ecsLibrary);
+ app.useGraphics(graphicsLibrary);
+ app.useInput(inputLibrary);
+ app.use(Symbol("music"), musicLibrary);
+ app.useNetwork(networkLibrary);
+ app.useSound(soundLibrary);
+
+ await app.init(options);
+
+ const registry = ecsLibrary.registry;
+
+ registry.addSystem(sceneSystem);
+ registry.addSystem(moveControl);
+ registry.addSystem(textareaSystem);
+ registry.addSystem(shootControl);
+ registry.addSystem(buildModeSystem);
+ registry.addSystem(playerDeathSystem);
+ registry.addSystem(spriteAnimator);
+ registry.addSystem(cameraFollowSystem);
+ registry.addSystem(moveSystem);
+ registry.addSystem(spriteSystem);
+ registry.addSystem(transformChildrenToParentSystem);
+ registry.addSystem(weaponReloadAnimationSystem);
+ registry.addSystem(reloadIndicatorSystem);
+ registry.addSystem(cursorSystem);
+ registry.addSystem(floatingTextSystem);
+ registry.addSystem(rotateToDirectionSystem);
+ registry.addSystem(zOrderSystem);
+
+ registry.addSystem(packetHandler);
+
+ registry.addSystem(sendMoveControl);
+ registry.addSystem(sendShootControl);
+ registry.addSystem(lobbyActionSenders);
+
+ async function waitForConnection(): Promise {
+ if (networkLibrary.tcp?.isConnected()) return;
+
+ return new Promise((resolve) => {
+ const check = () => {
+ if (networkLibrary.tcp.isConnected()) {
+ resolve();
+ } else {
+ setTimeout(check, 50);
+ }
+ };
+ check();
+ });
+ }
+
+ await waitForConnection();
+
+ sceneManager = new SceneManager(registry, graphicsLibrary.stage);
+ sceneManager.switchTo(new MenuScene());
+
+ await app.run();
+}
diff --git a/raid-survival/client/map-data.ts b/raid-survival/client/map-data.ts
new file mode 100644
index 0000000..0c49f8f
--- /dev/null
+++ b/raid-survival/client/map-data.ts
@@ -0,0 +1,14 @@
+import mapCollisionData from "./static/map-collision.json";
+
+// A copy of server/static/map-collision.json (server/systems/packet-handlers/
+// start-game-packet.handler.ts) - the server never sends tile/tree data over the network today,
+// and build-mode's occupancy preview (build-mode.system.ts) needs it client-side to color tiles
+// red/green the same way build-packet.handler.ts's canPlaceBuilding validates them. Kept in sync
+// manually, same reasoning as building-catalog.ts.
+export const TILE_SIZE = mapCollisionData.tileSize;
+export const MAP_COLS = mapCollisionData.cols;
+export const MAP_ROWS = mapCollisionData.rows;
+
+export function isTreeCell(col: number, row: number): boolean {
+ return mapCollisionData.collision[row]?.[col] === 1;
+}
diff --git a/raid-survival/client/scenes/GameOverScene.ts b/raid-survival/client/scenes/GameOverScene.ts
new file mode 100644
index 0000000..f69a3d3
--- /dev/null
+++ b/raid-survival/client/scenes/GameOverScene.ts
@@ -0,0 +1,138 @@
+import { Scene } from "./Scene";
+import { Registry } from "@nanoforge-dev/ecs-client";
+import { Layer, Stage } from "@nanoforge-dev/graphics-2d";
+import { RectComponent } from "../components/renderable/rect.component";
+import { TextComponent } from "../components/renderable/text.component";
+import { MenuScene } from "./MenuScene";
+import { sceneManager } from "../main";
+
+// Modeled on MenuScene's plain Rect/Text UI pattern - a static screen, no ECS gameplay entities.
+export class GameOverScene implements Scene {
+ readonly name = "gameOver";
+ layer: Layer | undefined;
+ private stage!: Stage;
+
+ constructor(private zombiesKilled: number) {}
+
+ load(registry: Registry, stage: Stage): void {
+ this.stage = stage;
+
+ this.layer = new Layer();
+ this.stage.add(this.layer);
+
+ registry.addComponent(
+ registry.spawnEntity(),
+ new RectComponent(this.layer, {
+ x: 0,
+ y: 0,
+ width: window.innerWidth,
+ height: window.innerHeight,
+ fill: "#0B2E1A",
+ }),
+ );
+
+ const panelSize = { width: 420, height: 280 };
+ const panelX = window.innerWidth / 2 - panelSize.width / 2;
+ const panelY = window.innerHeight / 2 - panelSize.height / 2;
+
+ registry.addComponent(
+ registry.spawnEntity(),
+ new RectComponent(this.layer, {
+ x: panelX,
+ y: panelY,
+ width: panelSize.width,
+ height: panelSize.height,
+ fill: "#104522",
+ cornerRadius: 5,
+ }),
+ );
+
+ registry.addComponent(
+ registry.spawnEntity(),
+ new TextComponent(this.layer, {
+ text: "GAME OVER",
+ x: panelX,
+ y: panelY + 30,
+ width: panelSize.width,
+ height: 40,
+ fontSize: 32,
+ fontStyle: "bold",
+ align: "center",
+ fill: "#F5F2E9",
+ }),
+ );
+
+ registry.addComponent(
+ registry.spawnEntity(),
+ new TextComponent(this.layer, {
+ text: `Zombies killed: ${this.zombiesKilled}`,
+ x: panelX,
+ y: panelY + 100,
+ width: panelSize.width,
+ height: 30,
+ fontSize: 20,
+ align: "center",
+ fill: "#F5F2E9",
+ }),
+ );
+
+ const buttonSize = { width: 220, height: 50 };
+ const buttonX = panelX + panelSize.width / 2 - buttonSize.width / 2;
+ const buttonY = panelY + panelSize.height - buttonSize.height - 30;
+
+ const retryButtonComponent = new RectComponent(this.layer, {
+ x: buttonX,
+ y: buttonY,
+ width: buttonSize.width,
+ height: buttonSize.height,
+ cornerRadius: 10,
+ fill: "#1F6B45",
+ stroke: "#5E8C61",
+ strokeWidth: 2,
+ shadowEnabled: false,
+ shadowOffsetX: 2,
+ shadowOffsetY: 2,
+ });
+ registry.addComponent(registry.spawnEntity(), retryButtonComponent);
+
+ retryButtonComponent.rect.on("mouseover", () => {
+ retryButtonComponent.rect.shadowEnabled(true);
+ this.stage.container().style.cursor = "pointer";
+ });
+ retryButtonComponent.rect.on("mouseout", () => {
+ retryButtonComponent.rect.shadowEnabled(false);
+ this.stage.container().style.cursor = "default";
+ });
+ retryButtonComponent.rect.on("click", () => {
+ // Straight back to the join-lobby screen, in its normal fresh/unjoined state - same place
+ // a first-time visitor lands. switchTo() clears the registry and this scene's own layer
+ // before MenuScene builds its own, so nothing from this screen (or the finished game)
+ // lingers.
+ this.stage.container().style.cursor = "default";
+ sceneManager.switchTo(new MenuScene());
+ });
+
+ registry.addComponent(
+ registry.spawnEntity(),
+ new TextComponent(this.layer, {
+ text: "Retry",
+ x: buttonX,
+ y: buttonY,
+ width: buttonSize.width,
+ height: buttonSize.height,
+ fontSize: 22,
+ verticalAlign: "middle",
+ align: "center",
+ fill: "#F5F2E9",
+ fontStyle: "bold",
+ listening: false,
+ }),
+ );
+ }
+
+ unload(): void {
+ this.layer?.destroy();
+ }
+
+ tick(): void {}
+}
diff --git a/raid-survival/client/scenes/GameScene.ts b/raid-survival/client/scenes/GameScene.ts
new file mode 100644
index 0000000..3d0118f
--- /dev/null
+++ b/raid-survival/client/scenes/GameScene.ts
@@ -0,0 +1,48 @@
+import { Scene } from "./Scene";
+import { Registry } from "@nanoforge-dev/ecs-client";
+import { Layer, Stage } from "@nanoforge-dev/graphics-2d";
+import { TransformComponent } from "../components/essentials/transform.component";
+import { SpriteComponent } from "../components/renderable/sprite.component";
+
+export class GameScene implements Scene {
+ readonly name = "GameScene";
+ layer: Layer | undefined;
+ // Separate, unscaled layer for screen-space UI (the wave HUD) - cameraFollowSystem only ever
+ // pans/zooms `layer` (the 3x-scaled world), so anything added here stays fixed on screen
+ // regardless of camera movement.
+ hudLayer: Layer | undefined;
+
+ private stage!: Stage;
+
+ load(registry: Registry, stage: Stage): void {
+ this.stage = stage;
+
+ // Replaced by a custom crosshair sprite (cursor.system.ts) - see also this scene's build-bar
+ // hover handlers in start-game-packet.handler.ts, which must restore "none" on mouseout
+ // rather than the OS default, or hovering a button would bring the OS cursor back for good.
+ this.stage.container().style.cursor = "none";
+
+ this.layer = new Layer();
+ this.layer?.scale({x: 3, y: 3});
+ const context = this.layer.getCanvas()._canvas.getContext("2d");
+ if (context) context.imageSmoothingEnabled = false;
+ this.stage.add(this.layer);
+
+ this.hudLayer = new Layer();
+ this.stage.add(this.hudLayer);
+
+ const map = registry.spawnEntity();
+ registry.addComponent(map, new TransformComponent(0, 0));
+ registry.addComponent(map, new SpriteComponent("map.png", { layer: this.layer }));
+ }
+
+ unload() {
+ // Restore the OS cursor - otherwise it stays hidden (still "none" from load()) on whatever
+ // scene comes next, until the player happens to hover something with its own cursor handler.
+ this.stage.container().style.cursor = "default";
+ this.layer?.destroy();
+ this.hudLayer?.destroy();
+ }
+
+ tick(): void {}
+}
\ No newline at end of file
diff --git a/raid-survival/client/scenes/MenuScene.ts b/raid-survival/client/scenes/MenuScene.ts
new file mode 100644
index 0000000..957a5cd
--- /dev/null
+++ b/raid-survival/client/scenes/MenuScene.ts
@@ -0,0 +1,363 @@
+import { Scene } from "./Scene";
+import { Container, Easings, Group, Layer, Stage, Tween } from "@nanoforge-dev/graphics-2d";
+import { Registry } from "@nanoforge-dev/ecs-client";
+
+import { RectComponent } from "../components/renderable/rect.component";
+import { TextAreaComponent } from "../components/renderable/textarea.component";
+import { GroupComponent } from "../components/renderable/group.component";
+import { TextComponent } from "../components/renderable/text.component";
+import { LobbyAction, LobbyState, LobbyStatusComponent } from "../components/lobby/lobby-status";
+
+export class MenuScene implements Scene {
+ readonly name = "menu";
+ layer: Layer | undefined;
+ private stage!: Stage;
+
+ private lobbyStatusComponent: LobbyStatusComponent | undefined;
+
+ private joinLobbyGroup: Group | undefined;
+ private lobbyGroup: Group | undefined;
+
+ private menu: "User" | "Lobby" = "User";
+ private playerCases: TextComponent[] = [];
+ private joinErrorText: TextComponent | undefined;
+ // TextAreaComponent isn't a Konva node - it backs the input with a real DOM