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