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..aa65812 --- /dev/null +++ b/raid-survival/client/components/ammo-hud.component.ts @@ -0,0 +1,23 @@ +import { Text } from "@nanoforge-dev/graphics-2d"; +import { SpriteComponent } from "./renderable/sprite.component"; + +// 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. One of these +// per hand now (buildAmmoHud is called twice): `hand` lets ammo-packet.handler.ts route an +// incoming `{weaponType}` ammo update to whichever row currently has that weapon equipped, and +// `icon` (the weapon-icon SpriteComponent, not just its Konva node) lets +// reload-indicator.system.ts toggle this whole row's visibility off "is this hand equipped" - +// see there for why it can't just hide the Konva node directly at construction time. +export class AmmoHudComponent { + name = this.constructor.name; + + constructor( + public text: Text, + public icon: SpriteComponent, + public hand: "left" | "right", + ) {} +} + +// * 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..41c3ccf --- /dev/null +++ b/raid-survival/client/components/build-mode.component.ts @@ -0,0 +1,44 @@ +import { Circle, Rect, Shape, Text } from "@nanoforge-dev/graphics-2d"; +import { type BuildingType } from "../building-catalog"; + +export interface BuildBarButton { + buildingType: BuildingType; + rect: Rect; + text: Text; + costText: Text; + costIcon: Circle; +} + +// 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..b0d8694 --- /dev/null +++ b/raid-survival/client/components/direction-rotator.component.ts @@ -0,0 +1,16 @@ +export class DirectionRotatorComponent { + name = this.constructor.name; + enable: boolean = true; + offset: number; + // A sprite that rotates through the full circle (a held weapon, the hand holding it) reads + // upside-down for half its arc unless mirrored vertically while aiming left - see + // rotate-to-direction.system.ts. Off by default since not every rotating sprite needs it + // (e.g. the health bar, which never rotates at all). + 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..625225a --- /dev/null +++ b/raid-survival/client/components/money-hud.component.ts @@ -0,0 +1,23 @@ +import { Text, Circle } 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. `coinIcon` is a raw Konva node (see hud-helpers.ts) with no +// SpriteComponent, so zOrderSystem never manages it - it needs a `.moveToTop()` every tick from +// build-mode.system.ts once any z-indexed sprite (e.g. a zombie) gets added to the same layer, +// same trap the grid/preview shapes hit. +export class MoneyHudComponent { + name = this.constructor.name; + + constructor( + public text: Text, + public amount: number, + public coinIcon: Circle, + ) {} +} + +// * 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..49c8dbf --- /dev/null +++ b/raid-survival/client/components/reload-indicator.component.ts @@ -0,0 +1,11 @@ +// Marker on a "RELOAD!" HUD sprite entity (built once alongside each hand's ammo HUD row - see +// buildAmmoHud in start-game-packet.handler.ts) - lets reload-indicator.system.ts find the right +// one of a player's two (one per hand) without mixing them up. +export class ReloadIndicatorComponent { + name = this.constructor.name; + + constructor(public hand: "left" | "right") {} +} + +// * 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..3d4dc22 --- /dev/null +++ b/raid-survival/client/components/renderable/sprite.component.ts @@ -0,0 +1,140 @@ +import { Layer, Sprite, Vector2d } from "@nanoforge-dev/graphics-2d"; + +interface SpriteComponentOptions { + animationsKey?: string; + layer?: Layer; + scale?: Vector2d; + currentAnimation?: string; + frameRate?: number; + // Native-pixel point within the frame that the rotation pivot sits on and that + // TransformComponent's position resolves to (see sprite.system.ts) - defaults to the frame's own + // geometric center (width/2, height/2) when omitted, which is correct for art that's naturally + // drawn centered in its crop. A weapon held by a hand isn't necessarily centered though (e.g. + // Shotgun-Shot.png's 52x32 frame has the gun's grip well off to one side, with empty space on + // the muzzle side for the recoil/flash frames) - without overriding this, the sprite is + // positioned/rotated around its crop's geometric center instead of the point a hand is actually + // holding it at, visibly displacing it away from the hand by however far off-center the art is. + pivot?: Vector2d; +} + +export class SpriteComponent { + name = this.constructor.name; + spriteKey: string; + sprite: Sprite | undefined; + animationsKey?: string | undefined; + layer: Layer | undefined; + loading: boolean = false; + // Read by spriteSystem at Sprite-construction time only (like width/height/animations) - not + // reactive on its own; changing it takes effect on the next setSpriteKey-triggered rebuild, not + // on an already-live sprite. Defaults to 7, matching every sprite before this field existed. + frameRate: number = 7; + + private _scale: Vector2d = { x: 1, y: 1 }; + private _currentAnimation: string = "idle"; + private _flipped: boolean = false; + private _flippedY: boolean = false; + private _pivot: Vector2d | undefined; + + 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; + if (options?.frameRate) this.frameRate = options.frameRate; + if (options?.pivot) this._pivot = options.pivot; + } + + // Swaps to a different source image (and, usually, a different animations file) at runtime - + // e.g. the shotgun's held-weapon sprite switching from its static weapons.png icon to + // Shotgun-Reload.png's real animation while reloading, then back. spriteSystem only ever + // creates the underlying Konva Sprite ONCE per component (guarded on `!sprite`), keyed off + // whatever spriteKey/animationsKey were set at that time - so this destroys the current one and + // clears the fields spriteSystem gates on, forcing it to load the new image and build a fresh + // Sprite next tick, exactly like a brand-new entity would. Also resets flip state: a freshly + // built Konva Sprite always starts unflipped (scaleX/Y at their base, non-negated), so leaving + // _flipped/_flippedY at whatever they were on the OLD sprite would desync + // rotate-to-direction.system.ts's hysteresis (it trusts isFlipped()/isFlippedY() to reflect the + // live sprite, and would then skip re-applying a flip the new sprite actually needs). + // currentAnimation defaults to "idle" for the same reason SpriteComponentOptions.currentAnimation + // is mostly decorative: spriteSystem always constructs a fresh Sprite on "idle" regardless of + // what _currentAnimation says, so a real animation key must be set again via setAnimation() once + // the new sprite actually exists (see weapon-reload-animation.system.ts for that idempotent + // every-tick check). + setSpriteKey(spriteKey: string, animationsKey?: string, currentAnimation: string = "idle"): void { + this.spriteKey = spriteKey; + this.animationsKey = animationsKey; + this._currentAnimation = currentAnimation; + this._flipped = false; + this._flippedY = false; + this.sprite?.destroy(); + this.sprite = undefined; + this.loading = false; + } + + 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); + } + + getPivot(): Vector2d | undefined { + return this._pivot; + } + + // Not applied to an already-live sprite (Konva's offsetX/Y, unlike scale, would also need the + // rendered position recomputed the same tick to avoid a one-frame jump) - like frameRate, this + // is read by spriteSystem only at Sprite-construction time, so a change here takes effect on the + // next setSpriteKey-triggered rebuild. Pass undefined to go back to the default frame-centered + // pivot (e.g. re-equipping from a weapon that overrode this to one that doesn't). + setPivot(pivot: Vector2d | undefined) { + this._pivot = pivot; + } + + 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..3985e31 --- /dev/null +++ b/raid-survival/client/components/shoot.controller.ts @@ -0,0 +1,44 @@ +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; + public lastSentSecondWeaponShooting: 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-reload-overlay.component.ts b/raid-survival/client/components/weapon-reload-overlay.component.ts new file mode 100644 index 0000000..5d4e35f --- /dev/null +++ b/raid-survival/client/components/weapon-reload-overlay.component.ts @@ -0,0 +1,13 @@ +// Marker on a hand's reload-animation overlay sprite entity (built once alongside the hand+weapon +// in buildHandAndWeapon, start-game-packet.handler.ts, only for weapon types with a catalog +// reloadSpriteKey - currently just the shotgun) - lets weapon-reload-animation.system.ts find the +// right one of a player's two (one per hand) without mixing them up, the same pattern +// ReloadIndicatorComponent already uses for the "RELOAD!" HUD sprite. +export class WeaponReloadOverlayComponent { + name = this.constructor.name; + + constructor(public hand: "left" | "right") {} +} + +// * Required to generate code +export default WeaponReloadOverlayComponent.name; diff --git a/raid-survival/client/components/weapon-shop.component.ts b/raid-survival/client/components/weapon-shop.component.ts new file mode 100644 index 0000000..9ec84f0 --- /dev/null +++ b/raid-survival/client/components/weapon-shop.component.ts @@ -0,0 +1,55 @@ +import { Circle, Rect, Text } from "@nanoforge-dev/graphics-2d"; +import { type WeaponType } from "../weapon-catalog"; +import { type ScreenBox } from "./build-mode.component"; + +export interface WeaponShopEntry { + weaponType: WeaponType; + buyRect: Rect; + buyText: Text; + costText: Text; + // Only present for non-alwaysOwned entries (smallGun never shows a cost at all). + costIcon: Circle | undefined; + leftButton: Rect; + rightButton: Rect; +} + +// Reserve only, not magazine - a weapon type's magazine is now per-hand (see +// weapon-inventory.component.ts server-side), so a single per-type shop entry has no one +// "the" magazine value to show (and a type can only occupy one hand at a time anyway - see +// equip-weapon-packet.handler.ts). Reserve stays meaningfully per-type (one shared bank), so +// that's what the shop shows; per-hand magazine content is what the bottom-left ammo HUD rows +// are for. +export interface OwnedWeaponAmmo { + reserveAmmo: number; +} + +// Singleton - built once at game start (start-game-packet.handler.ts's buildWeaponShop) and owned +// by build-mode.system.ts (visibility/button state, alongside BuildModeComponent - the shop shows +// and hides together with the build bar, no separate `active` flag of its own) plus +// weapon-inventory-packet.handler.ts and ammo-packet.handler.ts (data, local player only). +export class WeaponShopComponent { + name = this.constructor.name; + + // What the LOCAL player owns and how much reserve ammo each entry has - kept in sync by + // weapon-inventory-packet.handler.ts (buy/refill/equip events) AND ammo-packet.handler.ts + // (every shot/reload tick), the latter being what keeps this from going stale between + // purchases - see ammo-packet.handler.ts for why both write here. + owned: Map = new Map(); + leftWeaponType: WeaponType | null = null; + rightWeaponType: WeaponType | null = null; + + // One-shot intent flags: a shop button's click handler (built in start-game-packet.handler.ts, + // no access to the network client) sets one of these, and build-mode.system.ts's per-tick pass + // (which does have ctx/network - same split buildBuildMode/build-mode.system.ts already use for + // building placement) sends the actual packet and clears the flag the next tick. + pendingBuyType: WeaponType | null = null; + pendingEquip: { hand: "left" | "right"; weaponType: WeaponType | null } | null = null; + + constructor( + public entries: WeaponShopEntry[], + public shopBounds: ScreenBox, + ) {} +} + +// * Required to generate code +export default WeaponShopComponent.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..d96b6c1 --- /dev/null +++ b/raid-survival/client/components/weapon.component.ts @@ -0,0 +1,45 @@ +import { type WeaponType } from "../weapon-catalog"; + +// Marker on a weapon child entity (parented to a player, same as the hand - see buildPlayer in +// start-game-packet.handler.ts). Every player now has TWO of these (one per hand) - `hand` and +// `weaponType` are what let weapon-state-packet.handler.ts, ammo-packet.handler.ts, and +// reload-indicator.system.ts pick the RIGHT one of a player's two weapon children via +// ChildrenComponent.parentId, instead of the old single-weapon `.find()`. +export class Weapon { + name = this.constructor.name; + + constructor( + public hand: "left" | "right", + // null while this hand has nothing equipped - the sprite is hidden in that state (see + // buildHandAndWeapon / weapon-inventory-packet.handler.ts). + public weaponType: WeaponType | null, + // 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. + // Re-set by weapon-inventory-packet.handler.ts whenever the equipped type changes, since each + // weapon's sprite has its own rest angle. + public baseRotationOffset: number, + ) {} + + reloading: boolean = false; + // Local animation clock - only meaningful while reloading, reset each time it starts. + reloadElapsed: number = 0; + // The real reload duration for whichever weapon most recently started reloading here - carried + // over from weaponState's reloadSeconds field (weapon-state-packet.handler.ts) so + // weapon-reload-animation.system.ts can time a real frame-by-frame reload animation to span the + // actual server-driven duration, not a guessed constant. + reloadDurationSeconds: number = 0; + + // A one-shot "play the recoil/muzzle-flash animation" pulse, set true by + // weapon-fired-packet.handler.ts the instant this hand's weaponFired broadcast arrives (server, + // the exact moment a shot fires - not local input state, so this animates for every player's + // shots, not just the local one). weapon-reload-animation.system.ts counts firingElapsed up and + // flips this back to false once the weapon's own catalog.shootSeconds elapses - purely a client + // visual duration, unrelated to the server's actual fire-rate cooldown. No-op for a weapon with + // no shoot animation (e.g. smallGun) - see weapon-reload-animation.system.ts. + firing: boolean = false; + firingElapsed: number = 0; +} + +// * Required to generate code +export default Weapon.name; diff --git a/raid-survival/client/hud-helpers.ts b/raid-survival/client/hud-helpers.ts new file mode 100644 index 0000000..8535f87 --- /dev/null +++ b/raid-survival/client/hud-helpers.ts @@ -0,0 +1,22 @@ +import { Circle, Layer } from "@nanoforge-dev/graphics-2d"; + +// A small gold coin icon - no coin/currency sprite exists anywhere in this game's art (checked +// ui.png/objects.png/weapons.png exhaustively), so this is drawn directly instead of cropped. +// Plain function, not a component - nothing needs to look this node up later, same category as +// build-mode.system.ts's gridShape/previewRect. +const COIN_FILL = "#F4C74C"; +const COIN_STROKE = "#8A6A1E"; + +export function addCoinIcon(layer: Layer, x: number, y: number, radius: number): Circle { + const coin = new Circle({ + x: x + radius, + y: y + radius, + radius, + fill: COIN_FILL, + stroke: COIN_STROKE, + strokeWidth: 1.5, + listening: false, + }); + layer.add(coin); + return coin; +} diff --git a/raid-survival/client/main.ts b/raid-survival/client/main.ts new file mode 100644 index 0000000..9b951cb --- /dev/null +++ b/raid-survival/client/main.ts @@ -0,0 +1,129 @@ +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 { weaponVisibilitySystem } from "./systems/weapon-visibility.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(reloadIndicatorSystem); + registry.addSystem(weaponVisibilitySystem); + registry.addSystem(cursorSystem); + registry.addSystem(floatingTextSystem); + // After buildModeSystem/weaponVisibilitySystem (above), before rotateToDirectionSystem (below) - + // it forces the main weapon sprite hidden while a dedicated reload animation overlay is playing + // instead, and that write needs to be the last one standing for the tick; it also sets + // DirectionRotatorComponent.offset, which rotateToDirectionSystem must still consume afterward. + registry.addSystem(weaponReloadAnimationSystem); + 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/player-skins.ts b/raid-survival/client/player-skins.ts new file mode 100644 index 0000000..f8bb776 --- /dev/null +++ b/raid-survival/client/player-skins.ts @@ -0,0 +1,9 @@ +// Four player sprites (player1.png..player4.png) share the same player-animations.txt frame +// layout, so any of them can drop straight into SpriteComponent's spriteKey interchangeably - +// picking a different one per player is purely cosmetic, no server-side data needed. +export const PLAYER_SKINS = ["player1.png", "player2.png", "player3.png", "player4.png"]; + +export function pickPlayerSkin(index: number): string { + const safeIndex = ((index % PLAYER_SKINS.length) + PLAYER_SKINS.length) % PLAYER_SKINS.length; + return PLAYER_SKINS[safeIndex] ?? "player1.png"; +} 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..8b19f72 --- /dev/null +++ b/raid-survival/client/scenes/GameScene.ts @@ -0,0 +1,52 @@ +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"; + + // Right-click is now a real fire button (the second weapon slot) - without this the browser's + // native context menu would pop up on every right-click. + this.stage.container().addEventListener("contextmenu", (e) => e.preventDefault()); + + 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