diff --git a/package-lock.json b/package-lock.json
index c0e1ad4..5b5e209 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,15 @@
{
"name": "cannon-war",
- "version": "2.3.1",
+ "version": "2.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cannon-war",
- "version": "1.3.2",
+ "version": "2.3.1",
+ "dependencies": {
+ "colyseus.js": "^0.16.22"
+ },
"devDependencies": {
"@types/node": "^25.0.1",
"less": "^4.2.0",
@@ -15,6 +18,34 @@
"vite": "^6.0.0"
}
},
+ "node_modules/@colyseus/httpie": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@colyseus/httpie/-/httpie-2.0.1.tgz",
+ "integrity": "sha512-JvABMZzPLiyrUsVj3ElXGORRDTu+NKzXHWd1uV1R1SThAKMm06cVW6bOyADARD65bs8JJoHNNbUkW8KoRvRDzA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@colyseus/msgpackr": {
+ "version": "1.11.2",
+ "resolved": "https://registry.npmjs.org/@colyseus/msgpackr/-/msgpackr-1.11.2.tgz",
+ "integrity": "sha512-MuwPFhizFKC3zmGfy0fpo+kcnZdNdnQHFVjw81v4WXHCelDeCX8yNRVtuEm8kGlHqq7qiASLC0pu0RPqYOhxXg==",
+ "license": "MIT",
+ "optionalDependencies": {
+ "msgpackr-extract": "^3.0.2"
+ }
+ },
+ "node_modules/@colyseus/schema": {
+ "version": "3.0.76",
+ "resolved": "https://registry.npmjs.org/@colyseus/schema/-/schema-3.0.76.tgz",
+ "integrity": "sha512-i+ceBZyhB7lTn5+BoG/xxYfzW4dKKyLOywsGKVgXHe9fD905AS/Lk180jd1bICEJhebGeiRXEQ2YUPl/xwFg2g==",
+ "license": "MIT",
+ "bin": {
+ "schema-codegen": "bin/schema-codegen",
+ "schema-debug": "bin/schema-debug"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -507,6 +538,84 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
+ "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
+ "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
+ "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
+ "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
+ "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
+ "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
@@ -852,6 +961,25 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/colyseus.js": {
+ "version": "0.16.22",
+ "resolved": "https://registry.npmjs.org/colyseus.js/-/colyseus.js-0.16.22.tgz",
+ "integrity": "sha512-xyiajukHvlwOtcziVbXZWmz7yBH3EImovYrGPAe2kVkdubLVYmOjskJuXh2VLlO8XGjyhmNwig9ELz18sTUo9g==",
+ "license": "MIT",
+ "dependencies": {
+ "@colyseus/httpie": "^2.0.0",
+ "@colyseus/msgpackr": "^1.11.2",
+ "@colyseus/schema": "^3.0.0",
+ "tslib": "^2.1.0",
+ "ws": "^8.13.0"
+ },
+ "engines": {
+ "node": ">= 12.x"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/endel"
+ }
+ },
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@@ -872,6 +1000,16 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/errno": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz",
@@ -1060,6 +1198,28 @@
"node": ">=4"
}
},
+ "node_modules/msgpackr-extract": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
+ "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "node-gyp-build-optional-packages": "5.2.2"
+ },
+ "bin": {
+ "download-msgpackr-prebuilds": "bin/download-prebuilds.js"
+ },
+ "optionalDependencies": {
+ "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -1097,6 +1257,21 @@
"node": ">= 4.4.x"
}
},
+ "node_modules/node-gyp-build-optional-packages": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
+ "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "detect-libc": "^2.0.1"
+ },
+ "bin": {
+ "node-gyp-build-optional-packages": "bin.js",
+ "node-gyp-build-optional-packages-optional": "optional.js",
+ "node-gyp-build-optional-packages-test": "build-test.js"
+ }
+ },
"node_modules/parse-node-version": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz",
@@ -1315,7 +1490,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "dev": true,
"license": "0BSD"
},
"node_modules/typescript": {
@@ -1413,6 +1587,27 @@
"optional": true
}
}
+ },
+ "node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index 85a58c7..4be9873 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "cannon-war",
- "version": "2.3.1",
+ "version": "2.4.0",
"description": "A tower defense web game built with HTML5 Canvas",
"type": "module",
"scripts": {
@@ -14,5 +14,8 @@
"terser": "^5.44.1",
"typescript": "^5.9.3",
"vite": "^6.0.0"
+ },
+ "dependencies": {
+ "colyseus.js": "^0.16.22"
}
}
diff --git a/server/.gitignore b/server/.gitignore
new file mode 100644
index 0000000..06522d8
--- /dev/null
+++ b/server/.gitignore
@@ -0,0 +1,25 @@
+# Dependencies
+node_modules/
+
+# Build output
+dist/
+
+# Environment files
+.env
+.env.local
+.env.*.local
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# Logs
+logs/
+*.log
+npm-debug.log*
+
+# OS files
+.DS_Store
+Thumbs.db
diff --git a/server/README.md b/server/README.md
new file mode 100644
index 0000000..d65bba4
--- /dev/null
+++ b/server/README.md
@@ -0,0 +1,64 @@
+# Cannon War Multiplayer Server
+
+Colyseus 游戏服务器,用于 Cannon War 多人对战模式。
+
+## 快速开始
+
+```bash
+# 安装依赖
+npm install
+
+# 开发模式(热重载)
+npm run dev
+
+# 构建生产版本
+npm run build
+
+# 启动生产服务器
+npm start
+```
+
+## 配置
+
+服务器支持以下环境变量:
+
+| 变量 | 默认值 | 说明 |
+|------|--------|------|
+| `PORT` | 2567 | 服务器端口 |
+| `HOST` | 0.0.0.0 | 监听地址 |
+| `NODE_ENV` | development | 运行环境 |
+
+## 项目结构
+
+```
+server/
+├── src/
+│ ├── index.ts # 服务器入口
+│ ├── config.ts # 配置常量
+│ ├── rooms/ # 房间实现
+│ │ ├── GameRoom.ts # 游戏房间
+│ │ └── LobbyRoom.ts # 大厅房间
+│ ├── schema/ # Colyseus 状态定义
+│ │ ├── GameState.ts # 游戏状态
+│ │ ├── PlayerState.ts # 玩家状态
+│ │ └── ...
+│ └── shared/ # 共享代码
+│ └── types/ # 消息类型
+├── package.json
+└── tsconfig.json
+```
+
+## 开发
+
+开发模式下,访问 `http://localhost:2567/colyseus` 可查看 Colyseus Monitor。
+
+## API
+
+### Rooms
+
+- `lobby` - 大厅房间,用于匹配和房间列表
+- `game` - 游戏房间,实际对战逻辑
+
+### Messages
+
+详见 `src/shared/types/messages.ts`
diff --git a/server/package-lock.json b/server/package-lock.json
new file mode 100644
index 0000000..364dfd5
--- /dev/null
+++ b/server/package-lock.json
@@ -0,0 +1,1852 @@
+{
+ "name": "cannon-war-server",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "cannon-war-server",
+ "version": "1.0.0",
+ "dependencies": {
+ "@colyseus/core": "^0.15.0",
+ "@colyseus/monitor": "^0.15.0",
+ "@colyseus/schema": "^2.0.0",
+ "@colyseus/ws-transport": "^0.15.0",
+ "express": "^4.18.2"
+ },
+ "devDependencies": {
+ "@types/express": "^4.17.21",
+ "@types/node": "^22.10.0",
+ "tsx": "^4.19.0",
+ "typescript": "^5.7.0"
+ }
+ },
+ "node_modules/@colyseus/core": {
+ "version": "0.15.57",
+ "resolved": "https://registry.npmjs.org/@colyseus/core/-/core-0.15.57.tgz",
+ "integrity": "sha512-tAKNaFSFOpRH2ayLva9hQBVPQu0eKxDxaZJYugZMQ5i6yQ2RTvcbk/5Up7OZn/bfdk9THvBYnh6WfdZAOctK+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@colyseus/greeting-banner": "^2.0.0",
+ "@gamestdio/timer": "^1.3.0",
+ "debug": "^4.3.4",
+ "msgpackr": "^1.9.1",
+ "nanoid": "^2.0.0",
+ "ws": "^7.4.5"
+ },
+ "engines": {
+ "node": ">= 14.x"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/endel"
+ },
+ "peerDependencies": {
+ "@colyseus/schema": "^2.0.4"
+ }
+ },
+ "node_modules/@colyseus/greeting-banner": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@colyseus/greeting-banner/-/greeting-banner-2.0.6.tgz",
+ "integrity": "sha512-65nK7KnJn6g3ArtJqNfVX+Mx7xTlBka04kSwloLP7s24UpCEaK7bMGRLgkzfnysARzlVh1eV4jynBWZN82dYwQ==",
+ "license": "MIT"
+ },
+ "node_modules/@colyseus/monitor": {
+ "version": "0.15.8",
+ "resolved": "https://registry.npmjs.org/@colyseus/monitor/-/monitor-0.15.8.tgz",
+ "integrity": "sha512-+ObH0AsPoC+Co8eZczAHr6VMiM/AmLyNNOpOPMRLMI7/C4apO4IdPhJdU9ZqArgxHYYaSICWymmQWokVhoj60Q==",
+ "license": "MIT",
+ "dependencies": {
+ "express": "^4.16.2",
+ "node-os-utils": "^1.2.0"
+ }
+ },
+ "node_modules/@colyseus/schema": {
+ "version": "2.0.37",
+ "resolved": "https://registry.npmjs.org/@colyseus/schema/-/schema-2.0.37.tgz",
+ "integrity": "sha512-+WXEux9DMSaTz9hZKabl6LBuzsxzt9EvOwhXJ/G4rPCaaVkJ+iLxRsq8VbL2ZCx18E/uQH6nLaNIQVqH9wEt8w==",
+ "license": "MIT",
+ "bin": {
+ "schema-codegen": "bin/schema-codegen"
+ }
+ },
+ "node_modules/@colyseus/ws-transport": {
+ "version": "0.15.3",
+ "resolved": "https://registry.npmjs.org/@colyseus/ws-transport/-/ws-transport-0.15.3.tgz",
+ "integrity": "sha512-wm1AT1d6esUnZt1sUvrPcq9hkDBhZKZiB+fHCZEaPw3QDtG9slbOaZZ9Evr2DlxUUAaHU0H2qV3kchBYyL68UQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ws": "^7.4.4",
+ "ws": "^8.18.0"
+ },
+ "peerDependencies": {
+ "@colyseus/core": "0.15.x",
+ "@colyseus/schema": ">=1.0.0"
+ }
+ },
+ "node_modules/@colyseus/ws-transport/node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
+ "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
+ "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
+ "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
+ "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
+ "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
+ "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
+ "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
+ "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
+ "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
+ "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
+ "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
+ "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
+ "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
+ "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
+ "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
+ "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
+ "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
+ "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
+ "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
+ "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
+ "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@gamestdio/clock": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/@gamestdio/clock/-/clock-1.1.9.tgz",
+ "integrity": "sha512-O+PG3aRRytgX2BhAPMIhbM2ftq1Q8G4xUrYjEWYM6EmpoKn8oY4lXENGhpgfww6mQxHPbjfWyIAR6Xj3y1+avw==",
+ "license": "MIT"
+ },
+ "node_modules/@gamestdio/timer": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/@gamestdio/timer/-/timer-1.4.2.tgz",
+ "integrity": "sha512-WNciVCKSJzY56CM95TCVf+dtWShWNFUdziY1Qc+2gaqNCRbC3Egqzq9zumGRrV92Ym9GL6znkqTzF2AoAdydNw==",
+ "license": "MIT",
+ "dependencies": {
+ "@gamestdio/clock": "^1.1.9"
+ }
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
+ "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
+ "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
+ "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
+ "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
+ "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
+ "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/body-parser": {
+ "version": "1.19.6",
+ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+ "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/connect": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/connect": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/express": {
+ "version": "4.17.25",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
+ "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/body-parser": "*",
+ "@types/express-serve-static-core": "^4.17.33",
+ "@types/qs": "*",
+ "@types/serve-static": "^1"
+ }
+ },
+ "node_modules/@types/express-serve-static-core": {
+ "version": "4.19.8",
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
+ "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "@types/qs": "*",
+ "@types/range-parser": "*",
+ "@types/send": "*"
+ }
+ },
+ "node_modules/@types/http-errors": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/mime": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
+ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.7",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
+ "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/qs": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
+ "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/range-parser": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/serve-static": {
+ "version": "1.15.10",
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
+ "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/http-errors": "*",
+ "@types/node": "*",
+ "@types/send": "<1"
+ }
+ },
+ "node_modules/@types/serve-static/node_modules/@types/send": {
+ "version": "0.17.6",
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
+ "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/mime": "^1",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/ws": {
+ "version": "7.4.7",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz",
+ "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.4",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
+ "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.14.0",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/body-parser/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/body-parser/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
+ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.2",
+ "@esbuild/android-arm": "0.27.2",
+ "@esbuild/android-arm64": "0.27.2",
+ "@esbuild/android-x64": "0.27.2",
+ "@esbuild/darwin-arm64": "0.27.2",
+ "@esbuild/darwin-x64": "0.27.2",
+ "@esbuild/freebsd-arm64": "0.27.2",
+ "@esbuild/freebsd-x64": "0.27.2",
+ "@esbuild/linux-arm": "0.27.2",
+ "@esbuild/linux-arm64": "0.27.2",
+ "@esbuild/linux-ia32": "0.27.2",
+ "@esbuild/linux-loong64": "0.27.2",
+ "@esbuild/linux-mips64el": "0.27.2",
+ "@esbuild/linux-ppc64": "0.27.2",
+ "@esbuild/linux-riscv64": "0.27.2",
+ "@esbuild/linux-s390x": "0.27.2",
+ "@esbuild/linux-x64": "0.27.2",
+ "@esbuild/netbsd-arm64": "0.27.2",
+ "@esbuild/netbsd-x64": "0.27.2",
+ "@esbuild/openbsd-arm64": "0.27.2",
+ "@esbuild/openbsd-x64": "0.27.2",
+ "@esbuild/openharmony-arm64": "0.27.2",
+ "@esbuild/sunos-x64": "0.27.2",
+ "@esbuild/win32-arm64": "0.27.2",
+ "@esbuild/win32-ia32": "0.27.2",
+ "@esbuild/win32-x64": "0.27.2"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+ "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.3",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.14.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/express/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/finalhandler/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/finalhandler/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "4.13.1",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.1.tgz",
+ "integrity": "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/msgpackr": {
+ "version": "1.11.8",
+ "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.8.tgz",
+ "integrity": "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==",
+ "license": "MIT",
+ "optionalDependencies": {
+ "msgpackr-extract": "^3.0.2"
+ }
+ },
+ "node_modules/msgpackr-extract": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
+ "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "node-gyp-build-optional-packages": "5.2.2"
+ },
+ "bin": {
+ "download-msgpackr-prebuilds": "bin/download-prebuilds.js"
+ },
+ "optionalDependencies": {
+ "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "2.1.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz",
+ "integrity": "sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-gyp-build-optional-packages": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
+ "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "detect-libc": "^2.0.1"
+ },
+ "bin": {
+ "node-gyp-build-optional-packages": "bin.js",
+ "node-gyp-build-optional-packages-optional": "optional.js",
+ "node-gyp-build-optional-packages-test": "build-test.js"
+ }
+ },
+ "node_modules/node-os-utils": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/node-os-utils/-/node-os-utils-1.3.7.tgz",
+ "integrity": "sha512-fvnX9tZbR7WfCG5BAy3yO/nCLyjVWD6MghEq0z5FDfN+ZXpLWNITBdbifxQkQ25ebr16G0N7eRWJisOcMEHG3Q==",
+ "license": "MIT"
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.12",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
+ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
+ "license": "MIT"
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.14.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
+ "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/send/node_modules/debug/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tsx": {
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.27.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "license": "MIT"
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/ws": {
+ "version": "7.5.10",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
+ "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.3.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": "^5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/server/package.json b/server/package.json
new file mode 100644
index 0000000..d4d9bba
--- /dev/null
+++ b/server/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "cannon-war-server",
+ "version": "1.0.0",
+ "description": "Colyseus multiplayer server for Cannon War",
+ "type": "module",
+ "main": "dist/server/src/index.js",
+ "scripts": {
+ "dev": "tsx watch src/index.ts",
+ "build": "tsc && echo '✓ Server built successfully'",
+ "start": "node dist/server/src/index.js",
+ "start:prod": "NODE_ENV=production node dist/server/src/index.js"
+ },
+ "dependencies": {
+ "@colyseus/core": "^0.15.0",
+ "@colyseus/schema": "^2.0.0",
+ "@colyseus/ws-transport": "^0.15.0",
+ "@colyseus/monitor": "^0.15.0",
+ "express": "^4.18.2"
+ },
+ "devDependencies": {
+ "@types/express": "^4.17.21",
+ "@types/node": "^22.10.0",
+ "tsx": "^4.19.0",
+ "typescript": "^5.7.0"
+ }
+}
diff --git a/server/shared b/server/shared
new file mode 120000
index 0000000..8fba6b6
--- /dev/null
+++ b/server/shared
@@ -0,0 +1 @@
+../shared
\ No newline at end of file
diff --git a/server/src/config.ts b/server/src/config.ts
new file mode 100644
index 0000000..08cbe10
--- /dev/null
+++ b/server/src/config.ts
@@ -0,0 +1,81 @@
+/**
+ * Server configuration constants
+ */
+export const SERVER_CONFIG = {
+ // Server settings
+ port: parseInt(process.env.PORT || '2567', 10),
+ host: process.env.HOST || '0.0.0.0',
+
+ // Room settings
+ maxRoomsPerType: 100,
+
+ // Game room settings
+ maxPlayersPerRoom: 2,
+ roomReservationTime: 15000, // 15 seconds to join after reservation
+
+ // Reconnect settings
+ reconnectTimeout: 60000, // 60 seconds to reconnect
+ maxPauseTime: 180000, // 3 minutes max pause time
+
+ // Tick rate
+ tickRate: 60, // 60 ticks per second (matching client)
+ patchRate: 20, // Send state updates 20 times per second
+
+ // Development
+ isDev: process.env.NODE_ENV !== 'production',
+};
+
+/**
+ * PvP game configuration (mirroring client-side PVP_CONFIG)
+ */
+export const PVP_CONFIG = {
+ // Players
+ maxPlayers: 2,
+ initialMoney: 1000,
+
+ // Map sizes
+ mapSizes: {
+ small: { width: 4000, height: 3000 },
+ medium: { width: 6000, height: 4000 },
+ large: { width: 8000, height: 5000 },
+ },
+
+ // Base positions (ratio relative to map size)
+ basePositions: [
+ { xRatio: 0.2, yRatio: 0.5 }, // Player A (left)
+ { xRatio: 0.8, yRatio: 0.5 }, // Player B (right)
+ ],
+
+ // Spawning system
+ spawn: {
+ costMultiplier: 2, // spawn cost = addPrice × 2
+ },
+
+ // Territory
+ territory: {
+ enemyBuildCostMultiplier: 2, // Enemy territory build cost multiplier
+ },
+
+ // Vision
+ vision: {
+ spawnedMonsterProvidesVision: false,
+ },
+
+ // Manual cannon
+ manualCannon: {
+ price: 800,
+ damage: 100,
+ attackRadius: 400,
+ reloadTime: 60,
+ maxAmmo: 3,
+ explosionRadius: 50,
+ },
+
+ // Energy system
+ energy: {
+ perPlayerIndependent: true,
+ sharedMinesInCenter: true,
+ },
+};
+
+export type MapSize = keyof typeof PVP_CONFIG.mapSizes;
diff --git a/server/src/index.ts b/server/src/index.ts
new file mode 100644
index 0000000..8ac74d2
--- /dev/null
+++ b/server/src/index.ts
@@ -0,0 +1,88 @@
+/**
+ * Colyseus Server Entry Point
+ * Main server for Cannon War multiplayer
+ */
+import { Server } from '@colyseus/core';
+import { WebSocketTransport } from '@colyseus/ws-transport';
+import { monitor } from '@colyseus/monitor';
+import express from 'express';
+import { createServer } from 'http';
+import { SERVER_CONFIG } from './config.js';
+import { GameRoom } from './rooms/GameRoom.js';
+import { LobbyRoom } from './rooms/LobbyRoom.js';
+
+const app = express();
+
+// Enable JSON body parsing
+app.use(express.json());
+
+// Health check endpoint
+app.get('/health', (_req, res) => {
+ res.json({ status: 'ok', timestamp: Date.now() });
+});
+
+// CORS middleware for development
+if (SERVER_CONFIG.isDev) {
+ app.use((_req, res, next) => {
+ res.header('Access-Control-Allow-Origin', '*');
+ res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
+ res.header('Access-Control-Allow-Headers', 'Content-Type');
+ next();
+ });
+}
+
+// Create HTTP server
+const httpServer = createServer(app);
+
+// Create Colyseus server
+const gameServer = new Server({
+ transport: new WebSocketTransport({
+ server: httpServer,
+ pingInterval: 3000,
+ pingMaxRetries: 3,
+ }),
+});
+
+// Register game rooms
+gameServer.define('lobby', LobbyRoom);
+gameServer.define('game', GameRoom).enableRealtimeListing();
+
+// Colyseus monitor (development only)
+if (SERVER_CONFIG.isDev) {
+ app.use('/colyseus', monitor());
+}
+
+// Start server
+httpServer.listen(SERVER_CONFIG.port, SERVER_CONFIG.host, () => {
+ console.log(`
+╔════════════════════════════════════════════════════════╗
+║ Cannon War Multiplayer Server ║
+╠════════════════════════════════════════════════════════╣
+║ Status: Running ║
+║ Host: ${SERVER_CONFIG.host.padEnd(45)}║
+║ Port: ${String(SERVER_CONFIG.port).padEnd(45)}║
+║ Mode: ${(SERVER_CONFIG.isDev ? 'Development' : 'Production').padEnd(45)}║
+╚════════════════════════════════════════════════════════╝
+ `);
+
+ if (SERVER_CONFIG.isDev) {
+ console.log(` Monitor: http://localhost:${SERVER_CONFIG.port}/colyseus\n`);
+ }
+});
+
+// Graceful shutdown
+process.on('SIGTERM', () => {
+ console.log('SIGTERM received, shutting down gracefully...');
+ gameServer.gracefullyShutdown().then(() => {
+ console.log('Server shut down complete');
+ process.exit(0);
+ });
+});
+
+process.on('SIGINT', () => {
+ console.log('SIGINT received, shutting down gracefully...');
+ gameServer.gracefullyShutdown().then(() => {
+ console.log('Server shut down complete');
+ process.exit(0);
+ });
+});
diff --git a/server/src/rooms/GameRoom.ts b/server/src/rooms/GameRoom.ts
new file mode 100644
index 0000000..d50bf2e
--- /dev/null
+++ b/server/src/rooms/GameRoom.ts
@@ -0,0 +1,1720 @@
+/**
+ * Game Room - Main multiplayer game room
+ * Handles game logic, state synchronization, and player actions
+ */
+import { Room, Client, Delayed } from '@colyseus/core';
+import {
+ GameState,
+ PlayerState,
+ TowerState,
+ MonsterState,
+ BuildingState,
+ VectorSchema,
+ GamePhase,
+ GameEndReason,
+ PLAYER_COLORS,
+} from '../schema/index.js';
+import { SERVER_CONFIG, PVP_CONFIG, type MapSize } from '../config.js';
+import {
+ ClientMessage,
+ ServerMessage,
+ type BuildTowerPayload,
+ type BuildBuildingPayload,
+ type UpgradeTowerPayload,
+ type SellTowerPayload,
+ type SpawnMonsterPayload,
+ type CannonFirePayload,
+ type CannonSetAutoTargetPayload,
+ type UpgradeMinePayload,
+ type RepairMinePayload,
+ type DowngradeMinePayload,
+ type SellMinePayload,
+ type UpgradeVisionPayload,
+ type TerritorySyncPayload,
+} from '../shared/types/messages.js';
+import { TerritoryCalculator, type TerritoryResult } from '../systems/territory/territoryCalculator.js';
+import { EnergyCalculator } from '../systems/energy/energyCalculator.js';
+import { MineManager } from '../systems/mine/index.js';
+import {
+ CombatSystem,
+ DamageCalculator,
+ type BulletHitResult,
+ type CombatTickResult,
+ type MeleeResult,
+} from '../systems/combat/index.js';
+import { VisionSystem } from '../systems/vision/visionSystem.js';
+import { sendToVisible, sendToEntityOwnerAndVisible } from '../systems/vision/broadcastFilter.js';
+import {
+ InputValidator,
+ TowerMetaRegistry,
+ SpawnableMonsterRegistry,
+ BuildingMetaRegistry,
+} from '../validation/index.js';
+import { TOWER_META } from '../../../shared/config/towerMeta.js';
+import { BUILDING_META, getBuildingMeta } from '../../../shared/config/buildingMeta.js';
+import { TERRITORY_PENALTY } from '../../../shared/config/index.js';
+import {
+ SPAWNABLE_MONSTER_META,
+ getMonstersForWave,
+ type MonsterMetaData,
+} from '../../../shared/config/monsterMeta.js';
+import { getTowerCombatData } from '../../../shared/config/towerCombatMeta.js';
+import { getTowerBaseMeta } from '../../../shared/config/towerBaseMeta.js';
+import {
+ canUpgradeVision,
+ getVisionUpgradePrice,
+ VisionType,
+} from '../../../shared/config/visionMeta.js';
+
+/**
+ * Room options when creating/joining
+ */
+interface GameRoomOptions {
+ mapSize?: MapSize;
+ playerName?: string;
+ isPrivate?: boolean;
+ roomPassword?: string;
+}
+
+/**
+ * Client metadata
+ */
+interface ClientMetadata {
+ playerId: string;
+ playerName: string;
+ reconnectToken?: string;
+}
+
+export class GameRoom extends Room {
+ // State type declaration
+ declare state: GameState;
+ // Game loop
+ private gameLoopInterval: Delayed | null = null;
+ private readonly tickRate = SERVER_CONFIG.tickRate;
+ private readonly tickInterval = 1000 / this.tickRate;
+
+ // Reconnection tracking
+ private disconnectedClients: Map
=
+ new Map();
+
+ // Validation
+ private territoryCalc!: TerritoryCalculator;
+ private energyCalc!: EnergyCalculator;
+ private combatSystem!: CombatSystem;
+ private inputValidator!: InputValidator;
+ private towerMeta!: TowerMetaRegistry;
+ private spawnableMeta!: SpawnableMonsterRegistry;
+ private buildingMeta!: BuildingMetaRegistry;
+ private mineManager!: MineManager;
+ private visionSystem!: VisionSystem;
+
+ private territorySyncCounter: number = 0;
+ private readonly TERRITORY_SYNC_INTERVAL = 120; // Every 2 seconds at 60 ticks/sec
+
+ /**
+ * Room creation
+ */
+ onCreate(options: GameRoomOptions): void {
+ console.log(`[GameRoom] Room created: ${this.roomId}`);
+
+ // Initialize state
+ this.setState(new GameState());
+ this.state.roomId = this.roomId;
+
+ // Configure map
+ const mapSize = options.mapSize || 'medium';
+ const mapDimensions = PVP_CONFIG.mapSizes[mapSize];
+ this.state.mapConfig.size = mapSize;
+ this.state.mapConfig.width = mapDimensions.width;
+ this.state.mapConfig.height = mapDimensions.height;
+
+ // Set max clients
+ this.maxClients = PVP_CONFIG.maxPlayers;
+
+ // Set auto-dispose timeout
+ this.autoDispose = true;
+
+ // Initialize validation subsystem
+ this.initValidation();
+
+ // Register message handlers
+ this.registerMessageHandlers();
+
+ console.log(`[GameRoom] Map size: ${mapSize} (${mapDimensions.width}x${mapDimensions.height})`);
+ }
+
+ /**
+ * Initialize validation subsystem
+ */
+ private initValidation(): void {
+ // Territory calculator
+ this.territoryCalc = new TerritoryCalculator({ territoryRadius: 100 });
+
+ // Energy calculator
+ this.energyCalc = new EnergyCalculator();
+
+ // Damage calculator (combines territory + energy)
+ const damageCalc = new DamageCalculator(this.territoryCalc, this.energyCalc);
+
+ // Combat system (spatial grids + tower attacks + bullets)
+ this.combatSystem = new CombatSystem(
+ this.state.mapConfig.width,
+ this.state.mapConfig.height,
+ damageCalc
+ );
+ this.combatSystem.initTowerConfigs();
+
+ // Tower metadata registry - populated from shared config
+ this.towerMeta = new TowerMetaRegistry();
+ for (const [id, meta] of Object.entries(TOWER_META)) {
+ this.towerMeta.register(id, meta.price, meta.levelUpArr);
+ }
+
+ // Building metadata registry - populated from shared config
+ this.buildingMeta = new BuildingMetaRegistry();
+ for (const [id, meta] of Object.entries(BUILDING_META)) {
+ this.buildingMeta.register(id, meta.price, meta.radius, meta.hp);
+ }
+
+ // Spawnable monster registry - populated from shared config
+ this.spawnableMeta = new SpawnableMonsterRegistry();
+ for (const meta of Object.values(SPAWNABLE_MONSTER_META)) {
+ this.spawnableMeta.register({
+ monsterId: meta.monsterId,
+ cost: meta.cost,
+ cooldownTicks: meta.cooldownTicks,
+ unlockWave: meta.unlockWave,
+ });
+ }
+
+ // Input validator
+ this.inputValidator = new InputValidator(
+ this.state,
+ this.territoryCalc,
+ this.towerMeta,
+ this.spawnableMeta,
+ this.buildingMeta
+ );
+
+ // Mine manager
+ this.mineManager = new MineManager(this.state, this.energyCalc, this.territoryCalc);
+
+ // Vision system (fog of war)
+ this.visionSystem = new VisionSystem();
+ this.state._visionSystem = this.visionSystem;
+ }
+
+ /**
+ * Register all client message handlers
+ */
+ private registerMessageHandlers(): void {
+ // Player ready
+ this.onMessage(ClientMessage.PLAYER_READY, (client) => {
+ this.handlePlayerReady(client);
+ });
+
+ this.onMessage(ClientMessage.PLAYER_NOT_READY, (client) => {
+ this.handlePlayerNotReady(client);
+ });
+
+ // Building actions
+ this.onMessage(ClientMessage.BUILD_TOWER, (client, payload: BuildTowerPayload) => {
+ this.handleBuildTower(client, payload);
+ });
+
+ this.onMessage(ClientMessage.BUILD_BUILDING, (client, payload: BuildBuildingPayload) => {
+ this.handleBuildBuilding(client, payload);
+ });
+
+ this.onMessage(ClientMessage.UPGRADE_TOWER, (client, payload: UpgradeTowerPayload) => {
+ this.handleUpgradeTower(client, payload);
+ });
+
+ this.onMessage(ClientMessage.SELL_TOWER, (client, payload: SellTowerPayload) => {
+ this.handleSellTower(client, payload);
+ });
+
+ // Spawner actions
+ this.onMessage(ClientMessage.SPAWN_MONSTER, (client, payload: SpawnMonsterPayload) => {
+ this.handleSpawnMonster(client, payload);
+ });
+
+ // Manual cannon actions
+ this.onMessage(ClientMessage.CANNON_FIRE, (client, payload: CannonFirePayload) => {
+ this.handleCannonFire(client, payload);
+ });
+
+ this.onMessage(
+ ClientMessage.CANNON_SET_AUTO_TARGET,
+ (client, payload: CannonSetAutoTargetPayload) => {
+ this.handleCannonSetAutoTarget(client, payload);
+ }
+ );
+
+ // Game control
+ this.onMessage(ClientMessage.SURRENDER, (client) => {
+ this.handleSurrender(client);
+ });
+
+ // Mine actions
+ this.onMessage(ClientMessage.UPGRADE_MINE, (client, payload: UpgradeMinePayload) => {
+ this.handleUpgradeMine(client, payload);
+ });
+
+ this.onMessage(ClientMessage.REPAIR_MINE, (client, payload: RepairMinePayload) => {
+ this.handleRepairMine(client, payload);
+ });
+
+ this.onMessage(ClientMessage.DOWNGRADE_MINE, (client, payload: DowngradeMinePayload) => {
+ this.handleDowngradeMine(client, payload);
+ });
+
+ this.onMessage(ClientMessage.SELL_MINE, (client, payload: SellMinePayload) => {
+ this.handleSellMine(client, payload);
+ });
+
+ // Vision actions
+ this.onMessage(ClientMessage.UPGRADE_VISION, (client, payload: UpgradeVisionPayload) => {
+ this.handleUpgradeVision(client, payload);
+ });
+ }
+
+ /**
+ * Player joins the room
+ */
+ onJoin(client: Client, options: GameRoomOptions): void {
+ const playerName = options.playerName || `Player ${this.state.getPlayerCount() + 1}`;
+ const playerIndex = this.state.getPlayerCount();
+
+ console.log(`[GameRoom] Player joined: ${playerName} (${client.sessionId})`);
+
+ // Check if this is a reconnection
+ const reconnectData = this.disconnectedClients.get(client.sessionId);
+ if (reconnectData) {
+ this.handleReconnection(client, reconnectData.playerState);
+ return;
+ }
+
+ // Create new player
+ const player = new PlayerState(client.sessionId, playerName, playerIndex);
+ player.sessionId = client.sessionId;
+ player.color = PLAYER_COLORS[playerIndex];
+ player.money = PVP_CONFIG.initialMoney;
+
+ // Set base position
+ const basePos = PVP_CONFIG.basePositions[playerIndex];
+ player.basePosition = new VectorSchema(
+ this.state.mapConfig.width * basePos.xRatio,
+ this.state.mapConfig.height * basePos.yRatio
+ );
+
+ // Add to state
+ this.state.players.set(client.sessionId, player);
+
+ // Add to vision system
+ this.visionSystem.addPlayer(client.sessionId);
+
+ // Store client metadata
+ (client as unknown as { metadata: ClientMetadata }).metadata = {
+ playerId: client.sessionId,
+ playerName: playerName,
+ };
+
+ // Notify others
+ this.broadcast(ServerMessage.PLAYER_JOINED, {
+ playerId: client.sessionId,
+ playerName: playerName,
+ playerIndex: playerIndex,
+ });
+
+ // Create base building for this player
+ this.createPlayerBase(player);
+ }
+
+ /**
+ * Create base building for a player
+ */
+ private createPlayerBase(player: PlayerState): void {
+ const baseId = this.state.generateEntityId('base');
+ const base = new BuildingState();
+ base.id = baseId;
+ base.ownerId = player.id;
+ base.buildingType = 'RootBuilding';
+ base.isBase = true;
+ base.hp = 10000;
+ base.maxHp = 10000;
+ base.radius = 30;
+ base.setPosition(player.basePosition.x, player.basePosition.y);
+
+ this.state.buildings.set(baseId, base);
+ }
+
+ /**
+ * Player leaves the room
+ */
+ async onLeave(client: Client, consented?: boolean): Promise {
+ const player = this.state.getPlayer(client.sessionId);
+ if (!player) return;
+
+ console.log(
+ `[GameRoom] Player left: ${player.name} (consented: ${consented}, phase: ${this.state.phase})`
+ );
+
+ // If game hasn't started, remove player
+ if (this.state.phase === GamePhase.WAITING) {
+ this.removePlayer(client.sessionId);
+ return;
+ }
+
+ // If game is in progress
+ if (this.state.phase === GamePhase.PLAYING) {
+ if (consented) {
+ // Player intentionally left - treat as surrender
+ this.handlePlayerElimination(client.sessionId, 'surrender');
+ } else {
+ // Disconnected - allow reconnection
+ player.isConnected = false;
+ this.state.disconnectedPlayerId = client.sessionId;
+ this.state.reconnectDeadline = Date.now() + SERVER_CONFIG.reconnectTimeout;
+ this.state.phase = GamePhase.PAUSED;
+
+ // Track disconnected player
+ this.disconnectedClients.set(client.sessionId, {
+ deadline: this.state.reconnectDeadline,
+ playerState: player,
+ });
+
+ // Notify others
+ this.broadcast(ServerMessage.PLAYER_DISCONNECTED, {
+ playerId: client.sessionId,
+ reconnectDeadline: this.state.reconnectDeadline,
+ });
+
+ // Set timeout for reconnection
+ this.clock.setTimeout(() => {
+ if (this.disconnectedClients.has(client.sessionId)) {
+ console.log(`[GameRoom] Reconnect timeout for: ${player.name}`);
+ this.handlePlayerElimination(client.sessionId, 'disconnect_timeout');
+ this.disconnectedClients.delete(client.sessionId);
+ this.resumeGame();
+ }
+ }, SERVER_CONFIG.reconnectTimeout);
+
+ // Allow reconnection
+ try {
+ await this.allowReconnection(client, SERVER_CONFIG.reconnectTimeout / 1000);
+ } catch {
+ // Reconnection timeout handled above
+ }
+ }
+ }
+ }
+
+ /**
+ * Handle player reconnection
+ */
+ private handleReconnection(client: Client, playerState: PlayerState): void {
+ console.log(`[GameRoom] Player reconnected: ${playerState.name}`);
+
+ playerState.isConnected = true;
+ playerState.sessionId = client.sessionId;
+
+ this.disconnectedClients.delete(client.sessionId);
+ this.state.disconnectedPlayerId = '';
+ this.state.reconnectDeadline = 0;
+
+ // Notify others
+ this.broadcast(ServerMessage.PLAYER_RECONNECTED, {
+ playerId: client.sessionId,
+ });
+
+ // Resume game if was paused for this player
+ if (this.state.phase === GamePhase.PAUSED) {
+ this.resumeGame();
+ }
+ }
+
+ /**
+ * Remove a player from the game
+ */
+ private removePlayer(playerId: string): void {
+ // Remove player's base
+ this.state.buildings.forEach((building: BuildingState, id: string) => {
+ if (building.ownerId === playerId) {
+ this.state.buildings.delete(id);
+ }
+ });
+
+ // Remove player
+ this.state.players.delete(playerId);
+ this.visionSystem.removePlayer(playerId);
+
+ this.broadcast(ServerMessage.PLAYER_LEFT, { playerId });
+ }
+
+ /**
+ * Handle player elimination
+ */
+ private handlePlayerElimination(playerId: string, reason: string): void {
+ const player = this.state.getPlayer(playerId);
+ if (!player) return;
+
+ player.isAlive = false;
+
+ // Remove player's entities
+ this.state.towers.forEach((tower: TowerState, id: string) => {
+ if (tower.ownerId === playerId) {
+ this.state.towers.delete(id);
+ }
+ });
+
+ this.state.buildings.forEach((building: BuildingState, id: string) => {
+ if (building.ownerId === playerId) {
+ this.state.buildings.delete(id);
+ }
+ });
+
+ // Mark territory, energy, and vision dirty after entity removal
+ this.territoryCalc.markDirty();
+ this.energyCalc.markDirty();
+ this.visionSystem.markDirty();
+
+ // Notify
+ this.broadcast(ServerMessage.PLAYER_ELIMINATED, {
+ playerId,
+ reason,
+ });
+
+ // Check for game end
+ this.checkGameEnd();
+ }
+
+ /**
+ * Room disposal
+ */
+ onDispose(): void {
+ console.log(`[GameRoom] Room disposed: ${this.roomId}`);
+ this.stopGameLoop();
+ }
+
+ // ==================== Game Loop ====================
+
+ /**
+ * Start the game loop
+ */
+ private startGameLoop(): void {
+ if (this.gameLoopInterval) return;
+
+ this.gameLoopInterval = this.clock.setInterval(() => {
+ this.gameTick();
+ }, this.tickInterval);
+
+ console.log(`[GameRoom] Game loop started (${this.tickRate} ticks/sec)`);
+ }
+
+ /**
+ * Stop the game loop
+ */
+ private stopGameLoop(): void {
+ if (this.gameLoopInterval) {
+ this.gameLoopInterval.clear();
+ this.gameLoopInterval = null;
+ }
+ }
+
+ /**
+ * Main game tick
+ */
+
+ /**
+ * Convert TerritoryResult map to the format EnergyCalculator expects:
+ * playerId -> Set of valid entity IDs
+ */
+ private buildValidTerritoryIds(
+ territoryResults: Map
+ ): Map> {
+ const result = new Map>();
+ for (const [playerId, territory] of territoryResults) {
+ result.set(playerId, territory.validBuildings);
+ }
+ return result;
+ }
+
+ /**
+ * Apply territory penalties to all towers
+ */
+ private applyTerritoryPenalties(): void {
+ const results = this.territoryCalc.recalculate(
+ this.state.buildings,
+ this.state.towers,
+ this.state.mines
+ );
+
+ this.state.towers.forEach((tower) => {
+ const playerResult = results.get(tower.ownerId);
+ if (!playerResult) return;
+
+ const isValid = playerResult.validBuildings.has(tower.id);
+ if (isValid === tower.inValidTerritory) return;
+
+ tower.inValidTerritory = isValid;
+
+ if (isValid) {
+ this.removeTowerPenalty(tower);
+ } else {
+ this.applyTowerPenalty(tower);
+ }
+ });
+ }
+
+ private applyTowerPenalty(tower: TowerState): void {
+ tower.maxHp = Math.round(tower._baseMaxHp * TERRITORY_PENALTY.HP_MULTIPLIER);
+ tower.hp = Math.min(tower.hp, tower.maxHp);
+ tower.attackRadius = Math.round(tower._baseAttackRadius * TERRITORY_PENALTY.RANGE_MULTIPLIER);
+ }
+
+ private removeTowerPenalty(tower: TowerState): void {
+ tower.maxHp = tower._baseMaxHp;
+ tower.attackRadius = tower._baseAttackRadius;
+ }
+
+ /**
+ * Apply energy money penalties/bonuses to player states
+ */
+ private applyEnergyMoneyChanges(moneyChanges: Map): void {
+ for (const [playerId, change] of moneyChanges) {
+ const player = this.state.getPlayer(playerId);
+ if (!player || !player.isAlive) continue;
+ player.money = Math.max(0, player.money + change);
+ }
+ }
+
+ private gameTick(): void {
+ if (this.state.phase !== GamePhase.PLAYING) return;
+
+ this.state.currentTick++;
+
+ // Phase 1: Entity updates
+ this.updateMonsters();
+ this.updateTowers();
+ this.updateBuildings();
+ this.updateWave();
+ this.mineManager.updateRepairs();
+
+ // Phase 1.5: Territory + Energy recalculation (dirty flag, O(1) when clean)
+ const territoryResults = this.territoryCalc.recalculate(
+ this.state.buildings,
+ this.state.towers,
+ this.state.mines
+ );
+ const validTerritoryIds = this.buildValidTerritoryIds(territoryResults);
+
+ // Phase 1.5b: Sync mine production to energy calculator
+ this.mineManager.syncToEnergyCalc(validTerritoryIds);
+
+ // Phase 1.5c: Energy recalculation
+ this.energyCalc.recalculate(
+ this.state.towers,
+ this.state.buildings,
+ validTerritoryIds
+ );
+
+ // Phase 1.6: Energy tick (penalties/bonuses, frequency controlled internally)
+ const moneyChanges = this.energyCalc.processTick(this.state.currentTick);
+ this.applyEnergyMoneyChanges(moneyChanges);
+
+ // Phase 1.7: Sync energy state to PlayerState for client display
+ this.syncEnergyToPlayerStates();
+
+ // Phase 1.75: Territory sync (periodic)
+ this.syncTerritoryToClients();
+
+ // Phase 1.8: Vision recalculation (fog of war filtering)
+ this.updateVision();
+
+ // Phase 2: Combat (DamageCalculator now reads correct satisfactionRatio)
+ const combatResult = this.combatSystem.update(
+ this.state.currentTick,
+ this.state.towers,
+ this.state.monsters,
+ this.state.buildings
+ );
+ this.applyCombatResult(combatResult);
+
+ // Phase 2.5: Manual cannon auto-fire
+ const autoFireEvents = this.combatSystem.processCannonAutoFire(
+ this.state.currentTick,
+ this.state.towers
+ );
+ for (const event of autoFireEvents) {
+ sendToVisible(this, this.visionSystem, ServerMessage.BULLET_FIRED, event, event.x, event.y);
+ }
+
+ // Phase 3: Monster melee (buildings + mines)
+ const meleeResults = this.combatSystem.processMonsterMelee(
+ this.state.monsters,
+ this.state.buildings,
+ this.state.mines
+ );
+ this.applyMeleeResults(meleeResults);
+
+ // Phase 4: Game end check
+ this.checkGameEnd();
+ }
+
+ /**
+ * Update all monsters
+ */
+ private updateMonsters(): void {
+ this.state.monsters.forEach((monster: MonsterState) => {
+ // Save previous position for sweep collision
+ monster.prevX = monster.position.x;
+ monster.prevY = monster.position.y;
+
+ // Update position based on velocity
+ monster.position.x += monster.velocity.x;
+ monster.position.y += monster.velocity.y;
+
+ // Update status effects
+ if (monster.isFrozen && this.state.currentTick >= monster.freezeEndTime) {
+ monster.isFrozen = false;
+ }
+ if (monster.isSlowed && this.state.currentTick >= monster.slowEndTime) {
+ monster.isSlowed = false;
+ }
+
+ // Burn DoT
+ if (monster.burnRate > 0) {
+ const burnDamage = monster.maxHp * monster.burnRate;
+ monster.hp -= burnDamage;
+ if (monster.hp <= 0) {
+ this.killMonster(monster, '', monster.burnSourceOwnerId);
+ }
+ }
+ });
+ }
+
+ /**
+ * Update all towers
+ */
+ private updateTowers(): void {
+ // Tower-specific updates (rotation, ammo reload)
+ this.state.towers.forEach((tower: TowerState) => {
+ if (tower.isManual && tower.currentAmmo < tower.maxAmmo) {
+ // Reload 1 ammo every 120 ticks (~2 seconds at 60fps)
+ if (this.state.currentTick % 120 === 0) {
+ tower.currentAmmo++;
+ }
+ }
+ });
+ }
+
+ /**
+ * Update all buildings
+ */
+ private updateBuildings(): void {
+ this.state.buildings.forEach((building: BuildingState) => {
+ if (building.isSpawner) {
+ // Update cooldowns
+ building.cooldowns.forEach((cooldown: { remainingTicks: number }) => {
+ if (cooldown.remainingTicks > 0) {
+ cooldown.remainingTicks--;
+ }
+ });
+ }
+ });
+ }
+
+ /**
+ * Update wave state
+ */
+ private updateWave(): void {
+ if (!this.state.wave.isWaveActive) {
+ this.state.wave.nextWaveTime--;
+ if (this.state.wave.nextWaveTime <= 0) {
+ this.spawnWave();
+ }
+ } else {
+ // Check if wave is complete
+ const neutralMonsters = Array.from(this.state.monsters.values()).filter(
+ (m: MonsterState) => m.ownerId === ''
+ );
+ if (neutralMonsters.length === 0) {
+ this.state.wave.isWaveActive = false;
+ this.state.wave.nextWaveTime = 200 * this.tickRate; // Next wave in 200 ticks
+
+ this.broadcast(ServerMessage.WAVE_COMPLETED, {
+ waveNumber: this.state.wave.currentWave,
+ });
+ }
+ }
+ }
+
+ /**
+ * Spawn a new wave of neutral monsters
+ */
+ private spawnWave(): void {
+ this.state.wave.currentWave++;
+ this.state.wave.isWaveActive = true;
+
+ const waveNumber = this.state.wave.currentWave;
+ const monsterCount = Math.floor(5 + waveNumber * 2);
+ const alivePlayers = this.state.getAlivePlayers();
+
+ this.broadcast(ServerMessage.WAVE_STARTING, {
+ waveNumber,
+ monsterCount,
+ });
+
+ // Spawn monsters distributed among alive players
+ for (let i = 0; i < monsterCount; i++) {
+ const targetPlayer = alivePlayers[i % alivePlayers.length];
+ if (!targetPlayer) continue;
+
+ const monsterType = this.getMonsterTypeForWave(waveNumber);
+ const meta = SPAWNABLE_MONSTER_META[monsterType];
+
+ const monster = new MonsterState();
+ monster.id = this.state.generateEntityId('monster');
+ monster.ownerId = ''; // Neutral
+ monster.targetPlayerId = targetPlayer.id;
+ monster.monsterType = monsterType;
+
+ // Use metadata for base stats, scale HP with wave
+ monster.hp = (meta?.baseHp ?? 100) + waveNumber * 20;
+ monster.maxHp = monster.hp;
+ monster.radius = meta?.radius ?? 10;
+ monster.speed = meta?.speed ?? 2;
+
+ // Spawn from map edge, heading toward target player's base
+ const spawnPos = this.getEdgeSpawnPosition(targetPlayer);
+ monster.setPosition(spawnPos.x, spawnPos.y);
+
+ // Set velocity toward target base
+ const dx = targetPlayer.basePosition.x - spawnPos.x;
+ const dy = targetPlayer.basePosition.y - spawnPos.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ if (dist > 0) {
+ monster.setVelocity((dx / dist) * monster.speed, (dy / dist) * monster.speed);
+ }
+
+ this.state.monsters.set(monster.id, monster);
+ }
+
+ this.state.wave.monstersRemaining = monsterCount;
+ }
+
+ /**
+ * Get monster type based on wave number
+ */
+ private getMonsterTypeForWave(waveNumber: number): string {
+ const available = getMonstersForWave(waveNumber);
+ if (available.length === 0) return 'Normal';
+
+ // Sort by unlock wave descending, pick from top 3 candidates
+ const sorted = available.sort((a, b) => b.unlockWave - a.unlockWave);
+ const candidates = sorted.slice(0, Math.min(3, sorted.length));
+ return candidates[Math.floor(Math.random() * candidates.length)].monsterId;
+ }
+
+ /**
+ * Get spawn position from map edge
+ */
+ private getEdgeSpawnPosition(targetPlayer: PlayerState): { x: number; y: number } {
+ const edge = Math.floor(Math.random() * 4);
+ const margin = 50;
+
+ switch (edge) {
+ case 0: // Top
+ return { x: Math.random() * this.state.mapConfig.width, y: -margin };
+ case 1: // Right
+ return { x: this.state.mapConfig.width + margin, y: Math.random() * this.state.mapConfig.height };
+ case 2: // Bottom
+ return { x: Math.random() * this.state.mapConfig.width, y: this.state.mapConfig.height + margin };
+ default: // Left
+ return { x: -margin, y: Math.random() * this.state.mapConfig.height };
+ }
+ }
+
+ /**
+ * Apply combat tick results (bullet hits, fired events)
+ */
+ private applyCombatResult(result: CombatTickResult): void {
+ // Send bullet fired events to visible clients
+ for (const event of result.bulletsFired) {
+ sendToVisible(this, this.visionSystem, ServerMessage.BULLET_FIRED, event, event.x, event.y);
+ }
+
+ // Process bullet hits
+ for (const hit of result.bulletsHit) {
+ if (hit.targetType === 'monster') {
+ const monster = this.state.monsters.get(hit.targetId);
+ if (monster) {
+ this.damageMonster(monster, hit.damage, hit.towerId);
+ this.applyStatusEffects(monster, hit);
+
+ // Process explosion targets
+ for (const expTarget of hit.explosionTargets) {
+ const expMonster = this.state.monsters.get(expTarget.id);
+ if (expMonster) {
+ this.damageMonster(expMonster, expTarget.damage, hit.towerId);
+ this.applyStatusEffects(expMonster, hit);
+ }
+ }
+ }
+ } else if (hit.targetType === 'building') {
+ const building = this.state.buildings.get(hit.targetId);
+ if (building) {
+ this.damageBuilding(building, hit.damage, hit.towerId);
+ }
+ }
+ }
+
+ // Broadcast bullet removed events (IDs only, no position data to filter)
+ if (result.bulletsRemoved.length > 0) {
+ this.broadcast(ServerMessage.BULLET_HIT, {
+ removedBullets: result.bulletsRemoved,
+ });
+ }
+ }
+
+ /**
+ * Apply melee collision results
+ */
+ private applyMeleeResults(results: MeleeResult[]): void {
+ for (const result of results) {
+ if (result.mineId) {
+ // Mine hit
+ const destroyed = this.mineManager.damageMine(
+ result.mineId,
+ result.damage,
+ result.monsterId
+ );
+ if (destroyed) {
+ const mine = this.state.mines.get(result.mineId);
+ const mx = mine?.position.x ?? 0;
+ const my = mine?.position.y ?? 0;
+ sendToVisible(this, this.visionSystem, ServerMessage.MINE_DESTROYED, {
+ mineId: result.mineId,
+ destroyedBy: result.monsterId,
+ }, mx, my);
+ }
+ } else {
+ // Building hit
+ const building = this.state.buildings.get(result.buildingId);
+ if (building) {
+ this.damageBuilding(building, result.damage, result.monsterId);
+ }
+ }
+ this.removeMonster(result.monsterId);
+ }
+ }
+
+ /**
+ * Sync energy state from EnergyCalculator to PlayerState for client display
+ */
+ private syncEnergyToPlayerStates(): void {
+ for (const [playerId, energy] of this.energyCalc.getPlayerStates()) {
+ const player = this.state.getPlayer(playerId);
+ if (!player) continue;
+ player.energyProduction = energy.production;
+ player.energyConsumption = energy.consumption;
+ player.energySatisfaction = energy.satisfactionRatio;
+ }
+ }
+
+
+ /**
+ * Periodically broadcast authoritative territory state to clients
+ * Runs every TERRITORY_SYNC_INTERVAL ticks (default: 2 seconds)
+ */
+ private syncTerritoryToClients(): void {
+ this.territorySyncCounter++;
+ if (this.territorySyncCounter < this.TERRITORY_SYNC_INTERVAL) return;
+ this.territorySyncCounter = 0;
+
+ const payload: TerritorySyncPayload = { territories: {} };
+
+ // Build payload from current territory calculator results
+ for (const [playerId, _player] of this.state.players) {
+ const result = this.territoryCalc.getPlayerResult(playerId);
+ if (!result) continue;
+
+ payload.territories[playerId] = {
+ validBuildings: Array.from(result.validBuildings),
+ invalidBuildings: Array.from(result.invalidBuildings),
+ };
+ }
+
+ // Broadcast to all clients
+ this.broadcast(ServerMessage.TERRITORY_SYNC, payload);
+ }
+
+ /**
+ * Recalculate vision for all players and touch entities whose visibility changed.
+ * Uses OPERATION.TOUCH to trigger @filterChildren without sending data bytes.
+ */
+ private updateVision(): void {
+ const changedIds = this.visionSystem.recalculate(
+ this.state.currentTick,
+ this.state.towers,
+ this.state.monsters,
+ this.state.buildings,
+ this.state.mines,
+ );
+
+ if (changedIds.size === 0) return;
+
+ // Touch entities whose visibility changed to force @filterChildren re-evaluation
+ // Using self-assign on a field to trigger the dirty flag
+ for (const id of changedIds) {
+ const tower = this.state.towers.get(id);
+ if (tower) { tower.hp = tower.hp; continue; }
+
+ const building = this.state.buildings.get(id);
+ if (building) { building.hp = building.hp; continue; }
+
+ const mine = this.state.mines.get(id);
+ if (mine) { mine.hp = mine.hp; continue; }
+
+ // Monsters move every tick, their filter triggers naturally
+ }
+ }
+
+ /**
+ * Handle vision upgrade request
+ */
+ private handleUpgradeVision(client: Client, payload: UpgradeVisionPayload): void {
+ const playerId = client.sessionId;
+ const { towerId, visionType } = payload;
+
+ // Whitelist validation for visionType
+ const validTypes: string[] = ['observer', 'radar'];
+ if (!validTypes.includes(visionType)) {
+ console.warn(`[GameRoom] Invalid visionType: ${visionType}`);
+ return;
+ }
+
+ // Validate tower exists and belongs to player
+ const tower = this.state.towers.get(towerId);
+ if (!tower || tower.ownerId !== playerId) {
+ this.sendActionRejected(client, 'UPGRADE_VISION', 'Tower not found or not owned');
+ return;
+ }
+
+ // Validate upgrade is possible
+ if (!canUpgradeVision(tower.visionType, tower.visionLevel, visionType)) {
+ this.sendActionRejected(client, 'UPGRADE_VISION', 'Cannot upgrade vision');
+ return;
+ }
+
+ // Calculate price and check money
+ const price = getVisionUpgradePrice(tower.visionType, tower.visionLevel, visionType);
+ const player = this.state.getPlayer(playerId);
+ if (!player || player.money < price) {
+ this.sendActionRejected(client, 'UPGRADE_VISION', 'Not enough money');
+ return;
+ }
+
+ // Apply upgrade
+ player.money -= price;
+ if (tower.visionType === visionType) {
+ tower.visionLevel++;
+ } else {
+ tower.visionType = visionType;
+ tower.visionLevel = 1;
+ }
+
+ // Mark vision dirty for recalculation
+ this.visionSystem.markDirty();
+
+ console.log(
+ `[GameRoom] Vision upgraded: ${towerId} -> ${visionType} lv${tower.visionLevel} by ${player.name} (cost: ${price})`
+ );
+ }
+
+ // === Mine Message Handlers ===
+
+ private handleUpgradeMine(client: Client, payload: UpgradeMinePayload): void {
+ const playerId = client.sessionId;
+ const result = this.mineManager.upgradeMine(payload.mineId, playerId);
+ if (!result.ok) {
+ this.sendActionRejected(client, 'upgrade_mine', result.error!);
+ }
+ }
+
+ private handleRepairMine(client: Client, payload: RepairMinePayload): void {
+ const playerId = client.sessionId;
+ const result = this.mineManager.repairMine(payload.mineId, playerId);
+ if (!result.ok) {
+ this.sendActionRejected(client, 'repair_mine', result.error!);
+ }
+ }
+
+ private handleDowngradeMine(client: Client, payload: DowngradeMinePayload): void {
+ const playerId = client.sessionId;
+ const result = this.mineManager.downgradeMine(payload.mineId, playerId);
+ if (!result.ok) {
+ this.sendActionRejected(client, 'downgrade_mine', result.error!);
+ }
+ }
+
+ private handleSellMine(client: Client, payload: SellMinePayload): void {
+ const playerId = client.sessionId;
+ const result = this.mineManager.sellMine(payload.mineId, playerId);
+ if (!result.ok) {
+ this.sendActionRejected(client, 'sell_mine', result.error!);
+ }
+ }
+
+ /**
+ * Apply status effects from bullet hit to monster
+ */
+ private applyStatusEffects(monster: MonsterState, hit: BulletHitResult): void {
+ // Freeze effect
+ if (hit.freezeMultiplier < 1) {
+ monster.isSlowed = true;
+ monster.slowEndTime = this.state.currentTick + 180; // ~3 seconds at 60fps
+ // Apply speed reduction
+ monster.velocity.x *= hit.freezeMultiplier;
+ monster.velocity.y *= hit.freezeMultiplier;
+ }
+
+ // Burn effect
+ if (hit.burnRate > 0) {
+ // Stack burn rate with cap (matches client maxBurnRate)
+ monster.burnRate = Math.min(monster.burnRate + hit.burnRate, 0.005);
+ // Track who applied the burn for kill attribution
+ monster.burnSourceOwnerId = hit.ownerId;
+ // Fire clears ice
+ if (monster.isSlowed) {
+ monster.isSlowed = false;
+ }
+ }
+ }
+
+ /**
+ * Damage a monster
+ */
+ private damageMonster(monster: MonsterState, damage: number, sourceId: string): void {
+ monster.hp -= damage;
+
+ sendToVisible(this, this.visionSystem, ServerMessage.MONSTER_DAMAGED, {
+ monsterId: monster.id,
+ damage,
+ sourceId,
+ }, monster.position.x, monster.position.y);
+
+ if (monster.hp <= 0) {
+ this.killMonster(monster, sourceId);
+ }
+ }
+
+ /**
+ * Kill a monster
+ */
+ private killMonster(monster: MonsterState, killerId: string, killerOwnerId?: string): void {
+ // Award money to killer - killerId can be a tower ID or bullet owner ID
+ // killerOwnerId can be passed directly (e.g. burn kills) to skip tower lookup
+ if (!killerOwnerId) {
+ const killerTower = this.state.towers.get(killerId);
+ killerOwnerId = killerTower?.ownerId || '';
+ }
+
+ if (killerOwnerId) {
+ const player = this.state.getPlayer(killerOwnerId);
+ if (player) {
+ const reward = this.getMonsterReward(monster.monsterType);
+ player.money += reward;
+ player.monstersKilled++;
+
+ sendToEntityOwnerAndVisible(this, this.visionSystem, ServerMessage.MONSTER_KILLED, {
+ monsterId: monster.id,
+ killerId,
+ reward,
+ }, killerOwnerId, monster.position.x, monster.position.y);
+ }
+ }
+
+ this.removeMonster(monster.id);
+ }
+
+ /**
+ * Get monster kill reward
+ */
+ private getMonsterReward(monsterType: string): number {
+ const meta = SPAWNABLE_MONSTER_META[monsterType];
+ return meta?.reward ?? 10;
+ }
+
+ /**
+ * Remove a monster from state
+ */
+ private removeMonster(monsterId: string): void {
+ this.state.monsters.delete(monsterId);
+ if (this.state.wave.isWaveActive) {
+ this.state.wave.monstersRemaining--;
+ }
+ }
+
+ /**
+ * Damage a building
+ */
+ private damageBuilding(building: BuildingState, damage: number, sourceId: string): void {
+ building.hp -= damage;
+
+ sendToEntityOwnerAndVisible(this, this.visionSystem, ServerMessage.BUILDING_DAMAGED, {
+ buildingId: building.id,
+ damage,
+ sourceId,
+ }, building.ownerId, building.position.x, building.position.y);
+
+ if (building.hp <= 0) {
+ this.destroyBuilding(building, sourceId);
+ }
+ }
+
+ /**
+ * Destroy a building
+ */
+ private destroyBuilding(building: BuildingState, sourceId: string): void {
+ // Building owner always receives destruction event
+ sendToEntityOwnerAndVisible(this, this.visionSystem, ServerMessage.BUILDING_DESTROYED, {
+ buildingId: building.id,
+ sourceId,
+ wasBase: building.isBase,
+ }, building.ownerId, building.position.x, building.position.y);
+
+ // If this was a base, eliminate the player
+ if (building.isBase && building.ownerId) {
+ this.handlePlayerElimination(building.ownerId, 'base_destroyed');
+ }
+
+ this.state.buildings.delete(building.id);
+
+ // Mark territory, energy, and vision dirty
+ this.territoryCalc.markDirty();
+ this.energyCalc.markDirty();
+ this.visionSystem.markDirty();
+ }
+
+ /**
+ * Check if game has ended
+ */
+ private checkGameEnd(): void {
+ const alivePlayers = this.state.getAlivePlayers();
+
+ if (alivePlayers.length === 1) {
+ this.endGame(alivePlayers[0].id, GameEndReason.LAST_STANDING);
+ } else if (alivePlayers.length === 0) {
+ this.endGame('', GameEndReason.DRAW);
+ }
+ }
+
+ /**
+ * End the game
+ */
+ private endGame(winnerId: string, reason: string): void {
+ this.state.phase = GamePhase.ENDED;
+ this.state.winnerId = winnerId;
+ this.state.endReason = reason;
+
+ this.stopGameLoop();
+
+ // Collect stats
+ const stats = Array.from(this.state.players.values()).map((p: PlayerState) => ({
+ playerId: p.id,
+ towersBuilt: p.towersBuilt,
+ monstersKilled: p.monstersKilled,
+ monstersSpawned: p.monstersSpawned,
+ }));
+
+ this.broadcast(ServerMessage.GAME_ENDED, {
+ winnerId,
+ reason,
+ stats,
+ });
+
+ console.log(`[GameRoom] Game ended. Winner: ${winnerId}, Reason: ${reason}`);
+ }
+
+ /**
+ * Resume game from pause
+ */
+ private resumeGame(): void {
+ if (this.state.phase !== GamePhase.PAUSED) return;
+
+ this.state.phase = GamePhase.PLAYING;
+ this.broadcast(ServerMessage.GAME_RESUMED, {});
+ }
+
+ // ==================== Message Handlers ====================
+
+ /**
+ * Handle player ready
+ */
+ private handlePlayerReady(client: Client): void {
+ const player = this.state.getPlayer(client.sessionId);
+ if (!player) return;
+
+ player.isReady = true;
+
+ // Check if all players are ready to start
+ if (this.state.canStart()) {
+ this.startCountdown();
+ }
+ }
+
+ /**
+ * Handle player not ready
+ */
+ private handlePlayerNotReady(client: Client): void {
+ const player = this.state.getPlayer(client.sessionId);
+ if (!player) return;
+
+ player.isReady = false;
+ }
+
+ /**
+ * Start game countdown
+ */
+ private startCountdown(): void {
+ if (this.state.phase !== GamePhase.WAITING) return;
+
+ this.state.phase = GamePhase.STARTING;
+ this.state.countdownTicks = 3 * this.tickRate; // 3 second countdown
+
+ this.broadcast(ServerMessage.GAME_STARTING, {
+ countdownSeconds: 3,
+ });
+
+ // Countdown timer
+ const countdownInterval = this.clock.setInterval(() => {
+ this.state.countdownTicks -= this.tickRate;
+
+ if (this.state.countdownTicks <= 0) {
+ countdownInterval.clear();
+ this.startGame();
+ }
+ }, 1000);
+ }
+
+ /**
+ * Start the actual game
+ */
+ private startGame(): void {
+ this.state.phase = GamePhase.PLAYING;
+ this.state.startTime = Date.now();
+ this.state.currentTick = 0;
+ this.state.wave.nextWaveTime = 5 * this.tickRate; // First wave in 5 seconds
+
+ // Generate mines from base positions
+ const basePositions = this.state.getAlivePlayers().map((p: PlayerState) => ({
+ x: p.basePosition.x,
+ y: p.basePosition.y,
+ }));
+ this.mineManager.generateMines(basePositions);
+
+ this.broadcast(ServerMessage.GAME_STARTED, {});
+
+ this.startGameLoop();
+
+ console.log(`[GameRoom] Game started with ${this.state.getPlayerCount()} players`);
+ }
+
+ /**
+ * Handle build tower
+ */
+ private handleBuildTower(client: Client, payload: BuildTowerPayload): void {
+ // Ensure territory is up-to-date
+ this.territoryCalc.recalculate(this.state.buildings, this.state.towers, this.state.mines);
+
+ // Validate with InputValidator
+ const result = this.inputValidator.validateBuildTower(
+ client.sessionId,
+ payload.towerType,
+ payload.x,
+ payload.y
+ );
+
+ if (!result.valid) {
+ this.sendActionRejected(client, 'BUILD_TOWER', result.errorMessage || 'Validation failed', result.errorCode);
+ return;
+ }
+
+ const player = this.state.getPlayer(client.sessionId)!;
+ const cost = result.data?.cost as number;
+
+ // Deduct money
+ player.money -= cost;
+
+ // Create tower
+ const tower = new TowerState();
+ tower.id = this.state.generateEntityId('tower');
+ tower.ownerId = client.sessionId;
+ tower.towerType = payload.towerType;
+ tower.setPosition(payload.x, payload.y);
+
+ // Apply real combat stats from metadata
+ const meta = getTowerCombatData(payload.towerType);
+ if (meta) {
+ tower._baseMaxHp = meta.hp;
+ tower._baseAttackRadius = meta.attackRadius;
+ tower.hp = meta.hp;
+ tower.maxHp = meta.hp;
+ tower.radius = meta.radius;
+ tower.attackRadius = meta.attackRadius;
+ tower.attackClock = meta.attackClock;
+ tower.attackBulletCount = meta.bulletCount;
+ } else {
+ // Fallback for non-bullet towers (Laser/Hammer/Boomerang/Hell/Ray/ManualCannon)
+ const baseMeta = getTowerBaseMeta(payload.towerType);
+ if (baseMeta) {
+ tower._baseMaxHp = baseMeta.hp;
+ tower._baseAttackRadius = baseMeta.attackRadius;
+ tower.hp = baseMeta.hp;
+ tower.maxHp = baseMeta.hp;
+ tower.radius = baseMeta.radius;
+ tower.attackRadius = baseMeta.attackRadius;
+ if (baseMeta.isManual) {
+ tower.isManual = true;
+ tower.maxAmmo = baseMeta.maxAmmo!;
+ tower.currentAmmo = baseMeta.maxAmmo!; // start fully loaded
+ }
+ } else {
+ console.warn(`[GameRoom] No combat/base meta found for tower type: ${payload.towerType}`);
+ }
+ }
+
+ this.state.towers.set(tower.id, tower);
+ player.towersBuilt++;
+
+ // Mark territory, energy, and vision dirty for recalculation
+ this.territoryCalc.markDirty();
+ this.energyCalc.markDirty();
+ this.visionSystem.markDirty();
+
+ // Apply territory penalties
+ this.applyTerritoryPenalties();
+
+ console.log(
+ `[GameRoom] Tower built: ${payload.towerType} at (${payload.x}, ${payload.y}) by ${player.name} (cost: ${cost})`
+ );
+ }
+
+ private handleBuildBuilding(client: Client, payload: BuildBuildingPayload): void {
+ this.territoryCalc.recalculate(this.state.buildings, this.state.towers, this.state.mines);
+
+ const result = this.inputValidator.validateBuildBuilding(
+ client.sessionId,
+ payload.buildingType,
+ payload.x,
+ payload.y
+ );
+
+ if (!result.valid) {
+ this.sendActionRejected(client, 'BUILD_BUILDING', result.errorMessage || 'Validation failed', result.errorCode);
+ return;
+ }
+
+ const player = this.state.getPlayer(client.sessionId)!;
+ const cost = result.data?.cost as number;
+
+ player.money -= cost;
+
+ const building = new BuildingState();
+ building.id = this.state.generateEntityId('building');
+ building.ownerId = client.sessionId;
+ building.buildingType = payload.buildingType;
+ building.setPosition(payload.x, payload.y);
+
+ const meta = getBuildingMeta(payload.buildingType);
+ if (meta) {
+ building.hp = meta.hp;
+ building.maxHp = meta.hp;
+ building.radius = meta.radius;
+ }
+
+ this.state.buildings.set(building.id, building);
+
+ this.territoryCalc.markDirty();
+ this.energyCalc.markDirty();
+ this.visionSystem.markDirty();
+
+ this.applyTerritoryPenalties();
+
+ console.log(
+ `[GameRoom] Building built: ${payload.buildingType} at (${payload.x}, ${payload.y}) by ${player.name} (cost: ${cost})`
+ );
+ }
+
+ /**
+ * Handle upgrade tower
+ */
+ private handleUpgradeTower(client: Client, payload: UpgradeTowerPayload): void {
+ // Validate targetType is provided
+ if (!payload.targetType) {
+ this.sendActionRejected(client, 'UPGRADE_TOWER', 'Missing target type', 'MISSING_TARGET_TYPE');
+ return;
+ }
+
+ // Full validation using InputValidator
+ const result = this.inputValidator.validateUpgradeTower(
+ client.sessionId,
+ payload.towerId,
+ payload.targetType
+ );
+
+ if (!result.valid) {
+ this.sendActionRejected(client, 'UPGRADE_TOWER', result.errorMessage || 'Validation failed', result.errorCode);
+ return;
+ }
+
+ // Get tower and player (validated above)
+ const tower = this.state.towers.get(payload.towerId)!;
+ const player = this.state.getPlayer(client.sessionId)!;
+ const upgradeCost = result.data?.cost as number;
+
+ // Deduct money
+ player.money -= upgradeCost;
+
+ // Preserve vision properties across tower type change
+ const savedVisionType = tower.visionType;
+ const savedVisionLevel = tower.visionLevel;
+ const wasInValidTerritory = tower.inValidTerritory;
+
+ // Upgrade tower: change type and increment level
+ tower.towerType = payload.targetType;
+ tower.level++;
+
+ // Update tower stats based on new type
+ const targetCombatMeta = getTowerCombatData(payload.targetType);
+ if (targetCombatMeta) {
+ tower._baseMaxHp = targetCombatMeta.hp;
+ tower._baseAttackRadius = targetCombatMeta.attackRadius;
+ tower.hp = targetCombatMeta.hp;
+ tower.maxHp = targetCombatMeta.hp;
+ tower.radius = targetCombatMeta.radius;
+ tower.attackRadius = targetCombatMeta.attackRadius;
+ tower.attackClock = targetCombatMeta.attackClock;
+ tower.attackBulletCount = targetCombatMeta.bulletCount;
+ } else {
+ // Fallback for non-bullet towers (Laser/Hammer/Boomerang/Hell/Ray/ManualCannon)
+ const baseMeta = getTowerBaseMeta(payload.targetType);
+ if (baseMeta) {
+ tower._baseMaxHp = baseMeta.hp;
+ tower._baseAttackRadius = baseMeta.attackRadius;
+ tower.hp = baseMeta.hp;
+ tower.maxHp = baseMeta.hp;
+ tower.radius = baseMeta.radius;
+ tower.attackRadius = baseMeta.attackRadius;
+ if (baseMeta.isManual) {
+ tower.isManual = true;
+ tower.maxAmmo = baseMeta.maxAmmo!;
+ tower.currentAmmo = baseMeta.maxAmmo!;
+ }
+ }
+ }
+
+ // Reapply territory penalty if needed
+ if (!wasInValidTerritory) {
+ this.applyTowerPenalty(tower);
+ }
+
+ // Notify combat system of upgrade
+ this.combatSystem.onTowerUpgraded(tower.id, payload.targetType);
+
+ // Restore vision properties after type change
+ tower.visionType = savedVisionType;
+ tower.visionLevel = savedVisionLevel;
+
+ // Mark energy dirty (level change affects consumption)
+ this.energyCalc.markDirty();
+
+ console.log(
+ `[GameRoom] Tower upgraded: ${tower.id} -> ${payload.targetType} by ${player.name} (cost: ${upgradeCost})`
+ );
+ }
+
+ /**
+ * Handle sell tower
+ */
+ private handleSellTower(client: Client, payload: SellTowerPayload): void {
+ const result = this.inputValidator.validateSellTower(
+ client.sessionId,
+ payload.towerId
+ );
+
+ if (!result.valid) {
+ this.sendActionRejected(client, 'SELL_TOWER', result.errorMessage || 'Validation failed', result.errorCode);
+ return;
+ }
+
+ const player = this.state.getPlayer(client.sessionId)!;
+ const refund = result.data?.refund as number;
+
+ // Refund money
+ player.money += refund;
+
+ // Notify combat system before removal
+ this.combatSystem.onTowerRemoved(payload.towerId);
+
+ // Remove tower
+ this.state.towers.delete(payload.towerId);
+
+ // Mark territory, energy, and vision dirty
+ this.territoryCalc.markDirty();
+ this.energyCalc.markDirty();
+ this.visionSystem.markDirty();
+
+ console.log(`[GameRoom] Tower sold: ${payload.towerId} by ${player.name} (refund: ${refund})`);
+ }
+
+ /**
+ * Handle spawn monster
+ */
+ private handleSpawnMonster(client: Client, payload: SpawnMonsterPayload): void {
+ // Ensure territory is up-to-date
+ this.territoryCalc.recalculate(this.state.buildings, this.state.towers, this.state.mines);
+
+ const result = this.inputValidator.validateSpawnMonster(
+ client.sessionId,
+ payload.spawnerId,
+ payload.monsterType,
+ payload.targetPlayerId
+ );
+
+ if (!result.valid) {
+ this.sendActionRejected(client, 'SPAWN_MONSTER', result.errorMessage || 'Validation failed', result.errorCode);
+ return;
+ }
+
+ const player = this.state.getPlayer(client.sessionId)!;
+ const spawner = this.state.buildings.get(payload.spawnerId)!;
+ const targetPlayer = this.state.getPlayer(payload.targetPlayerId)!;
+ const cost = result.data?.cost as number;
+ const cooldownTicks = result.data?.cooldownTicks as number;
+
+ // Deduct money
+ player.money -= cost;
+
+ // Create monster
+ const monster = new MonsterState();
+ monster.id = this.state.generateEntityId('monster');
+ monster.ownerId = client.sessionId;
+ monster.targetPlayerId = payload.targetPlayerId;
+ monster.monsterType = payload.monsterType;
+
+ // Use real stats from metadata
+ const meta = SPAWNABLE_MONSTER_META[payload.monsterType];
+ monster.hp = meta?.baseHp ?? 100;
+ monster.maxHp = monster.hp;
+ monster.radius = meta?.radius ?? 10;
+ monster.speed = meta?.speed ?? 2;
+
+ // Spawn at target's territory edge
+ const spawnPos = this.getEdgeSpawnPosition(targetPlayer);
+ monster.setPosition(spawnPos.x, spawnPos.y);
+
+ // Set velocity toward target base
+ const dx = targetPlayer.basePosition.x - spawnPos.x;
+ const dy = targetPlayer.basePosition.y - spawnPos.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ if (dist > 0) {
+ monster.setVelocity((dx / dist) * monster.speed, (dy / dist) * monster.speed);
+ }
+
+ this.state.monsters.set(monster.id, monster);
+ player.monstersSpawned++;
+
+ // Set cooldown using validated config
+ spawner.setCooldown(payload.monsterType, cooldownTicks, cooldownTicks);
+
+ console.log(
+ `[GameRoom] Monster spawned: ${payload.monsterType} by ${player.name} targeting ${targetPlayer.name} (cost: ${cost})`
+ );
+ }
+
+ /**
+ * Handle cannon fire
+ */
+ private handleCannonFire(client: Client, payload: CannonFirePayload): void {
+ const result = this.inputValidator.validateCannonFire(
+ client.sessionId,
+ payload.towerId,
+ payload.targetX,
+ payload.targetY
+ );
+
+ if (!result.valid) {
+ this.sendActionRejected(client, 'CANNON_FIRE', result.errorMessage || 'Validation failed', result.errorCode);
+ return;
+ }
+
+ const tower = this.state.towers.get(payload.towerId)!;
+ tower.currentAmmo--;
+
+ // Create projectile via combat system
+ const bulletEvent = this.combatSystem.fireManualCannon(tower, payload.targetX, payload.targetY);
+ if (bulletEvent) {
+ sendToVisible(this, this.visionSystem, ServerMessage.BULLET_FIRED, bulletEvent, bulletEvent.x, bulletEvent.y);
+ }
+
+ console.log(`[GameRoom] Cannon fired at (${payload.targetX}, ${payload.targetY})`);
+ }
+
+ /**
+ * Handle cannon set auto target
+ */
+ private handleCannonSetAutoTarget(client: Client, payload: CannonSetAutoTargetPayload): void {
+ const tower = this.state.towers.get(payload.towerId);
+ if (!tower || tower.ownerId !== client.sessionId || !tower.isManual) {
+ this.sendError(client, 'INVALID_CANNON', 'Cannot set target: invalid cannon');
+ return;
+ }
+
+ // Clear auto-target if requested
+ if (payload.clear) {
+ tower.hasAutoTarget = false;
+ tower.autoTargetX = 0;
+ tower.autoTargetY = 0;
+ tower.autoTargetRadius = 0;
+ return;
+ }
+
+ // Store auto-target settings
+ tower.autoTargetX = payload.targetX;
+ tower.autoTargetY = payload.targetY;
+ tower.autoTargetRadius = payload.radius;
+ tower.hasAutoTarget = true;
+
+ console.log(
+ `[GameRoom] Cannon auto-target set at (${payload.targetX}, ${payload.targetY}) radius ${payload.radius}`
+ );
+ }
+
+ /**
+ * Handle surrender
+ */
+ private handleSurrender(client: Client): void {
+ const player = this.state.getPlayer(client.sessionId);
+ if (!player || !player.isAlive) return;
+
+ console.log(`[GameRoom] Player surrendered: ${player.name}`);
+ this.handlePlayerElimination(client.sessionId, 'surrender');
+ }
+
+ /**
+ * Send error to client
+ */
+ private sendError(client: Client, code: string, message: string): void {
+ client.send(ServerMessage.ERROR, { code, message });
+ }
+
+ /**
+ * Send action rejected to client
+ */
+ private sendActionRejected(client: Client, action: string, reason: string, errorCode?: string): void {
+ client.send(ServerMessage.ACTION_REJECTED, { action, reason, errorCode });
+ }
+}
diff --git a/server/src/rooms/LobbyRoom.ts b/server/src/rooms/LobbyRoom.ts
new file mode 100644
index 0000000..f62fec9
--- /dev/null
+++ b/server/src/rooms/LobbyRoom.ts
@@ -0,0 +1,388 @@
+/**
+ * Lobby Room - Matchmaking and room listing
+ * Players join here to find games or create new rooms
+ */
+import { Room, Client, matchMaker } from '@colyseus/core';
+import { LobbyState, LobbyPlayer, RoomListing } from '../schema/LobbyState.js';
+import type { MapSize } from '../config.js';
+import { LobbyMessage } from '../shared/types/messages.js';
+
+/**
+ * Create room options
+ */
+interface CreateRoomOptions {
+ roomName?: string;
+ mapSize?: MapSize;
+ isPrivate?: boolean;
+ password?: string;
+}
+
+/**
+ * Join room options
+ */
+interface JoinRoomOptions {
+ roomId: string;
+ password?: string;
+}
+
+/**
+ * Lobby room options
+ */
+interface LobbyOptions {
+ playerName?: string;
+}
+
+export class LobbyRoom extends Room {
+ // State type declaration
+ declare state: LobbyState;
+ // Quick match queue
+ private matchQueue: Client[] = [];
+ private matchCheckInterval: ReturnType | null = null;
+
+ /**
+ * Room creation
+ */
+ onCreate(): void {
+ console.log('[LobbyRoom] Lobby created');
+
+ this.setState(new LobbyState());
+
+ // No max clients for lobby
+ this.maxClients = 1000;
+
+ // Auto dispose when empty
+ this.autoDispose = false;
+
+ // Register message handlers
+ this.registerMessageHandlers();
+
+ // Start periodic room list refresh
+ this.startRoomListRefresh();
+
+ // Start match queue processor
+ this.startMatchQueueProcessor();
+ }
+
+ /**
+ * Register message handlers
+ */
+ private registerMessageHandlers(): void {
+ this.onMessage(LobbyMessage.CREATE_ROOM, async (client, options: CreateRoomOptions) => {
+ await this.handleCreateRoom(client, options);
+ });
+
+ this.onMessage(LobbyMessage.JOIN_ROOM, async (client, options: JoinRoomOptions) => {
+ await this.handleJoinRoom(client, options);
+ });
+
+ this.onMessage(LobbyMessage.QUICK_MATCH, (client) => {
+ this.handleQuickMatch(client);
+ });
+
+ this.onMessage(LobbyMessage.CANCEL_SEARCH, (client) => {
+ this.handleCancelSearch(client);
+ });
+
+ this.onMessage(LobbyMessage.REFRESH_ROOMS, async (client) => {
+ await this.refreshRoomList();
+ client.send(LobbyMessage.ROOM_LIST_UPDATED, {
+ rooms: this.state.availableRooms.toArray(),
+ });
+ });
+ }
+
+ /**
+ * Start periodic room list refresh
+ */
+ private startRoomListRefresh(): void {
+ // Refresh every 5 seconds
+ this.clock.setInterval(async () => {
+ await this.refreshRoomList();
+ }, 5000);
+ }
+
+ /**
+ * Start match queue processor
+ */
+ private startMatchQueueProcessor(): void {
+ // Check queue every second
+ this.matchCheckInterval = setInterval(() => {
+ this.processMatchQueue();
+ }, 1000);
+ }
+
+ /**
+ * Process match queue
+ */
+ private processMatchQueue(): void {
+ // Need at least 2 players to make a match
+ while (this.matchQueue.length >= 2) {
+ const player1 = this.matchQueue.shift()!;
+ const player2 = this.matchQueue.shift()!;
+
+ // Verify both clients are still connected
+ if (!this.isClientConnected(player1) || !this.isClientConnected(player2)) {
+ // Put back connected players
+ if (this.isClientConnected(player1)) this.matchQueue.unshift(player1);
+ if (this.isClientConnected(player2)) this.matchQueue.unshift(player2);
+ continue;
+ }
+
+ // Create a match
+ this.createMatchedGame(player1, player2);
+ }
+ }
+
+ /**
+ * Check if client is still connected
+ */
+ private isClientConnected(client: Client): boolean {
+ return this.clients.includes(client);
+ }
+
+ /**
+ * Create a matched game
+ */
+ private async createMatchedGame(player1: Client, player2: Client): Promise {
+ try {
+ const lobbyPlayer1 = this.state.players.get(player1.sessionId);
+ const lobbyPlayer2 = this.state.players.get(player2.sessionId);
+
+ // Create game room
+ const room = await matchMaker.createRoom('game', {
+ mapSize: 'medium',
+ });
+
+ // Get reservation for both players
+ const reservation1 = await matchMaker.reserveSeatFor(room, {
+ playerName: lobbyPlayer1?.name || 'Player 1',
+ });
+
+ const reservation2 = await matchMaker.reserveSeatFor(room, {
+ playerName: lobbyPlayer2?.name || 'Player 2',
+ });
+
+ // Send match info to both players
+ player1.send(LobbyMessage.MATCH_FOUND, {
+ roomId: room.roomId,
+ reservation: reservation1,
+ });
+
+ player2.send(LobbyMessage.MATCH_FOUND, {
+ roomId: room.roomId,
+ reservation: reservation2,
+ });
+
+ // Update player search status
+ if (lobbyPlayer1) lobbyPlayer1.isSearching = false;
+ if (lobbyPlayer2) lobbyPlayer2.isSearching = false;
+
+ console.log(
+ `[LobbyRoom] Match created: ${lobbyPlayer1?.name} vs ${lobbyPlayer2?.name} in room ${room.roomId}`
+ );
+ } catch (error) {
+ console.error('[LobbyRoom] Failed to create match:', error);
+
+ // Put players back in queue
+ this.matchQueue.unshift(player1);
+ this.matchQueue.unshift(player2);
+ }
+ }
+
+ /**
+ * Refresh available room list
+ */
+ private async refreshRoomList(): Promise {
+ try {
+ const rooms = await matchMaker.query({ name: 'game' });
+
+ // Clear current list
+ this.state.availableRooms.clear();
+
+ let gamesInProgress = 0;
+
+ for (const room of rooms) {
+ // Only show rooms that are waiting for players
+ if (!room.locked && room.clients < room.maxClients) {
+ const listing = new RoomListing();
+ listing.roomId = room.roomId;
+ listing.roomName = room.metadata?.roomName || `Game ${room.roomId.slice(0, 6)}`;
+ listing.hostName = room.metadata?.hostName || 'Unknown';
+ listing.mapSize = room.metadata?.mapSize || 'medium';
+ listing.playerCount = room.clients;
+ listing.maxPlayers = room.maxClients;
+ listing.isPrivate = room.metadata?.isPrivate || false;
+ listing.isPlaying = room.metadata?.isPlaying || false;
+
+ this.state.availableRooms.push(listing);
+ }
+
+ if (room.metadata?.isPlaying) {
+ gamesInProgress++;
+ }
+ }
+
+ this.state.gamesInProgress = gamesInProgress;
+ } catch (error) {
+ console.error('[LobbyRoom] Failed to refresh room list:', error);
+ }
+ }
+
+ /**
+ * Player joins lobby
+ */
+ onJoin(client: Client, options: LobbyOptions): void {
+ const playerName = options.playerName || `Player ${this.state.onlineCount + 1}`;
+
+ console.log(`[LobbyRoom] Player joined lobby: ${playerName} (${client.sessionId})`);
+
+ const player = new LobbyPlayer(client.sessionId, playerName);
+ this.state.players.set(client.sessionId, player);
+ this.state.onlineCount = this.state.players.size;
+ }
+
+ /**
+ * Player leaves lobby
+ */
+ onLeave(client: Client): void {
+ const player = this.state.players.get(client.sessionId);
+ console.log(`[LobbyRoom] Player left lobby: ${player?.name || client.sessionId}`);
+
+ // Remove from match queue
+ const queueIndex = this.matchQueue.indexOf(client);
+ if (queueIndex !== -1) {
+ this.matchQueue.splice(queueIndex, 1);
+ }
+
+ this.state.players.delete(client.sessionId);
+ this.state.onlineCount = this.state.players.size;
+ }
+
+ /**
+ * Room disposal
+ */
+ onDispose(): void {
+ console.log('[LobbyRoom] Lobby disposed');
+
+ if (this.matchCheckInterval) {
+ clearInterval(this.matchCheckInterval);
+ }
+ }
+
+ // ==================== Message Handlers ====================
+
+ /**
+ * Handle create room
+ */
+ private async handleCreateRoom(client: Client, options: CreateRoomOptions): Promise {
+ try {
+ const player = this.state.players.get(client.sessionId);
+
+ const room = await matchMaker.createRoom('game', {
+ mapSize: options.mapSize || 'medium',
+ isPrivate: options.isPrivate || false,
+ });
+
+ // Get reservation for the creator
+ const reservation = await matchMaker.reserveSeatFor(room, {
+ playerName: player?.name || 'Host',
+ });
+
+ // Update room metadata
+ await matchMaker.remoteRoomCall(room.roomId, 'setMetadata', [
+ {
+ roomName: options.roomName || `${player?.name}'s Game`,
+ hostName: player?.name || 'Unknown',
+ mapSize: options.mapSize || 'medium',
+ isPrivate: options.isPrivate || false,
+ isPlaying: false,
+ },
+ ]);
+
+ client.send(LobbyMessage.ROOM_CREATED, {
+ roomId: room.roomId,
+ reservation,
+ });
+
+ console.log(`[LobbyRoom] Room created: ${room.roomId} by ${player?.name}`);
+ } catch (error) {
+ console.error('[LobbyRoom] Failed to create room:', error);
+ client.send(LobbyMessage.ERROR, {
+ code: 'CREATE_FAILED',
+ message: 'Failed to create room',
+ });
+ }
+ }
+
+ /**
+ * Handle join room
+ */
+ private async handleJoinRoom(client: Client, options: JoinRoomOptions): Promise {
+ try {
+ const player = this.state.players.get(client.sessionId);
+
+ // Query the room first to get its reference
+ const rooms = await matchMaker.query({ roomId: options.roomId });
+ if (rooms.length === 0) {
+ throw new Error('Room not found');
+ }
+
+ const room = rooms[0];
+ if (room.clients >= room.maxClients) {
+ throw new Error('Room is full');
+ }
+
+ // Get a proper reservation (not joinById which returns Room directly)
+ const reservation = await matchMaker.reserveSeatFor(room, {
+ playerName: player?.name || 'Player',
+ });
+
+ client.send(LobbyMessage.MATCH_FOUND, {
+ roomId: options.roomId,
+ reservation,
+ });
+
+ console.log(`[LobbyRoom] Player ${player?.name} joining room ${options.roomId}`);
+ } catch (error) {
+ console.error('[LobbyRoom] Failed to join room:', error);
+ client.send(LobbyMessage.ERROR, {
+ code: 'JOIN_FAILED',
+ message: 'Failed to join room. It may be full or no longer available.',
+ });
+ }
+ }
+
+ /**
+ * Handle quick match
+ */
+ private handleQuickMatch(client: Client): void {
+ const player = this.state.players.get(client.sessionId);
+ if (!player) return;
+
+ // Check if already in queue
+ if (this.matchQueue.includes(client)) {
+ return;
+ }
+
+ player.isSearching = true;
+ this.matchQueue.push(client);
+
+ console.log(`[LobbyRoom] Player ${player.name} joined match queue (queue size: ${this.matchQueue.length})`);
+ }
+
+ /**
+ * Handle cancel search
+ */
+ private handleCancelSearch(client: Client): void {
+ const player = this.state.players.get(client.sessionId);
+ if (!player) return;
+
+ const index = this.matchQueue.indexOf(client);
+ if (index !== -1) {
+ this.matchQueue.splice(index, 1);
+ player.isSearching = false;
+
+ console.log(`[LobbyRoom] Player ${player.name} left match queue`);
+ }
+ }
+}
diff --git a/server/src/rooms/index.ts b/server/src/rooms/index.ts
new file mode 100644
index 0000000..7fbc383
--- /dev/null
+++ b/server/src/rooms/index.ts
@@ -0,0 +1,6 @@
+/**
+ * Rooms Module
+ * Export all room implementations
+ */
+export { GameRoom } from './GameRoom.js';
+export { LobbyRoom } from './LobbyRoom.js';
diff --git a/server/src/schema/BuildingState.ts b/server/src/schema/BuildingState.ts
new file mode 100644
index 0000000..daa8218
--- /dev/null
+++ b/server/src/schema/BuildingState.ts
@@ -0,0 +1,68 @@
+/**
+ * Building State Schema
+ * Represents a building (base, spawner, etc.) in the multiplayer game
+ */
+import { Schema, type, ArraySchema } from '@colyseus/schema';
+import { VectorSchema } from './VectorSchema.js';
+
+/**
+ * Spawner cooldown for a specific monster type
+ */
+export class SpawnerCooldown extends Schema {
+ @type('string') monsterType: string = '';
+ remainingTicks: number = 0; // Server-side only, not synced
+ @type('number') totalTicks: number = 0;
+
+ constructor(monsterType: string = '', totalTicks: number = 0) {
+ super();
+ this.monsterType = monsterType;
+ this.totalTicks = totalTicks;
+ this.remainingTicks = 0;
+ }
+}
+
+export class BuildingState extends Schema {
+ @type('string') id: string = '';
+ @type('string') ownerId: string = ''; // Player ID
+ @type('string') buildingType: string = ''; // Building class name
+ @type(VectorSchema) position: VectorSchema = new VectorSchema();
+ @type('number') hp: number = 0;
+ @type('number') maxHp: number = 0;
+ @type('number') radius: number = 0;
+ @type('boolean') isBase: boolean = false;
+
+ // For monster spawner
+ @type('boolean') isSpawner: boolean = false;
+ @type([SpawnerCooldown]) cooldowns: ArraySchema =
+ new ArraySchema();
+
+ constructor() {
+ super();
+ this.position = new VectorSchema();
+ this.cooldowns = new ArraySchema();
+ }
+
+ setPosition(x: number, y: number): void {
+ this.position.set(x, y);
+ }
+
+ /**
+ * Get cooldown for a specific monster type
+ */
+ getCooldown(monsterType: string): SpawnerCooldown | undefined {
+ return this.cooldowns.find((c) => c.monsterType === monsterType);
+ }
+
+ /**
+ * Set cooldown for a specific monster type
+ */
+ setCooldown(monsterType: string, remainingTicks: number, totalTicks: number): void {
+ let cooldown = this.getCooldown(monsterType);
+ if (!cooldown) {
+ cooldown = new SpawnerCooldown(monsterType, totalTicks);
+ this.cooldowns.push(cooldown);
+ }
+ cooldown.remainingTicks = remainingTicks;
+ cooldown.totalTicks = totalTicks;
+ }
+}
diff --git a/server/src/schema/BulletState.ts b/server/src/schema/BulletState.ts
new file mode 100644
index 0000000..1725f78
--- /dev/null
+++ b/server/src/schema/BulletState.ts
@@ -0,0 +1,97 @@
+/**
+ * Bullet State Schema
+ * Represents a bullet/projectile in the multiplayer game
+ *
+ * NOTE: Per plan, bullets use event-driven sync (BULLET_FIRED, BULLET_HIT)
+ * rather than full state sync. This schema is for server-side tracking.
+ */
+import { Schema, type } from '@colyseus/schema';
+import { VectorSchema } from './VectorSchema.js';
+
+export class BulletState extends Schema {
+ @type('string') id: string = '';
+ @type('string') ownerId: string = ''; // Player ID who owns the tower that fired
+ @type('string') towerId: string = ''; // Tower that fired this bullet
+ @type('string') bulletType: string = 'Normal'; // Bullet class name
+
+ @type(VectorSchema) position: VectorSchema = new VectorSchema();
+ @type(VectorSchema) velocity: VectorSchema = new VectorSchema();
+
+ // Previous position for sweep collision
+ prevX: number = 0;
+ prevY: number = 0;
+
+ @type('number') radius: number = 5;
+ @type('number') damage: number = 0;
+ @type('number') speed: number = 0;
+
+ // Tracking bullet properties
+ @type('boolean') isTracking: boolean = false;
+ @type('string') targetId: string = '';
+ @type('number') trackingRadius: number = 0;
+
+ // Explosive bullet properties
+ @type('boolean') isExplosive: boolean = false;
+ @type('number') explosionRadius: number = 0;
+ @type('number') explosionDamage: number = 0;
+
+ // Penetrating bullet properties
+ @type('boolean') isPenetrating: boolean = false;
+ @type('number') penetrationCount: number = 0; // Remaining penetrations
+
+ // Freeze properties
+ @type('number') freezeMultiplier: number = 1; // 1 = no freeze, <1 = slow
+
+ // Burn properties
+ @type('number') burnRate: number = 0;
+
+ // Range tracking
+ originX: number = 0;
+ originY: number = 0;
+ maxRange: number = 0;
+ slideRate: number = 2;
+
+ // Split bullet properties
+ isSplit: boolean = false;
+ canSplit: boolean = false;
+ splitCount: number = 0;
+ splitRange: number = 0;
+
+ // Targeting tower instead of monster
+ targetsTowers: boolean = false;
+
+ constructor() {
+ super();
+ this.position = new VectorSchema();
+ this.velocity = new VectorSchema();
+ }
+
+ setPosition(x: number, y: number): void {
+ this.prevX = this.position.x;
+ this.prevY = this.position.y;
+ this.position.set(x, y);
+ }
+
+ setVelocity(vx: number, vy: number): void {
+ this.velocity.set(vx, vy);
+ }
+
+ setOrigin(x: number, y: number): void {
+ this.originX = x;
+ this.originY = y;
+ }
+
+ /**
+ * Check if bullet has exceeded its range
+ */
+ isOutOfRange(): boolean {
+ if (this.maxRange <= 0) return false;
+
+ const dx = this.position.x - this.originX;
+ const dy = this.position.y - this.originY;
+ const distSq = dx * dx + dy * dy;
+ const maxDistSq = (this.maxRange * this.slideRate) ** 2;
+
+ return distSq > maxDistSq;
+ }
+}
diff --git a/server/src/schema/GameState.ts b/server/src/schema/GameState.ts
new file mode 100644
index 0000000..b58dc24
--- /dev/null
+++ b/server/src/schema/GameState.ts
@@ -0,0 +1,173 @@
+/**
+ * Game State Schema
+ * Main state container for multiplayer game
+ */
+import { Schema, type, MapSchema, ArraySchema, filterChildren } from '@colyseus/schema';
+import { PlayerState } from './PlayerState.js';
+import { TowerState } from './TowerState.js';
+import { MonsterState } from './MonsterState.js';
+import { BuildingState } from './BuildingState.js';
+import { BulletState } from './BulletState.js';
+import { MineState } from './MineState.js';
+import type { VisionSystem } from '../systems/vision/visionSystem.js';
+
+/** Minimal client type matching Colyseus @filterChildren callback signature */
+interface FilterClient {
+ sessionId: string;
+}
+
+/**
+ * Game phase enum
+ */
+export const GamePhase = {
+ WAITING: 'waiting', // Waiting for players
+ STARTING: 'starting', // Countdown before game starts
+ PLAYING: 'playing', // Game in progress
+ PAUSED: 'paused', // Game paused (e.g., player disconnected)
+ ENDED: 'ended', // Game over
+} as const;
+
+export type GamePhaseType = (typeof GamePhase)[keyof typeof GamePhase];
+
+/**
+ * Game end reason
+ */
+export const GameEndReason = {
+ LAST_STANDING: 'last_standing', // Only one player alive
+ DRAW: 'draw', // All players eliminated
+ SURRENDER: 'surrender', // Player surrendered
+ DISCONNECT_TIMEOUT: 'disconnect_timeout', // Player didn't reconnect
+} as const;
+
+export type GameEndReasonType = (typeof GameEndReason)[keyof typeof GameEndReason];
+
+/**
+ * Vision filter callback for @filterChildren.
+ * Own entities are always visible; others checked via VisionSystem.
+ */
+function visionFilter(
+ this: GameState,
+ client: FilterClient,
+ _key: string,
+ value: { id: string; ownerId: string },
+): boolean {
+ if (value.ownerId === client.sessionId) return true;
+ return this._visionSystem?.isEntityVisible(client.sessionId, value.id, value.ownerId) ?? false;
+}
+
+/**
+ * Map configuration
+ */
+export class MapConfig extends Schema {
+ @type('string') size: string = 'medium'; // small, medium, large
+ @type('number') width: number = 6000;
+ @type('number') height: number = 4000;
+}
+
+/**
+ * Wave state
+ */
+export class WaveState extends Schema {
+ @type('number') currentWave: number = 0;
+ @type('number') monstersRemaining: number = 0;
+ @type('number') nextWaveTime: number = 0; // Ticks until next wave
+ @type('boolean') isWaveActive: boolean = false;
+}
+
+/**
+ * Main game state
+ */
+export class GameState extends Schema {
+ // Game metadata
+ @type('string') roomId: string = '';
+ @type('string') phase: string = GamePhase.WAITING;
+ @type('string') winnerId: string = ''; // Player ID of winner
+ @type('string') endReason: string = '';
+
+ // Timing
+ currentTick: number = 0; // Server-side only, not synced
+ @type('number') startTime: number = 0; // Timestamp when game started
+ @type('number') pauseTime: number = 0; // Timestamp when paused
+ @type('number') countdownTicks: number = 0; // Countdown before game starts
+
+ // Map configuration
+ @type(MapConfig) mapConfig: MapConfig = new MapConfig();
+
+ // Wave state
+ @type(WaveState) wave: WaveState = new WaveState();
+
+ // Entity collections (using MapSchema for efficient delta sync)
+ @type({ map: PlayerState }) players: MapSchema = new MapSchema();
+
+ @filterChildren(visionFilter)
+ @type({ map: TowerState }) towers: MapSchema = new MapSchema();
+
+ @filterChildren(visionFilter)
+ @type({ map: MonsterState }) monsters: MapSchema = new MapSchema();
+
+ @filterChildren(visionFilter)
+ @type({ map: BuildingState }) buildings: MapSchema = new MapSchema();
+
+ @filterChildren(visionFilter)
+ @type({ map: BulletState }) bullets: MapSchema = new MapSchema();
+
+ @filterChildren(visionFilter)
+ @type({ map: MineState }) mines: MapSchema = new MapSchema();
+
+ // Non-serialized: runtime vision system reference (injected by GameRoom)
+ _visionSystem: VisionSystem | null = null;
+
+ private _nextEntityId = 0;
+
+ // Disconnection handling
+ @type('string') disconnectedPlayerId: string = '';
+ @type('number') reconnectDeadline: number = 0; // Timestamp
+
+ constructor() {
+ super();
+ this.mapConfig = new MapConfig();
+ this.wave = new WaveState();
+ this.players = new MapSchema();
+ this.towers = new MapSchema();
+ this.monsters = new MapSchema();
+ this.buildings = new MapSchema();
+ this.bullets = new MapSchema();
+ this.mines = new MapSchema();
+ }
+
+ /**
+ * Get player by ID
+ */
+ getPlayer(playerId: string): PlayerState | undefined {
+ return this.players.get(playerId);
+ }
+
+ /**
+ * Get all alive players
+ */
+ getAlivePlayers(): PlayerState[] {
+ return Array.from(this.players.values()).filter((p) => p.isAlive);
+ }
+
+ /**
+ * Get player count
+ */
+ getPlayerCount(): number {
+ return this.players.size;
+ }
+
+ /**
+ * Check if game can start
+ */
+ canStart(): boolean {
+ const readyPlayers = Array.from(this.players.values()).filter((p) => p.isReady);
+ return readyPlayers.length >= 2;
+ }
+
+ /**
+ * Generate unique entity ID
+ */
+ generateEntityId(prefix: string): string {
+ return `${prefix}_${this._nextEntityId++}`;
+ }
+}
diff --git a/server/src/schema/LobbyState.ts b/server/src/schema/LobbyState.ts
new file mode 100644
index 0000000..d654796
--- /dev/null
+++ b/server/src/schema/LobbyState.ts
@@ -0,0 +1,58 @@
+/**
+ * Lobby State Schema
+ * State for the lobby room
+ */
+import { Schema, type, MapSchema, ArraySchema } from '@colyseus/schema';
+
+/**
+ * Available room listing
+ */
+export class RoomListing extends Schema {
+ @type('string') roomId: string = '';
+ @type('string') roomName: string = '';
+ @type('string') hostName: string = '';
+ @type('string') mapSize: string = 'medium';
+ @type('number') playerCount: number = 0;
+ @type('number') maxPlayers: number = 2;
+ @type('boolean') isPrivate: boolean = false;
+ @type('boolean') isPlaying: boolean = false;
+ @type('number') createdAt: number = 0;
+
+ constructor() {
+ super();
+ this.createdAt = Date.now();
+ }
+}
+
+/**
+ * Lobby player info
+ */
+export class LobbyPlayer extends Schema {
+ @type('string') id: string = '';
+ @type('string') name: string = '';
+ @type('boolean') isSearching: boolean = false;
+ @type('number') joinedAt: number = 0;
+
+ constructor(id: string = '', name: string = '') {
+ super();
+ this.id = id;
+ this.name = name;
+ this.joinedAt = Date.now();
+ }
+}
+
+/**
+ * Lobby state
+ */
+export class LobbyState extends Schema {
+ @type({ map: LobbyPlayer }) players: MapSchema = new MapSchema();
+ @type([RoomListing]) availableRooms: ArraySchema = new ArraySchema();
+ @type('number') onlineCount: number = 0;
+ @type('number') gamesInProgress: number = 0;
+
+ constructor() {
+ super();
+ this.players = new MapSchema();
+ this.availableRooms = new ArraySchema();
+ }
+}
diff --git a/server/src/schema/MineState.ts b/server/src/schema/MineState.ts
new file mode 100644
index 0000000..b690a19
--- /dev/null
+++ b/server/src/schema/MineState.ts
@@ -0,0 +1,29 @@
+/**
+ * Mine State Schema
+ * Represents a mine/power plant entity synced to clients
+ */
+import { Schema, type } from '@colyseus/schema';
+import { VectorSchema } from './VectorSchema.js';
+import { MineStateType } from '../../../shared/config/mineMeta.js';
+
+export class MineState extends Schema {
+ @type('string') id: string = '';
+ @type(VectorSchema) position: VectorSchema = new VectorSchema();
+ @type('string') mineState: string = MineStateType.NORMAL;
+ @type('string') ownerId: string = '';
+ @type('number') level: number = 0;
+ @type('number') hp: number = 0;
+ @type('number') maxHp: number = 0;
+ @type('number') radius: number = 15;
+ @type('boolean') repairing: boolean = false;
+ @type('number') repairProgress: number = 0;
+
+ constructor() {
+ super();
+ this.position = new VectorSchema();
+ }
+
+ setPosition(x: number, y: number): void {
+ this.position.set(x, y);
+ }
+}
diff --git a/server/src/schema/MonsterState.ts b/server/src/schema/MonsterState.ts
new file mode 100644
index 0000000..12ae569
--- /dev/null
+++ b/server/src/schema/MonsterState.ts
@@ -0,0 +1,49 @@
+/**
+ * Monster State Schema
+ * Represents a monster in the multiplayer game
+ */
+import { Schema, type } from '@colyseus/schema';
+import { VectorSchema } from './VectorSchema.js';
+
+export class MonsterState extends Schema {
+ @type('string') id: string = '';
+ @type('string') ownerId: string = ''; // Player ID who spawned it, empty for neutral
+ @type('string') targetPlayerId: string = ''; // Player being attacked
+ @type('string') monsterType: string = ''; // Monster class name
+ @type(VectorSchema) position: VectorSchema = new VectorSchema();
+ @type(VectorSchema) velocity: VectorSchema = new VectorSchema();
+ @type('number') hp: number = 0;
+ @type('number') maxHp: number = 0;
+ @type('number') radius: number = 0;
+ @type('number') speed: number = 0;
+
+ // Status effects
+ @type('boolean') isFrozen: boolean = false;
+ @type('boolean') isSlowed: boolean = false;
+ @type('number') freezeEndTime: number = 0;
+ @type('number') slowEndTime: number = 0;
+ @type('number') burnRate: number = 0; // HP% damage per tick from burning
+ @type('string') burnSourceOwnerId: string = ''; // Player who applied the burn
+
+ // Movement tracking (for sweep collision)
+ prevX: number = 0;
+ prevY: number = 0;
+
+ // AI properties
+ @type('string') movementType: string = 'normal'; // normal, swing, sudden, exciting
+ @type('boolean') dodgeAble: boolean = false;
+
+ constructor() {
+ super();
+ this.position = new VectorSchema();
+ this.velocity = new VectorSchema();
+ }
+
+ setPosition(x: number, y: number): void {
+ this.position.set(x, y);
+ }
+
+ setVelocity(vx: number, vy: number): void {
+ this.velocity.set(vx, vy);
+ }
+}
diff --git a/server/src/schema/PlayerState.ts b/server/src/schema/PlayerState.ts
new file mode 100644
index 0000000..ac5637f
--- /dev/null
+++ b/server/src/schema/PlayerState.ts
@@ -0,0 +1,41 @@
+/**
+ * Player State Schema
+ * Represents a player in the multiplayer game
+ */
+import { Schema, type } from '@colyseus/schema';
+import { VectorSchema } from './VectorSchema.js';
+
+export class PlayerState extends Schema {
+ @type('string') id: string = '';
+ @type('string') name: string = '';
+ @type('string') color: string = '#ffffff';
+ @type('number') money: number = 0;
+ @type('boolean') isAlive: boolean = true;
+ @type('boolean') isConnected: boolean = true;
+ @type('boolean') isReady: boolean = false;
+ @type('string') sessionId: string = ''; // For reconnection
+ @type(VectorSchema) basePosition: VectorSchema = new VectorSchema();
+
+ // Player index (0 for left, 1 for right)
+ @type('number') playerIndex: number = 0;
+
+ // Statistics
+ @type('number') towersBuilt: number = 0;
+ @type('number') monstersKilled: number = 0;
+ @type('number') monstersSpawned: number = 0;
+
+ // Energy system
+ @type('number') energyProduction: number = 6;
+ @type('number') energyConsumption: number = 0;
+ @type('number') energySatisfaction: number = 1;
+
+ constructor(id: string = '', name: string = '', playerIndex: number = 0) {
+ super();
+ this.id = id;
+ this.name = name;
+ this.playerIndex = playerIndex;
+ }
+}
+
+// Re-export from shared config (single source of truth)
+export { PLAYER_COLORS } from '../../../shared/config/playerMeta.js';
diff --git a/server/src/schema/TowerState.ts b/server/src/schema/TowerState.ts
new file mode 100644
index 0000000..44c2d03
--- /dev/null
+++ b/server/src/schema/TowerState.ts
@@ -0,0 +1,53 @@
+/**
+ * Tower State Schema
+ * Represents a tower in the multiplayer game
+ */
+import { Schema, type } from '@colyseus/schema';
+import { VectorSchema } from './VectorSchema.js';
+
+export class TowerState extends Schema {
+ @type('string') id: string = '';
+ @type('string') ownerId: string = ''; // Player ID, empty for neutral
+ @type('string') towerType: string = ''; // Tower class name
+ @type(VectorSchema) position: VectorSchema = new VectorSchema();
+ @type('number') hp: number = 0;
+ @type('number') maxHp: number = 0;
+ @type('number') level: number = 1;
+ @type('number') radius: number = 0;
+ @type('number') attackRadius: number = 0;
+
+ // Attack timing
+ @type('number') attackClock: number = 60; // Ticks between attacks
+ @type('number') attackBulletCount: number = 1; // Bullets per attack
+
+ // For manual cannon
+ @type('boolean') isManual: boolean = false;
+ @type('number') currentAmmo: number = 0;
+ @type('number') maxAmmo: number = 0;
+
+ // Auto-target settings for manual cannon
+ @type('number') autoTargetX: number = 0;
+ @type('number') autoTargetY: number = 0;
+ @type('number') autoTargetRadius: number = 0;
+ @type('boolean') hasAutoTarget: boolean = false;
+
+ // Vision system
+ @type('string') visionType: string = 'none'; // 'none' | 'observer' | 'radar'
+ @type('uint8') visionLevel: number = 0;
+
+ // Territory system
+ @type('boolean') inValidTerritory: boolean = true;
+
+ // Internal fields (not synced)
+ _baseMaxHp: number = 0;
+ _baseAttackRadius: number = 0;
+
+ constructor() {
+ super();
+ this.position = new VectorSchema();
+ }
+
+ setPosition(x: number, y: number): void {
+ this.position.set(x, y);
+ }
+}
diff --git a/server/src/schema/VectorSchema.ts b/server/src/schema/VectorSchema.ts
new file mode 100644
index 0000000..479afd7
--- /dev/null
+++ b/server/src/schema/VectorSchema.ts
@@ -0,0 +1,31 @@
+/**
+ * Vector Schema - 2D position/direction
+ */
+import { Schema, type } from '@colyseus/schema';
+
+export class VectorSchema extends Schema {
+ @type('float32') x: number = 0;
+ @type('float32') y: number = 0;
+
+ constructor(x: number = 0, y: number = 0) {
+ super();
+ this.x = x;
+ this.y = y;
+ }
+
+ set(x: number, y: number): void {
+ this.x = x;
+ this.y = y;
+ }
+
+ copyFrom(other: { x: number; y: number }): void {
+ this.x = other.x;
+ this.y = other.y;
+ }
+
+ distanceTo(other: { x: number; y: number }): number {
+ const dx = this.x - other.x;
+ const dy = this.y - other.y;
+ return Math.sqrt(dx * dx + dy * dy);
+ }
+}
diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts
new file mode 100644
index 0000000..b4cdbb6
--- /dev/null
+++ b/server/src/schema/index.ts
@@ -0,0 +1,22 @@
+/**
+ * Schema Module - Colyseus State Definitions
+ * Export all schema classes for state synchronization
+ */
+
+export { VectorSchema } from './VectorSchema.js';
+export { PlayerState, PLAYER_COLORS } from './PlayerState.js';
+export { TowerState } from './TowerState.js';
+export { MonsterState } from './MonsterState.js';
+export { BuildingState, SpawnerCooldown } from './BuildingState.js';
+export { BulletState } from './BulletState.js';
+export { MineState } from './MineState.js';
+export {
+ GameState,
+ MapConfig,
+ WaveState,
+ GamePhase,
+ GameEndReason,
+ type GamePhaseType,
+ type GameEndReasonType,
+} from './GameState.js';
+export { LobbyState, LobbyPlayer, RoomListing } from './LobbyState.js';
diff --git a/server/src/shared/constants/index.ts b/server/src/shared/constants/index.ts
new file mode 100644
index 0000000..4f5e358
--- /dev/null
+++ b/server/src/shared/constants/index.ts
@@ -0,0 +1,4 @@
+/**
+ * Shared Constants
+ */
+export * from './speedScale.js';
diff --git a/server/src/shared/constants/speedScale.ts b/server/src/shared/constants/speedScale.ts
new file mode 100644
index 0000000..7e9327f
--- /dev/null
+++ b/server/src/shared/constants/speedScale.ts
@@ -0,0 +1,6 @@
+// Re-export from top-level shared module
+export {
+ SPEED_SCALE_FACTOR,
+ scaleSpeed,
+ scalePeriod,
+} from '../../../../shared/constants/speedScale.js';
diff --git a/server/src/shared/index.ts b/server/src/shared/index.ts
new file mode 100644
index 0000000..c612109
--- /dev/null
+++ b/server/src/shared/index.ts
@@ -0,0 +1,6 @@
+/**
+ * Shared Utilities and Types
+ */
+export * from './math/index.js';
+export * from './constants/index.js';
+export * from './types/index.js';
diff --git a/server/src/shared/math/circle.ts b/server/src/shared/math/circle.ts
new file mode 100644
index 0000000..1b48e86
--- /dev/null
+++ b/server/src/shared/math/circle.ts
@@ -0,0 +1,7 @@
+// Re-export from top-level shared module
+export {
+ collides,
+ sweepCollides,
+ sweepCollidesRelative,
+ sweepCollisionTime,
+} from '../../../../shared/math/circleCollision.js';
diff --git a/server/src/shared/math/index.ts b/server/src/shared/math/index.ts
new file mode 100644
index 0000000..3b1fe09
--- /dev/null
+++ b/server/src/shared/math/index.ts
@@ -0,0 +1,5 @@
+/**
+ * Shared Math Utilities
+ */
+export * from './vector.js';
+export * from './circle.js';
diff --git a/server/src/shared/math/vector.ts b/server/src/shared/math/vector.ts
new file mode 100644
index 0000000..6dd40d3
--- /dev/null
+++ b/server/src/shared/math/vector.ts
@@ -0,0 +1,20 @@
+// Re-export from top-level shared module
+export type { Vec2 } from '../../../../shared/math/vector.js';
+export {
+ add,
+ sub,
+ mul,
+ distSq,
+ dist,
+ magSq,
+ mag,
+ normalize,
+ dot,
+ rotate,
+ rotate90,
+ toAngle,
+ fromAngle,
+ lerp,
+ randUnit,
+ zero,
+} from '../../../../shared/math/vector.js';
diff --git a/server/src/shared/types/index.ts b/server/src/shared/types/index.ts
new file mode 100644
index 0000000..7813bb2
--- /dev/null
+++ b/server/src/shared/types/index.ts
@@ -0,0 +1,5 @@
+/**
+ * Shared Types
+ */
+export * from './ownership.js';
+export * from './messages.js';
diff --git a/server/src/shared/types/messages.ts b/server/src/shared/types/messages.ts
new file mode 100644
index 0000000..a876fd0
--- /dev/null
+++ b/server/src/shared/types/messages.ts
@@ -0,0 +1,39 @@
+// Re-export from top-level shared module
+export {
+ ClientMessage,
+ ServerMessage,
+ LobbyMessage,
+ type ClientMessageType,
+ type ServerMessageType,
+ type LobbyMessageType,
+ type BuildTowerPayload,
+ type BuildBuildingPayload,
+ type UpgradeTowerPayload,
+ type SellTowerPayload,
+ type UpgradeVisionPayload,
+ type SpawnMonsterPayload,
+ type CannonAimPayload,
+ type CannonFirePayload,
+ type CannonSetAutoTargetPayload,
+ type ChatMessagePayload,
+ type GameEndedPayload,
+ type WaveStartingPayload,
+ type MonsterDamagedPayload,
+ type MonsterKilledPayload,
+ type BuildingDamagedPayload,
+ type BuildingDestroyedPayload,
+ type PlayerEliminatedPayload,
+ type ErrorPayload,
+ type ActionRejectedPayload,
+ type BulletFiredPayload,
+ type BulletHitPayload,
+ type BulletExplosionPayload,
+ type RoomInfo,
+ type MatchFoundPayload,
+ type UpgradeMinePayload,
+ type RepairMinePayload,
+ type DowngradeMinePayload,
+ type SellMinePayload,
+ type MineDestroyedPayload,
+ type TerritorySyncPayload,
+} from '../../../../shared/types/messages.js';
diff --git a/server/src/shared/types/ownership.ts b/server/src/shared/types/ownership.ts
new file mode 100644
index 0000000..60fc863
--- /dev/null
+++ b/server/src/shared/types/ownership.ts
@@ -0,0 +1,10 @@
+// Re-export from top-level shared module
+export type { OwnedEntity } from '../../../../shared/types/ownership.js';
+export {
+ isEnemy,
+ isFriendly,
+ belongsTo,
+ isNeutral,
+ filterEnemies,
+ filterFriendlies,
+} from '../../../../shared/types/ownership.js';
diff --git a/server/src/shared/validation/index.ts b/server/src/shared/validation/index.ts
new file mode 100644
index 0000000..f4e7100
--- /dev/null
+++ b/server/src/shared/validation/index.ts
@@ -0,0 +1,26 @@
+// Re-export from top-level shared validation module
+export {
+ ValidationErrorCode,
+ validationSuccess,
+ validationFailure,
+ type ValidationErrorCodeType,
+ type ValidationResult,
+ isPositionInBounds,
+ hasCollision,
+ checkBuildCollision,
+ MIN_BUILD_DISTANCE,
+ validateBuildTowerBasic,
+ validateUpgradeTower,
+ validateSellTower,
+ validateCannonFire,
+ type TowerMetaData,
+ type Position,
+ type CollidableEntity,
+ type PlayerValidationState,
+ type TowerValidationState,
+ type MapBounds,
+ validateSpawnMonster,
+ type SpawnableMonsterConfig,
+ type SpawnerValidationState,
+ type TargetPlayerState,
+} from '../../../../shared/validation/index.js';
diff --git a/server/src/systems/combat/bulletManager.ts b/server/src/systems/combat/bulletManager.ts
new file mode 100644
index 0000000..da5f10a
--- /dev/null
+++ b/server/src/systems/combat/bulletManager.ts
@@ -0,0 +1,412 @@
+/**
+ * Bullet Manager - Server-side bullet system
+ * Handles bullet movement, collision detection, and effects
+ *
+ * Port of src/bullets/bullet.ts for server use
+ */
+
+import { BulletState } from '../../schema/BulletState.js';
+import type { MonsterState } from '../../schema/MonsterState.js';
+import type { BuildingState } from '../../schema/BuildingState.js';
+import type { MapSchema } from '@colyseus/schema';
+import { sweepCollides, sweepCollidesRelative, collides } from '../../shared/math/circle.js';
+import { normalize, sub, distSq } from '../../shared/math/vector.js';
+import { isEnemy } from '../../shared/types/ownership.js';
+import type { SpatialHashGrid, SpatialEntity } from '../spatial/spatialHashGrid.js';
+import type { BulletCreationData } from './towerAttack.js';
+
+/**
+ * Result of bullet collision
+ */
+export interface BulletHitResult {
+ bulletId: string;
+ towerId: string;
+ ownerId: string;
+ targetId: string;
+ targetType: 'monster' | 'building';
+ damage: number;
+ position: { x: number; y: number };
+ // Effects to apply
+ freezeMultiplier: number;
+ burnRate: number;
+ // Explosion data (if explosive)
+ isExplosion: boolean;
+ explosionRadius: number;
+ explosionDamage: number;
+ explosionTargets: Array<{ id: string; damage: number }>;
+}
+
+/**
+ * Bullet fired event (for client sync)
+ */
+export interface BulletFiredEvent {
+ bulletId: string;
+ bulletType: string;
+ towerId: string;
+ ownerId: string;
+ x: number;
+ y: number;
+ vx: number;
+ vy: number;
+ radius: number;
+ maxRange: number;
+}
+
+/**
+ * Monster wrapper for spatial grid
+ */
+interface MonsterSpatialEntity extends SpatialEntity {
+ state: MonsterState;
+ prevX?: number;
+ prevY?: number;
+}
+
+/**
+ * Building wrapper for spatial grid
+ */
+interface BuildingSpatialEntity extends SpatialEntity {
+ state: BuildingState;
+}
+
+/**
+ * Bullet Manager
+ */
+export class BulletManager {
+ // Active bullets
+ private bullets: Map = new Map();
+ private nextBulletId: number = 0;
+
+ // Bullets to remove this tick
+ private toRemove: Set = new Set();
+
+ constructor() {}
+
+ /**
+ * Create a bullet from attack data
+ */
+ createBullet(data: BulletCreationData, towerId: string, ownerId: string): BulletState {
+ const bullet = new BulletState();
+ bullet.id = `bullet_${this.nextBulletId++}`;
+ bullet.ownerId = ownerId;
+ bullet.towerId = towerId;
+ bullet.bulletType = data.bulletType;
+
+ bullet.setPosition(data.x, data.y);
+ bullet.setOrigin(data.x, data.y);
+ bullet.setVelocity(data.vx, data.vy);
+ bullet.prevX = data.x;
+ bullet.prevY = data.y;
+
+ bullet.radius = data.radius;
+ bullet.damage = data.damage;
+ bullet.maxRange = data.maxRange;
+
+ // Tracking
+ bullet.isTracking = !!data.targetId;
+ bullet.targetId = data.targetId || '';
+
+ // Explosive
+ bullet.isExplosive = data.isExplosive;
+ bullet.explosionRadius = data.explosionRadius;
+ bullet.explosionDamage = data.explosionDamage;
+
+ // Penetrating
+ bullet.isPenetrating = data.isPenetrating;
+ bullet.penetrationCount = data.penetrationCount;
+
+ // Effects
+ bullet.freezeMultiplier = data.freezeMultiplier;
+ bullet.burnRate = data.burnRate;
+
+ this.bullets.set(bullet.id, bullet);
+ return bullet;
+ }
+
+ /**
+ * Get bullet fired event for client sync
+ */
+ getBulletFiredEvent(bullet: BulletState): BulletFiredEvent {
+ return {
+ bulletId: bullet.id,
+ bulletType: bullet.bulletType,
+ towerId: bullet.towerId,
+ ownerId: bullet.ownerId,
+ x: bullet.position.x,
+ y: bullet.position.y,
+ vx: bullet.velocity.x,
+ vy: bullet.velocity.y,
+ radius: bullet.radius,
+ maxRange: bullet.maxRange,
+ };
+ }
+
+ /**
+ * Update all bullet positions (Phase 1: Movement)
+ */
+ updatePositions(monsters: MapSchema): void {
+ for (const bullet of this.bullets.values()) {
+ // Save previous position
+ bullet.prevX = bullet.position.x;
+ bullet.prevY = bullet.position.y;
+
+ // Tracking movement
+ if (bullet.isTracking && bullet.targetId) {
+ const target = monsters.get(bullet.targetId);
+ if (target && target.hp > 0) {
+ // Track toward target
+ const dir = normalize(
+ sub({ x: target.position.x, y: target.position.y }, { x: bullet.position.x, y: bullet.position.y })
+ );
+ const speed = Math.sqrt(bullet.velocity.x ** 2 + bullet.velocity.y ** 2);
+ bullet.setVelocity(dir.x * speed, dir.y * speed);
+ }
+ }
+
+ // Apply velocity
+ bullet.setPosition(bullet.position.x + bullet.velocity.x, bullet.position.y + bullet.velocity.y);
+
+ // Check range
+ if (bullet.isOutOfRange()) {
+ this.toRemove.add(bullet.id);
+ }
+ }
+ }
+
+ /**
+ * Process all bullet collisions (Phase 2: Collision)
+ * Returns hit results for damage application
+ */
+ processCollisions(
+ monsterGrid: SpatialHashGrid,
+ buildingGrid?: SpatialHashGrid
+ ): BulletHitResult[] {
+ const results: BulletHitResult[] = [];
+
+ for (const bullet of this.bullets.values()) {
+ if (this.toRemove.has(bullet.id)) continue;
+
+ const hitResult = bullet.targetsTowers
+ ? this.checkBuildingCollision(bullet, buildingGrid)
+ : this.checkMonsterCollision(bullet, monsterGrid);
+
+ if (hitResult) {
+ results.push(hitResult);
+
+ // Handle penetrating bullets
+ if (bullet.isPenetrating && bullet.penetrationCount > 0) {
+ bullet.penetrationCount--;
+ bullet.radius *= 0.9; // Shrink after penetration
+ if (bullet.radius <= 0 || bullet.penetrationCount <= 0) {
+ this.toRemove.add(bullet.id);
+ }
+ } else {
+ this.toRemove.add(bullet.id);
+ }
+ }
+ }
+
+ return results;
+ }
+
+ /**
+ * Check collision with monsters
+ */
+ private checkMonsterCollision(
+ bullet: BulletState,
+ monsterGrid: SpatialHashGrid
+ ): BulletHitResult | null {
+ const nearbyMonsters = monsterGrid.queryRange(
+ bullet.position.x,
+ bullet.position.y,
+ bullet.radius + 100 // Buffer for sweep collision
+ );
+
+ for (const entity of nearbyMonsters) {
+ const monster = entity.state;
+
+ // Check ownership
+ if (!isEnemy({ ownerId: bullet.ownerId }, { ownerId: monster.ownerId })) {
+ continue;
+ }
+
+ // Get monster previous position
+ const mPrevX = entity.prevX ?? monster.position.x;
+ const mPrevY = entity.prevY ?? monster.position.y;
+
+ // Sweep collision detection (both objects moving)
+ const collided = sweepCollidesRelative(
+ bullet.prevX,
+ bullet.prevY,
+ bullet.position.x,
+ bullet.position.y,
+ bullet.radius,
+ mPrevX,
+ mPrevY,
+ monster.position.x,
+ monster.position.y,
+ monster.radius
+ );
+
+ if (collided) {
+ return this.createHitResult(bullet, monster.id, 'monster', monsterGrid);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Check collision with buildings
+ */
+ private checkBuildingCollision(
+ bullet: BulletState,
+ buildingGrid?: SpatialHashGrid
+ ): BulletHitResult | null {
+ if (!buildingGrid) return null;
+
+ const nearbyBuildings = buildingGrid.queryRange(
+ bullet.position.x,
+ bullet.position.y,
+ bullet.radius + 100
+ );
+
+ for (const entity of nearbyBuildings) {
+ const building = entity.state;
+
+ // Check ownership
+ if (!isEnemy({ ownerId: bullet.ownerId }, { ownerId: building.ownerId })) {
+ continue;
+ }
+
+ // Buildings don't move, use basic sweep collision
+ const collided = sweepCollides(
+ bullet.prevX,
+ bullet.prevY,
+ bullet.position.x,
+ bullet.position.y,
+ bullet.radius,
+ building.position.x,
+ building.position.y,
+ building.radius
+ );
+
+ if (collided) {
+ return this.createHitResult(bullet, building.id, 'building');
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Create hit result
+ */
+ private createHitResult(
+ bullet: BulletState,
+ targetId: string,
+ targetType: 'monster' | 'building',
+ monsterGrid?: SpatialHashGrid
+ ): BulletHitResult {
+ const result: BulletHitResult = {
+ bulletId: bullet.id,
+ towerId: bullet.towerId,
+ ownerId: bullet.ownerId,
+ targetId,
+ targetType,
+ damage: bullet.damage,
+ position: { x: bullet.position.x, y: bullet.position.y },
+ freezeMultiplier: bullet.freezeMultiplier,
+ burnRate: bullet.burnRate,
+ isExplosion: bullet.isExplosive,
+ explosionRadius: bullet.explosionRadius,
+ explosionDamage: bullet.explosionDamage,
+ explosionTargets: [],
+ };
+
+ // Calculate explosion targets
+ if (bullet.isExplosive && bullet.explosionRadius > 0 && monsterGrid) {
+ const explosionTargets = monsterGrid.queryRange(
+ bullet.position.x,
+ bullet.position.y,
+ bullet.explosionRadius
+ );
+
+ for (const entity of explosionTargets) {
+ const monster = entity.state;
+
+ if (!isEnemy({ ownerId: bullet.ownerId }, { ownerId: monster.ownerId })) {
+ continue;
+ }
+
+ // Check if in explosion range
+ const dist = Math.sqrt(
+ distSq({ x: bullet.position.x, y: bullet.position.y }, { x: monster.position.x, y: monster.position.y })
+ );
+
+ if (dist <= bullet.explosionRadius + monster.radius) {
+ // Damage falls off with distance
+ const damageRatio = Math.max(0, 1 - dist / bullet.explosionRadius);
+ const explosionDamage = bullet.explosionDamage * damageRatio;
+
+ result.explosionTargets.push({
+ id: monster.id,
+ damage: explosionDamage,
+ });
+ }
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Remove bullets marked for removal
+ */
+ cleanup(): string[] {
+ const removed: string[] = [];
+
+ for (const id of this.toRemove) {
+ this.bullets.delete(id);
+ removed.push(id);
+ }
+
+ this.toRemove.clear();
+ return removed;
+ }
+
+ /**
+ * Get all active bullets
+ */
+ getActiveBullets(): Map {
+ return this.bullets;
+ }
+
+ /**
+ * Get bullet by ID
+ */
+ getBullet(id: string): BulletState | undefined {
+ return this.bullets.get(id);
+ }
+
+ /**
+ * Remove a bullet
+ */
+ removeBullet(id: string): void {
+ this.toRemove.add(id);
+ }
+
+ /**
+ * Clear all bullets
+ */
+ clear(): void {
+ this.bullets.clear();
+ this.toRemove.clear();
+ }
+
+ /**
+ * Get bullet count
+ */
+ getBulletCount(): number {
+ return this.bullets.size;
+ }
+}
diff --git a/server/src/systems/combat/combatSystem.ts b/server/src/systems/combat/combatSystem.ts
new file mode 100644
index 0000000..beeb422
--- /dev/null
+++ b/server/src/systems/combat/combatSystem.ts
@@ -0,0 +1,394 @@
+/**
+ * Combat System - Facade for all combat subsystems
+ * Coordinates tower attacks, bullet management, and damage calculation
+ */
+
+import type { TowerState } from '../../schema/TowerState.js';
+import type { MonsterState } from '../../schema/MonsterState.js';
+import type { BuildingState } from '../../schema/BuildingState.js';
+import type { BulletState } from '../../schema/BulletState.js';
+import type { MineState } from '../../schema/MineState.js';
+import type { MapSchema } from '@colyseus/schema';
+import { TowerAttackSystem, type AttackResult, type BulletCreationData } from './towerAttack.js';
+import { BulletManager, type BulletHitResult, type BulletFiredEvent } from './bulletManager.js';
+import { DamageCalculator } from './damageCalculator.js';
+import { SpatialHashGrid, type SpatialEntity } from '../spatial/spatialHashGrid.js';
+import { processMonsterMelee, type MeleeResult } from './monsterMeleeSystem.js';
+import { TOWER_COMBAT_META, type TowerCombatData } from '../../../../shared/config/towerCombatMeta.js';
+import { BULLET_COMBAT_META } from '../../../../shared/config/bulletCombatMeta.js';
+import { normalize, sub } from '../../shared/math/vector.js';
+
+/**
+ * Spatial entity wrappers for grids
+ */
+interface MonsterSpatialEntity extends SpatialEntity {
+ state: MonsterState;
+ prevX?: number;
+ prevY?: number;
+}
+
+interface BuildingSpatialEntity extends SpatialEntity {
+ state: BuildingState;
+}
+
+/**
+ * Result of a single combat tick
+ */
+export interface CombatTickResult {
+ bulletsFired: BulletFiredEvent[];
+ bulletsHit: BulletHitResult[];
+ bulletsRemoved: string[];
+}
+
+/**
+ * CombatSystem facade - integrates all combat subsystems
+ */
+export class CombatSystem {
+ private towerAttack: TowerAttackSystem;
+ private bulletMgr: BulletManager;
+ private damageCalc: DamageCalculator;
+ private monsterGrid: SpatialHashGrid;
+ private buildingGrid: SpatialHashGrid;
+
+ // Track monster/building spatial wrappers
+ private monsterEntities: Map = new Map();
+ private buildingEntities: Map = new Map();
+
+ constructor(
+ mapWidth: number,
+ mapHeight: number,
+ damageCalc: DamageCalculator
+ ) {
+ this.towerAttack = new TowerAttackSystem();
+ this.bulletMgr = new BulletManager();
+ this.damageCalc = damageCalc;
+
+ // Cell size 128 for good balance of precision and performance
+ this.monsterGrid = new SpatialHashGrid(mapWidth, mapHeight, 128);
+ this.buildingGrid = new SpatialHashGrid(mapWidth, mapHeight, 128);
+ }
+
+ /**
+ * Register all tower configs from TOWER_COMBAT_META
+ */
+ initTowerConfigs(): void {
+ for (const [towerType, meta] of Object.entries(TOWER_COMBAT_META)) {
+ this.towerAttack.registerTowerConfig(towerType, {
+ attackRadius: meta.attackRadius,
+ attackClock: meta.attackClock,
+ bulletCount: meta.bulletCount,
+ bulletSpread: meta.bulletSpread,
+ bulletType: meta.bulletType,
+ bulletDamage: meta.bulletDamage,
+ bulletSpeed: meta.bulletSpeed,
+ bulletRadius: meta.bulletRadius,
+ isShrapnel: meta.isShrapnel,
+ isExplosive: meta.isExplosive,
+ explosionRadius: meta.explosionRadius,
+ explosionDamage: meta.explosionDamage,
+ isTracking: meta.isTracking,
+ trackingRadius: meta.trackingRadius,
+ isPenetrating: meta.isPenetrating,
+ penetrationCount: meta.penetrationCount,
+ freezeMultiplier: meta.freezeMultiplier,
+ burnRate: meta.burnRate,
+ });
+ }
+ }
+
+ /**
+ * Main combat update tick
+ */
+ update(
+ currentTick: number,
+ towers: MapSchema,
+ monsters: MapSchema,
+ buildings: MapSchema
+ ): CombatTickResult {
+ const bulletsFired: BulletFiredEvent[] = [];
+
+ // Step 1: Update spatial grids
+ this.updateMonsterGrid(monsters);
+ this.updateBuildingGrid(buildings);
+
+ // Step 2: Tower attacks -> create bullets
+ const attacks = this.towerAttack.processAttacks(
+ currentTick,
+ towers,
+ this.monsterGrid,
+ this.damageCalc
+ );
+
+ for (const attack of attacks) {
+ for (const bulletData of attack.bullets) {
+ const bullet = this.bulletMgr.createBullet(bulletData, attack.towerId, attack.ownerId);
+ bulletsFired.push(this.bulletMgr.getBulletFiredEvent(bullet));
+ }
+ }
+
+ // Step 3: Move bullets
+ this.bulletMgr.updatePositions(monsters);
+
+ // Step 4: Bullet collisions
+ const bulletsHit = this.bulletMgr.processCollisions(this.monsterGrid, this.buildingGrid);
+
+ // Step 5: Cleanup dead bullets
+ const bulletsRemoved = this.bulletMgr.cleanup();
+
+ return { bulletsFired, bulletsHit, bulletsRemoved };
+ }
+
+ /**
+ * Process monster-building melee collisions
+ */
+ processMonsterMelee(
+ monsters: MapSchema,
+ buildings: MapSchema,
+ mines?: MapSchema
+ ): MeleeResult[] {
+ return processMonsterMelee(monsters, buildings, mines);
+ }
+
+ /**
+ * Fire manual cannon - creates a bullet toward target position
+ */
+ fireManualCannon(
+ tower: TowerState,
+ targetX: number,
+ targetY: number
+ ): BulletFiredEvent | null {
+ const bulletType = 'ManualCannon_Shell';
+ const bulletMeta = BULLET_COMBAT_META[bulletType];
+ if (!bulletMeta) return null;
+
+ const towerMeta = TOWER_COMBAT_META['ManualCannon'] as TowerCombatData | undefined;
+ const bulletSpeed = towerMeta?.bulletSpeed ?? 24; // scaleSpeed(8)
+
+ // Apply damage multiplier (territory + energy) — same as regular towers
+ const damageMultiplier = this.damageCalc.getDamageMultiplier(tower.id, tower.ownerId);
+
+ const dir = normalize(
+ sub(
+ { x: targetX, y: targetY },
+ { x: tower.position.x, y: tower.position.y }
+ )
+ );
+
+ const bulletData: BulletCreationData = {
+ bulletType,
+ x: tower.position.x,
+ y: tower.position.y,
+ vx: dir.x * bulletSpeed,
+ vy: dir.y * bulletSpeed,
+ damage: bulletMeta.damage * damageMultiplier,
+ radius: bulletMeta.radius,
+ maxRange: tower.attackRadius || 300,
+ isExplosive: bulletMeta.isExplosive,
+ explosionRadius: bulletMeta.explosionRadius,
+ explosionDamage: (bulletMeta.explosionDamage ?? 0) * damageMultiplier,
+ isPenetrating: bulletMeta.isPenetrating,
+ penetrationCount: bulletMeta.penetrationCount,
+ freezeMultiplier: bulletMeta.freezeMultiplier,
+ burnRate: bulletMeta.burnRate,
+ };
+
+ const bullet = this.bulletMgr.createBullet(bulletData, tower.id, tower.ownerId);
+ // Manual cannon shells can hit buildings
+ bullet.targetsTowers = true;
+
+ return this.bulletMgr.getBulletFiredEvent(bullet);
+ }
+
+ /**
+ * Notify tower removed (cleanup cooldown tracking)
+ */
+ /**
+ * Process auto-fire for manual cannons that have an auto-target set.
+ * Searches for enemy buildings/towers first, then monsters in the marked area.
+ */
+ processCannonAutoFire(
+ currentTick: number,
+ towers: MapSchema
+ ): BulletFiredEvent[] {
+ const events: BulletFiredEvent[] = [];
+
+ towers.forEach((tower: TowerState) => {
+ if (!tower.isManual || !tower.hasAutoTarget || tower.currentAmmo <= 0) return;
+
+ // Cooldown check: ~1 second at 60fps
+ if (!this.towerAttack.checkAndSetCooldown(tower.id, currentTick, 60)) return;
+
+ // Find target in the marked area
+ const target = this.findAutoTarget(tower);
+ if (!target) return;
+
+ const bulletEvent = this.fireManualCannon(tower, target.x, target.y);
+ if (bulletEvent) {
+ tower.currentAmmo--;
+ events.push(bulletEvent);
+ }
+ });
+
+ return events;
+ }
+
+ /**
+ * Find a target within the cannon's auto-target area.
+ * Priority: enemy buildings/towers > enemy monsters.
+ * Target must also be within the cannon's attack radius.
+ */
+ private findAutoTarget(
+ tower: TowerState
+ ): { x: number; y: number } | null {
+ const markerX = tower.autoTargetX;
+ const markerY = tower.autoTargetY;
+ const markerRadius = tower.autoTargetRadius;
+ const attackRadius = tower.attackRadius || 300;
+
+ // Helper: check if position is within cannon's attack range
+ const inAttackRange = (x: number, y: number): boolean => {
+ const dx = x - tower.position.x;
+ const dy = y - tower.position.y;
+ return dx * dx + dy * dy <= attackRadius * attackRadius;
+ };
+
+ // Helper: check if position is within the marked area
+ const inMarkedArea = (x: number, y: number): boolean => {
+ const dx = x - markerX;
+ const dy = y - markerY;
+ return dx * dx + dy * dy <= markerRadius * markerRadius;
+ };
+
+ // Priority 1: Enemy buildings in marked area
+ let closestBuildingDistSq = Infinity;
+ let closestBuildingPos: { x: number; y: number } | null = null;
+
+ for (const [, entity] of this.buildingEntities) {
+ const building = entity.state;
+ if (building.ownerId === tower.ownerId) continue; // Skip friendly
+ const bx = building.position.x;
+ const by = building.position.y;
+ if (!inMarkedArea(bx, by) || !inAttackRange(bx, by)) continue;
+
+ const distSq = (bx - tower.position.x) ** 2 + (by - tower.position.y) ** 2;
+ if (distSq < closestBuildingDistSq) {
+ closestBuildingDistSq = distSq;
+ closestBuildingPos = { x: bx, y: by };
+ }
+ }
+
+ if (closestBuildingPos) return closestBuildingPos;
+
+ // Priority 2: Enemy monsters in marked area
+ const nearbyMonsters = this.monsterGrid.queryRange(markerX, markerY, markerRadius);
+ let closestMonsterDistSq = Infinity;
+ let closestMonsterPos: { x: number; y: number } | null = null;
+
+ for (const entity of nearbyMonsters) {
+ const monster = entity.state;
+ // Skip friendly monsters
+ if (tower.ownerId && monster.ownerId === tower.ownerId) continue;
+
+ const mx = monster.position.x;
+ const my = monster.position.y;
+ if (!inAttackRange(mx, my)) continue;
+
+ const distSq = (mx - tower.position.x) ** 2 + (my - tower.position.y) ** 2;
+ if (distSq < closestMonsterDistSq) {
+ closestMonsterDistSq = distSq;
+ closestMonsterPos = { x: mx, y: my };
+ }
+ }
+
+ return closestMonsterPos;
+ }
+
+ onTowerRemoved(towerId: string): void {
+ this.towerAttack.removeTower(towerId);
+ }
+
+ /**
+ * Notify tower upgraded (reset cooldown for immediate re-engagement)
+ */
+ onTowerUpgraded(towerId: string, _newType: string): void {
+ this.towerAttack.resetTowerCooldown(towerId);
+ }
+
+ /**
+ * Get active bullet count (for debugging/monitoring)
+ */
+ getActiveBulletCount(): number {
+ return this.bulletMgr.getBulletCount();
+ }
+
+ /**
+ * Clear all combat state
+ */
+ clear(): void {
+ this.bulletMgr.clear();
+ this.monsterGrid.clear();
+ this.buildingGrid.clear();
+ this.monsterEntities.clear();
+ this.buildingEntities.clear();
+ }
+
+ // ==================== Private Methods ====================
+
+ private updateMonsterGrid(monsters: MapSchema): void {
+ // Remove entities no longer present
+ for (const [id, entity] of this.monsterEntities) {
+ if (!monsters.has(id)) {
+ this.monsterGrid.remove(entity);
+ this.monsterEntities.delete(id);
+ }
+ }
+
+ // Update or insert
+ monsters.forEach((monster: MonsterState) => {
+ let entity = this.monsterEntities.get(monster.id);
+ if (!entity) {
+ entity = {
+ id: monster.id,
+ position: monster.position,
+ radius: monster.radius,
+ state: monster,
+ prevX: monster.prevX,
+ prevY: monster.prevY,
+ };
+ this.monsterEntities.set(monster.id, entity);
+ } else {
+ entity.position = monster.position;
+ entity.radius = monster.radius;
+ entity.prevX = monster.prevX;
+ entity.prevY = monster.prevY;
+ }
+ this.monsterGrid.update(entity);
+ });
+ }
+
+ private updateBuildingGrid(buildings: MapSchema): void {
+ // Remove entities no longer present
+ for (const [id, entity] of this.buildingEntities) {
+ if (!buildings.has(id)) {
+ this.buildingGrid.remove(entity);
+ this.buildingEntities.delete(id);
+ }
+ }
+
+ // Update or insert
+ buildings.forEach((building: BuildingState) => {
+ let entity = this.buildingEntities.get(building.id);
+ if (!entity) {
+ entity = {
+ id: building.id,
+ position: building.position,
+ radius: building.radius,
+ state: building,
+ };
+ this.buildingEntities.set(building.id, entity);
+ this.buildingGrid.insert(entity);
+ }
+ // Buildings don't move, so no need to update position in grid
+ });
+ }
+}
diff --git a/server/src/systems/combat/damageCalculator.ts b/server/src/systems/combat/damageCalculator.ts
new file mode 100644
index 0000000..08d7804
--- /dev/null
+++ b/server/src/systems/combat/damageCalculator.ts
@@ -0,0 +1,74 @@
+/**
+ * Damage Calculator - Server-side damage multiplier calculation
+ * Combines territory and energy factors for tower damage
+ *
+ * Based on src/towers/base/tower.ts getDamageMultiplier()
+ */
+
+import type { TerritoryCalculator } from '../territory/territoryCalculator.js';
+import type { EnergyCalculator } from '../energy/energyCalculator.js';
+
+/**
+ * Calculate final damage multiplier for a tower
+ *
+ * Multiplier = territoryMultiplier * energyRatio
+ * - territoryMultiplier: 1.0 in valid territory, 0.33 in invalid
+ * - energyRatio: production / consumption (capped at 1.0)
+ */
+export class DamageCalculator {
+ private territoryCalc: TerritoryCalculator;
+ private energyCalc: EnergyCalculator;
+
+ constructor(territoryCalc: TerritoryCalculator, energyCalc: EnergyCalculator) {
+ this.territoryCalc = territoryCalc;
+ this.energyCalc = energyCalc;
+ }
+
+ /**
+ * Get damage multiplier for a tower
+ * @param towerId Tower ID
+ * @param ownerId Owner player ID
+ * @returns Damage multiplier (0.0 to 1.0)
+ */
+ getDamageMultiplier(towerId: string, ownerId: string): number {
+ // Territory multiplier: 1.0 or 0.33
+ const territoryMult = this.territoryCalc.getTerritoryMultiplier(towerId, ownerId);
+
+ // Energy ratio: 0.0 to 1.0
+ const energyRatio = this.energyCalc.getSatisfactionRatio(ownerId);
+
+ return territoryMult * energyRatio;
+ }
+
+ /**
+ * Calculate actual damage from base damage
+ * @param baseDamage Tower's base damage
+ * @param towerId Tower ID
+ * @param ownerId Owner player ID
+ * @returns Actual damage after multipliers
+ */
+ calculateDamage(baseDamage: number, towerId: string, ownerId: string): number {
+ const multiplier = this.getDamageMultiplier(towerId, ownerId);
+ return baseDamage * multiplier;
+ }
+
+ /**
+ * Calculate actual damage with provided multiplier (for caching)
+ */
+ calculateDamageWithMultiplier(baseDamage: number, multiplier: number): number {
+ return baseDamage * multiplier;
+ }
+}
+
+/**
+ * Standalone function for damage calculation without calculator instances
+ * Useful for simple cases where you already have the values
+ */
+export function calculateDamage(
+ baseDamage: number,
+ inValidTerritory: boolean,
+ energySatisfactionRatio: number
+): number {
+ const territoryMult = inValidTerritory ? 1.0 : 1 / 3;
+ return baseDamage * territoryMult * energySatisfactionRatio;
+}
diff --git a/server/src/systems/combat/index.ts b/server/src/systems/combat/index.ts
new file mode 100644
index 0000000..38cd6a6
--- /dev/null
+++ b/server/src/systems/combat/index.ts
@@ -0,0 +1,17 @@
+/**
+ * Combat Systems
+ */
+export { DamageCalculator, calculateDamage } from './damageCalculator.js';
+export {
+ TowerAttackSystem,
+ type TowerAttackConfig,
+ type AttackResult,
+ type BulletCreationData,
+} from './towerAttack.js';
+export {
+ BulletManager,
+ type BulletHitResult,
+ type BulletFiredEvent,
+} from './bulletManager.js';
+export { CombatSystem, type CombatTickResult } from './combatSystem.js';
+export { processMonsterMelee, type MeleeResult } from './monsterMeleeSystem.js';
diff --git a/server/src/systems/combat/monsterMeleeSystem.ts b/server/src/systems/combat/monsterMeleeSystem.ts
new file mode 100644
index 0000000..cd4f4a9
--- /dev/null
+++ b/server/src/systems/combat/monsterMeleeSystem.ts
@@ -0,0 +1,107 @@
+/**
+ * Monster Melee System
+ * Handles monster-building and monster-mine collision (melee attacks)
+ * Monster deals damage proportional to its HP on collision, then dies.
+ */
+
+import type { MonsterState } from '../../schema/MonsterState.js';
+import type { BuildingState } from '../../schema/BuildingState.js';
+import type { MineState } from '../../schema/MineState.js';
+import type { MapSchema } from '@colyseus/schema';
+import { collides } from '../../shared/math/circle.js';
+import { isEnemy } from '../../shared/types/ownership.js';
+import { MineStateType } from '../../../../shared/config/mineMeta.js';
+
+export interface MeleeResult {
+ monsterId: string;
+ buildingId: string;
+ damage: number;
+ monsterOwnerId: string;
+ /** If set, this melee hit a mine instead of a building */
+ mineId?: string;
+}
+
+/**
+ * Process monster-building and monster-mine melee collisions.
+ * Monsters that collide with enemy buildings/mines deal damage = hp * 0.5, then die.
+ */
+export function processMonsterMelee(
+ monsters: MapSchema,
+ buildings: MapSchema,
+ mines?: MapSchema
+): MeleeResult[] {
+ const results: MeleeResult[] = [];
+ const processed = new Set();
+
+ monsters.forEach((monster: MonsterState) => {
+ if (processed.has(monster.id)) return;
+
+ // Check buildings
+ buildings.forEach((building: BuildingState) => {
+ if (processed.has(monster.id)) return;
+
+ if (!isEnemy({ ownerId: monster.ownerId }, { ownerId: building.ownerId })) {
+ return;
+ }
+
+ const hit = collides(
+ monster.position.x,
+ monster.position.y,
+ monster.radius,
+ building.position.x,
+ building.position.y,
+ building.radius
+ );
+
+ if (hit) {
+ const damage = Math.max(1, Math.floor(monster.hp * 0.5));
+
+ results.push({
+ monsterId: monster.id,
+ buildingId: building.id,
+ damage,
+ monsterOwnerId: monster.ownerId,
+ });
+
+ processed.add(monster.id);
+ }
+ });
+
+ // Check mines (only powerPlant state)
+ if (mines) {
+ mines.forEach((mine: MineState) => {
+ if (processed.has(monster.id)) return;
+ if (mine.mineState !== MineStateType.POWER_PLANT) return;
+
+ if (!isEnemy({ ownerId: monster.ownerId }, { ownerId: mine.ownerId })) {
+ return;
+ }
+
+ const hit = collides(
+ monster.position.x,
+ monster.position.y,
+ monster.radius,
+ mine.position.x,
+ mine.position.y,
+ mine.radius
+ );
+
+ if (hit) {
+ const damage = Math.max(1, Math.floor(monster.hp * 0.5));
+
+ results.push({
+ monsterId: monster.id,
+ buildingId: mine.id,
+ damage,
+ monsterOwnerId: monster.ownerId,
+ mineId: mine.id,
+ });
+
+ processed.add(monster.id);
+ }
+ });
+ }
+ });
+
+ return results;
+}
diff --git a/server/src/systems/combat/towerAttack.ts b/server/src/systems/combat/towerAttack.ts
new file mode 100644
index 0000000..0c33fdb
--- /dev/null
+++ b/server/src/systems/combat/towerAttack.ts
@@ -0,0 +1,280 @@
+/**
+ * Tower Attack System - Server-side tower attack logic
+ * Handles target finding, attack timing, and bullet creation
+ *
+ * Port of src/towers/base/tower.ts attack methods for server use
+ */
+
+import type { TowerState } from '../../schema/TowerState.js';
+import type { MonsterState } from '../../schema/MonsterState.js';
+import type { MapSchema } from '@colyseus/schema';
+import { distSq, normalize, sub, rotate } from '../../shared/math/vector.js';
+import { collides } from '../../shared/math/circle.js';
+import { isEnemy } from '../../shared/types/ownership.js';
+import type { SpatialHashGrid, SpatialEntity } from '../spatial/spatialHashGrid.js';
+import type { DamageCalculator } from './damageCalculator.js';
+
+/**
+ * Tower attack configuration (from tower config)
+ */
+export interface TowerAttackConfig {
+ attackRadius: number; // Attack range
+ attackClock: number; // Ticks between attacks
+ bulletCount: number; // Bullets per attack
+ bulletSpread: number; // Spread angle for shrapnel (radians)
+ bulletType: string; // Bullet type to fire
+ bulletDamage: number; // Base damage per bullet
+ bulletSpeed: number; // Bullet velocity
+ bulletRadius: number; // Bullet collision radius
+ isShrapnel: boolean; // Use shrapnel attack pattern
+ // Optional properties for special bullets
+ isExplosive?: boolean;
+ explosionRadius?: number;
+ explosionDamage?: number;
+ isTracking?: boolean;
+ trackingRadius?: number;
+ isPenetrating?: boolean;
+ penetrationCount?: number;
+ freezeMultiplier?: number;
+ burnRate?: number;
+}
+
+/**
+ * Result of a tower attack (bullets to create)
+ */
+export interface AttackResult {
+ towerId: string;
+ ownerId: string;
+ bullets: BulletCreationData[];
+}
+
+/**
+ * Data for creating a bullet
+ */
+export interface BulletCreationData {
+ bulletType: string;
+ x: number;
+ y: number;
+ vx: number;
+ vy: number;
+ damage: number;
+ radius: number;
+ maxRange: number;
+ targetId?: string; // For tracking bullets
+ isExplosive: boolean;
+ explosionRadius: number;
+ explosionDamage: number;
+ isPenetrating: boolean;
+ penetrationCount: number;
+ freezeMultiplier: number;
+ burnRate: number;
+}
+
+/**
+ * Monster wrapper for spatial grid
+ */
+interface MonsterSpatialEntity extends SpatialEntity {
+ state: MonsterState;
+}
+
+/**
+ * Tower Attack System
+ */
+export class TowerAttackSystem {
+ // Tower configurations (loaded from game config)
+ private towerConfigs: Map = new Map();
+
+ // Tower attack state (last attack tick)
+ private lastAttackTick: Map = new Map();
+
+ constructor() {}
+
+ /**
+ * Register tower attack configuration
+ */
+ registerTowerConfig(towerType: string, config: TowerAttackConfig): void {
+ this.towerConfigs.set(towerType, config);
+ }
+
+ /**
+ * Get tower config
+ */
+ getTowerConfig(towerType: string): TowerAttackConfig | undefined {
+ return this.towerConfigs.get(towerType);
+ }
+
+ /**
+ * Process attacks for all towers
+ * Returns list of attack results (bullets to create)
+ */
+ processAttacks(
+ currentTick: number,
+ towers: MapSchema,
+ monsterGrid: SpatialHashGrid,
+ damageCalc: DamageCalculator
+ ): AttackResult[] {
+ const results: AttackResult[] = [];
+
+ towers.forEach((tower) => {
+ if (tower.isManual) return; // Manual towers don't auto-attack
+
+ const config = this.towerConfigs.get(tower.towerType);
+ if (!config) return;
+
+ // Check attack cooldown
+ const lastAttack = this.lastAttackTick.get(tower.id) || 0;
+ if (currentTick - lastAttack < config.attackClock) return;
+
+ // Find target
+ const target = this.findTarget(tower, config, monsterGrid);
+ if (!target) return;
+
+ // Calculate damage multiplier
+ const damageMultiplier = damageCalc.getDamageMultiplier(tower.id, tower.ownerId);
+ const actualDamage = config.bulletDamage * damageMultiplier;
+
+ // Create attack
+ const bullets = this.createAttack(tower, target, config, actualDamage);
+ if (bullets.length > 0) {
+ results.push({
+ towerId: tower.id,
+ ownerId: tower.ownerId,
+ bullets,
+ });
+ this.lastAttackTick.set(tower.id, currentTick);
+ }
+ });
+
+ return results;
+ }
+
+ /**
+ * Find first valid target for a tower
+ */
+ private findTarget(
+ tower: TowerState,
+ config: TowerAttackConfig,
+ monsterGrid: SpatialHashGrid
+ ): MonsterState | null {
+ const nearbyMonsters = monsterGrid.queryRange(
+ tower.position.x,
+ tower.position.y,
+ tower.attackRadius + 50 // Small buffer for edge cases
+ );
+
+ for (const entity of nearbyMonsters) {
+ const monster = entity.state;
+
+ // Check ownership
+ if (!isEnemy({ ownerId: tower.ownerId }, { ownerId: monster.ownerId })) {
+ continue;
+ }
+
+ // Check if in attack range
+ if (
+ collides(
+ tower.position.x,
+ tower.position.y,
+ tower.attackRadius,
+ monster.position.x,
+ monster.position.y,
+ monster.radius
+ )
+ ) {
+ return monster;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Create attack bullets
+ */
+ private createAttack(
+ tower: TowerState,
+ target: MonsterState,
+ config: TowerAttackConfig,
+ damage: number
+ ): BulletCreationData[] {
+ const bullets: BulletCreationData[] = [];
+
+ // Calculate direction to target
+ const dir = normalize(
+ sub({ x: target.position.x, y: target.position.y }, { x: tower.position.x, y: tower.position.y })
+ );
+
+ if (config.isShrapnel && config.bulletCount > 1) {
+ // Shrapnel attack: spread bullets in an arc
+ for (let i = 0; i < config.bulletCount; i++) {
+ // Calculate spread angle for this bullet
+ const spreadFraction = i / config.bulletCount;
+ const angle = config.bulletSpread * spreadFraction - config.bulletSpread / 2;
+ const rotatedDir = rotate(dir, angle);
+
+ bullets.push(this.createBulletData(tower, rotatedDir, config, damage));
+ }
+ } else {
+ // Normal attack: fire directly at target
+ for (let i = 0; i < config.bulletCount; i++) {
+ bullets.push(this.createBulletData(tower, dir, config, damage, target.id));
+ }
+ }
+
+ return bullets;
+ }
+
+ /**
+ * Create bullet data
+ */
+ private createBulletData(
+ tower: TowerState,
+ direction: { x: number; y: number },
+ config: TowerAttackConfig,
+ damage: number,
+ targetId?: string
+ ): BulletCreationData {
+ return {
+ bulletType: config.bulletType,
+ x: tower.position.x,
+ y: tower.position.y,
+ vx: direction.x * config.bulletSpeed,
+ vy: direction.y * config.bulletSpeed,
+ damage,
+ radius: config.bulletRadius,
+ maxRange: tower.attackRadius,
+ targetId: config.isTracking ? targetId : undefined,
+ isExplosive: config.isExplosive ?? false,
+ explosionRadius: config.explosionRadius ?? 0,
+ explosionDamage: config.explosionDamage ?? 0,
+ isPenetrating: config.isPenetrating ?? false,
+ penetrationCount: config.penetrationCount ?? 0,
+ freezeMultiplier: config.freezeMultiplier ?? 1,
+ burnRate: config.burnRate ?? 0,
+ };
+ }
+
+ /**
+ * Reset tower cooldown (for selling/rebuilding)
+ */
+ /**
+ * Check cooldown and set it if ready. Returns true if action can proceed.
+ */
+ checkAndSetCooldown(towerId: string, currentTick: number, cooldownTicks: number): boolean {
+ const lastAttack = this.lastAttackTick.get(towerId) || 0;
+ if (currentTick - lastAttack < cooldownTicks) return false;
+ this.lastAttackTick.set(towerId, currentTick);
+ return true;
+ }
+
+ resetTowerCooldown(towerId: string): void {
+ this.lastAttackTick.delete(towerId);
+ }
+
+ /**
+ * Clean up removed tower
+ */
+ removeTower(towerId: string): void {
+ this.lastAttackTick.delete(towerId);
+ }
+}
diff --git a/server/src/systems/energy/energyCalculator.ts b/server/src/systems/energy/energyCalculator.ts
new file mode 100644
index 0000000..4aa1a46
--- /dev/null
+++ b/server/src/systems/energy/energyCalculator.ts
@@ -0,0 +1,252 @@
+/**
+ * Energy Calculator - Server-side energy production/consumption calculation
+ * Determines energy satisfaction ratio for damage multipliers
+ *
+ * Port of src/systems/energy/energy.ts for server use
+ */
+
+import type { TowerState } from '../../schema/TowerState.js';
+import type { BuildingState } from '../../schema/BuildingState.js';
+import type { MapSchema } from '@colyseus/schema';
+import { scalePeriod } from '../../shared/constants/speedScale.js';
+
+/**
+ * Configuration for energy calculation
+ */
+export interface EnergyConfig {
+ rootProduction: number; // Base production from headquarters
+ penaltyInterval: number; // Ticks between penalty application
+ penaltyCost: number; // Money cost per energy deficit
+ bonusInterval: number; // Ticks between bonus application
+ consumptionPerTowerLevel: number; // Energy per tower level
+ consumptionPerRepairBuilding: number; // Energy per repair building
+}
+
+const DEFAULT_CONFIG: EnergyConfig = {
+ rootProduction: 6,
+ penaltyInterval: scalePeriod(120),
+ penaltyCost: 1,
+ bonusInterval: scalePeriod(400),
+ consumptionPerTowerLevel: 0.5,
+ consumptionPerRepairBuilding: 0.5,
+};
+
+/**
+ * Energy state for a player
+ */
+export interface PlayerEnergyState {
+ production: number;
+ consumption: number;
+ balance: number;
+ satisfactionRatio: number;
+ isDeficit: boolean;
+}
+
+/**
+ * Mine-like entity for energy production
+ */
+interface MineEntity {
+ id: string;
+ ownerId: string;
+ production: number; // Energy production value
+}
+
+/**
+ * Calculate energy for all players
+ */
+export class EnergyCalculator {
+ private config: EnergyConfig;
+
+ // Cache per player
+ private playerStates: Map = new Map();
+ private dirty: boolean = true;
+
+ // Track mines separately (buildings with energy production)
+ private mines: Map = new Map();
+
+ constructor(config: Partial = {}) {
+ this.config = { ...DEFAULT_CONFIG, ...config };
+ }
+
+ /**
+ * Mark energy as needing recalculation
+ */
+ markDirty(): void {
+ this.dirty = true;
+ }
+
+ /**
+ * Register a mine (energy-producing building)
+ */
+ registerMine(id: string, ownerId: string, production: number): void {
+ this.mines.set(id, { id, ownerId, production });
+ this.markDirty();
+ }
+
+ /**
+ * Unregister a mine
+ */
+ unregisterMine(id: string): void {
+ this.mines.delete(id);
+ this.markDirty();
+ }
+
+ /**
+ * Update mine production
+ */
+ updateMineProduction(id: string, production: number): void {
+ const mine = this.mines.get(id);
+ if (mine) {
+ mine.production = production;
+ this.markDirty();
+ }
+ }
+
+ /**
+ * Clear all registered mines (used before bulk re-sync)
+ */
+ clearMines(): void {
+ this.mines.clear();
+ this.markDirty();
+ }
+
+ /**
+ * Recalculate energy for all players
+ */
+ recalculate(
+ towers: MapSchema,
+ buildings: MapSchema,
+ validTerritoryIds: Map> // playerId -> set of valid entity IDs
+ ): Map {
+ if (!this.dirty) return this.playerStates;
+ this.dirty = false;
+
+ // Collect all player IDs
+ const playerIds = new Set();
+ towers.forEach((t) => {
+ if (t.ownerId) playerIds.add(t.ownerId);
+ });
+ buildings.forEach((b) => {
+ if (b.ownerId) playerIds.add(b.ownerId);
+ });
+
+ // Calculate for each player
+ for (const playerId of playerIds) {
+ const state = this.calculatePlayerEnergy(
+ playerId,
+ towers,
+ buildings,
+ validTerritoryIds.get(playerId) || new Set()
+ );
+ this.playerStates.set(playerId, state);
+ }
+
+ return this.playerStates;
+ }
+
+ /**
+ * Calculate energy for a single player
+ */
+ private calculatePlayerEnergy(
+ playerId: string,
+ towers: MapSchema,
+ buildings: MapSchema,
+ validTerritoryIds: Set
+ ): PlayerEnergyState {
+ // Production: base + mines
+ let production = this.config.rootProduction;
+
+ for (const mine of this.mines.values()) {
+ if (mine.ownerId === playerId) {
+ production += mine.production;
+ }
+ }
+
+ // Consumption: towers in valid territory
+ let consumption = 0;
+
+ towers.forEach((tower) => {
+ if (tower.ownerId !== playerId) return;
+ if (!validTerritoryIds.has(tower.id)) return;
+
+ consumption += this.config.consumptionPerTowerLevel * tower.level;
+ });
+
+ // Consumption: repair buildings in valid territory
+ buildings.forEach((building) => {
+ if (building.ownerId !== playerId) return;
+ if (!validTerritoryIds.has(building.id)) return;
+
+ // Check if it's a repair building (otherHpAddAble)
+ if (building.buildingType === 'RepairBuilding') {
+ consumption += this.config.consumptionPerRepairBuilding;
+ }
+ });
+
+ const balance = production - consumption;
+ let satisfactionRatio = 1;
+
+ if (consumption > 0 && production < consumption) {
+ satisfactionRatio = production / consumption;
+ }
+
+ return {
+ production,
+ consumption,
+ balance,
+ satisfactionRatio,
+ isDeficit: balance < 0,
+ };
+ }
+
+ /**
+ * Get energy state for a specific player
+ */
+ getPlayerState(playerId: string): PlayerEnergyState | undefined {
+ return this.playerStates.get(playerId);
+ }
+
+ /**
+ * Get all player energy states (for syncing to PlayerState schema)
+ */
+ getPlayerStates(): Map {
+ return this.playerStates;
+ }
+
+ /**
+ * Get satisfaction ratio for a player (used for damage calculation)
+ * Returns 1.0 if player not found or no deficit
+ */
+ getSatisfactionRatio(playerId: string): number {
+ const state = this.playerStates.get(playerId);
+ return state?.satisfactionRatio ?? 1;
+ }
+
+ /**
+ * Process energy tick (apply penalties/bonuses)
+ * Returns map of playerId -> money change (negative for penalty, positive for bonus)
+ */
+ processTick(currentTick: number): Map {
+ const moneyChanges = new Map();
+
+ for (const [playerId, state] of this.playerStates) {
+ let change = 0;
+
+ // Penalty for deficit
+ if (state.isDeficit && currentTick % this.config.penaltyInterval === 0) {
+ change -= this.config.penaltyCost;
+ }
+
+ // Bonus for surplus
+ if (state.balance > 0 && currentTick % this.config.bonusInterval === 0) {
+ change += Math.floor(state.balance);
+ }
+
+ if (change !== 0) {
+ moneyChanges.set(playerId, change);
+ }
+ }
+
+ return moneyChanges;
+ }
+}
diff --git a/server/src/systems/energy/index.ts b/server/src/systems/energy/index.ts
new file mode 100644
index 0000000..0afe659
--- /dev/null
+++ b/server/src/systems/energy/index.ts
@@ -0,0 +1,8 @@
+/**
+ * Energy System
+ */
+export {
+ EnergyCalculator,
+ type EnergyConfig,
+ type PlayerEnergyState,
+} from './energyCalculator.js';
diff --git a/server/src/systems/index.ts b/server/src/systems/index.ts
new file mode 100644
index 0000000..f098e75
--- /dev/null
+++ b/server/src/systems/index.ts
@@ -0,0 +1,8 @@
+/**
+ * Game Systems
+ */
+export * from './spatial/index.js';
+export * from './territory/index.js';
+export * from './energy/index.js';
+export * from './combat/index.js';
+export * from './vision/index.js';
diff --git a/server/src/systems/mine/index.ts b/server/src/systems/mine/index.ts
new file mode 100644
index 0000000..33eaadf
--- /dev/null
+++ b/server/src/systems/mine/index.ts
@@ -0,0 +1,5 @@
+/**
+ * Mine System Module
+ */
+export { MineManager } from './mineManager.js';
+export { generateMinePositions } from './mineGenerator.js';
diff --git a/server/src/systems/mine/mineGenerator.ts b/server/src/systems/mine/mineGenerator.ts
new file mode 100644
index 0000000..8630d88
--- /dev/null
+++ b/server/src/systems/mine/mineGenerator.ts
@@ -0,0 +1,192 @@
+/**
+ * Mine Position Generator
+ * Generates pseudo-symmetric mine positions for PvP maps
+ * Ported from client-side world.ts generateMinesPseudoSymmetric
+ */
+
+import { MINE_GENERATION } from '../../../../shared/config/mineMeta.js';
+
+interface Position {
+ x: number;
+ y: number;
+}
+
+/**
+ * Generate mine positions for a PvP map.
+ * Distributes mines pseudo-symmetrically around player bases.
+ */
+export function generateMinePositions(
+ mapWidth: number,
+ mapHeight: number,
+ basePositions: Position[]
+): Position[] {
+ if (basePositions.length < 2) return [];
+
+ const positions: Position[] = [];
+ const centerX = mapWidth / 2;
+ const {
+ guaranteedNearBase,
+ nearBaseMinDist,
+ nearBaseMaxDist,
+ minesPerSide,
+ centerMines,
+ minDistFromEdge,
+ minDistFromBase,
+ } = MINE_GENERATION;
+
+ // Phase 1: Guaranteed mines near each base
+ for (const basePos of basePositions) {
+ let generated = 0;
+ let attempts = 0;
+ const maxAttempts = 100 * guaranteedNearBase;
+
+ while (generated < guaranteedNearBase && attempts < maxAttempts) {
+ const angle = Math.random() * Math.PI * 2;
+ const dist = nearBaseMinDist + Math.random() * (nearBaseMaxDist - nearBaseMinDist);
+ const x = basePos.x + Math.cos(angle) * dist;
+ const y = basePos.y + Math.sin(angle) * dist;
+ attempts++;
+
+ if (!isInBounds(x, y, mapWidth, mapHeight, minDistFromEdge)) continue;
+ if (isTooCloseToExisting(x, y, positions)) continue;
+
+ positions.push({ x, y });
+ generated++;
+ }
+ }
+
+ // Phase 2: Left side mines
+ generateMinesInRegion(
+ positions,
+ minDistFromEdge,
+ centerX - 100,
+ minDistFromEdge,
+ mapHeight - minDistFromEdge,
+ minesPerSide - guaranteedNearBase,
+ basePositions,
+ minDistFromBase
+ );
+
+ // Phase 3: Right side mines
+ generateMinesInRegion(
+ positions,
+ centerX + 100,
+ mapWidth - minDistFromEdge,
+ minDistFromEdge,
+ mapHeight - minDistFromEdge,
+ minesPerSide - guaranteedNearBase,
+ basePositions,
+ minDistFromBase
+ );
+
+ // Phase 4: Center contested zone
+ generateMinesInRegion(
+ positions,
+ centerX - 100,
+ centerX + 100,
+ minDistFromEdge,
+ mapHeight - minDistFromEdge,
+ centerMines,
+ basePositions,
+ minDistFromBase
+ );
+
+ return positions;
+}
+
+/**
+ * Generate mines in a rectangular region using grid distribution
+ */
+function generateMinesInRegion(
+ positions: Position[],
+ minX: number,
+ maxX: number,
+ minY: number,
+ maxY: number,
+ count: number,
+ basePositions: Position[],
+ minDistFromBase: number
+): void {
+ const effectiveWidth = maxX - minX;
+ const effectiveHeight = maxY - minY;
+
+ if (effectiveWidth <= 0 || effectiveHeight <= 0 || count <= 0) return;
+
+ const gridCols = Math.max(1, Math.ceil(Math.sqrt(count * effectiveWidth / effectiveHeight)));
+ const gridRows = Math.max(1, Math.ceil(count / gridCols));
+ const cellWidth = effectiveWidth / gridCols;
+ const cellHeight = effectiveHeight / gridRows;
+
+ // Shuffle cell indices for random distribution
+ const cellIndices: number[] = [];
+ for (let i = 0; i < gridCols * gridRows; i++) {
+ cellIndices.push(i);
+ }
+ for (let i = cellIndices.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [cellIndices[i], cellIndices[j]] = [cellIndices[j], cellIndices[i]];
+ }
+
+ let generated = 0;
+ const maxAttempts = 100;
+
+ for (const idx of cellIndices) {
+ if (generated >= count) break;
+
+ const col = idx % gridCols;
+ const row = Math.floor(idx / gridCols);
+
+ const margin = 20;
+ const baseX = minX + col * cellWidth;
+ const baseY = minY + row * cellHeight;
+
+ let attempts = 0;
+ let placed = false;
+
+ while (!placed && attempts < maxAttempts) {
+ const x = baseX + margin + Math.random() * Math.max(0, cellWidth - 2 * margin);
+ const y = baseY + margin + Math.random() * Math.max(0, cellHeight - 2 * margin);
+ attempts++;
+
+ // Check distance from bases
+ let tooCloseToBase = false;
+ for (const bp of basePositions) {
+ const dx = x - bp.x;
+ const dy = y - bp.y;
+ if (dx * dx + dy * dy < minDistFromBase * minDistFromBase) {
+ tooCloseToBase = true;
+ break;
+ }
+ }
+ if (tooCloseToBase) continue;
+
+ // Check distance from other mines
+ if (isTooCloseToExisting(x, y, positions)) continue;
+
+ positions.push({ x, y });
+ generated++;
+ placed = true;
+ }
+ }
+}
+
+function isInBounds(
+ x: number,
+ y: number,
+ mapWidth: number,
+ mapHeight: number,
+ margin: number
+): boolean {
+ return x >= margin && x <= mapWidth - margin && y >= margin && y <= mapHeight - margin;
+}
+
+function isTooCloseToExisting(x: number, y: number, positions: Position[]): boolean {
+ const minDist = MINE_GENERATION.minDistBetweenMines;
+ const minDistSq = minDist * minDist;
+ for (const pos of positions) {
+ const dx = x - pos.x;
+ const dy = y - pos.y;
+ if (dx * dx + dy * dy < minDistSq) return true;
+ }
+ return false;
+}
diff --git a/server/src/systems/mine/mineManager.ts b/server/src/systems/mine/mineManager.ts
new file mode 100644
index 0000000..dbc9d92
--- /dev/null
+++ b/server/src/systems/mine/mineManager.ts
@@ -0,0 +1,282 @@
+/**
+ * Mine Manager
+ * Handles mine state transitions: upgrade, repair, downgrade, sell, damage
+ */
+
+import type { GameState } from '../../schema/GameState.js';
+import { MineState } from '../../schema/MineState.js';
+import type { EnergyCalculator } from '../energy/energyCalculator.js';
+import type { TerritoryCalculator } from '../territory/territoryCalculator.js';
+import { MINE_CONFIG, MineStateType } from '../../../../shared/config/mineMeta.js';
+import { generateMinePositions } from './mineGenerator.js';
+
+interface ActionResult {
+ ok: boolean;
+ error?: string;
+ cost?: number;
+ refund?: number;
+}
+
+export class MineManager {
+ private state: GameState;
+ private energyCalc: EnergyCalculator;
+ private territoryCalc: TerritoryCalculator;
+
+ constructor(
+ state: GameState,
+ energyCalc: EnergyCalculator,
+ territoryCalc: TerritoryCalculator
+ ) {
+ this.state = state;
+ this.energyCalc = energyCalc;
+ this.territoryCalc = territoryCalc;
+ }
+
+ /**
+ * Generate and populate mines at game start
+ */
+ generateMines(basePositions: { x: number; y: number }[]): void {
+ const positions = generateMinePositions(
+ this.state.mapConfig.width,
+ this.state.mapConfig.height,
+ basePositions
+ );
+
+ for (const pos of positions) {
+ const mine = new MineState();
+ mine.id = this.state.generateEntityId('mine');
+ mine.setPosition(pos.x, pos.y);
+ mine.mineState = MineStateType.NORMAL;
+ mine.radius = MINE_CONFIG.normalRadius;
+ this.state.mines.set(mine.id, mine);
+ }
+
+ console.log(`[MineManager] Generated ${positions.length} mines`);
+ }
+
+ /**
+ * Upgrade a mine (normal→powerPlant lv1, or powerPlant lv+1)
+ */
+ upgradeMine(mineId: string, playerId: string): ActionResult {
+ const mine = this.state.mines.get(mineId);
+ if (!mine) return { ok: false, error: 'Mine not found' };
+
+ const player = this.state.getPlayer(playerId);
+ if (!player?.isAlive) return { ok: false, error: 'Player not alive' };
+
+ // Determine upgrade cost and validate state
+ let cost: number;
+ let newLevel: number;
+
+ if (mine.mineState === MineStateType.NORMAL) {
+ cost = MINE_CONFIG.upgradePrices[0];
+ newLevel = 1;
+ } else if (mine.mineState === MineStateType.POWER_PLANT) {
+ if (mine.level >= MINE_CONFIG.maxLevel) {
+ return { ok: false, error: 'Already max level' };
+ }
+ // Only owner can upgrade their own power plant
+ if (mine.ownerId !== playerId) {
+ return { ok: false, error: 'Not your power plant' };
+ }
+ cost = MINE_CONFIG.upgradePrices[mine.level];
+ newLevel = mine.level + 1;
+ } else {
+ return { ok: false, error: 'Cannot upgrade damaged mine' };
+ }
+
+ if (player.money < cost) {
+ return { ok: false, error: 'Insufficient funds' };
+ }
+
+ // Apply upgrade
+ player.money -= cost;
+ mine.mineState = MineStateType.POWER_PLANT;
+ mine.level = newLevel;
+ mine.hp = MINE_CONFIG.upgradeHp[newLevel - 1];
+ mine.maxHp = MINE_CONFIG.upgradeHp[newLevel - 1];
+ mine.ownerId = playerId;
+ mine.radius = MINE_CONFIG.powerPlantRadius;
+
+ this.energyCalc.markDirty();
+ this.territoryCalc.markDirty();
+
+ return { ok: true, cost };
+ }
+
+ /**
+ * Repair a damaged mine back to normal state
+ */
+ repairMine(mineId: string, playerId: string): ActionResult {
+ const mine = this.state.mines.get(mineId);
+ if (!mine) return { ok: false, error: 'Mine not found' };
+
+ const player = this.state.getPlayer(playerId);
+ if (!player?.isAlive) return { ok: false, error: 'Player not alive' };
+
+ if (mine.mineState !== MineStateType.DAMAGED) {
+ return { ok: false, error: 'Mine is not damaged' };
+ }
+ if (mine.repairing) {
+ return { ok: false, error: 'Already repairing' };
+ }
+ if (player.money < MINE_CONFIG.repairCost) {
+ return { ok: false, error: 'Insufficient funds' };
+ }
+
+ player.money -= MINE_CONFIG.repairCost;
+ mine.repairing = true;
+ mine.repairProgress = 0;
+ mine.ownerId = playerId;
+
+ return { ok: true, cost: MINE_CONFIG.repairCost };
+ }
+
+ /**
+ * Downgrade a power plant (lv3→lv2, lv2→lv1, lv1→normal)
+ */
+ downgradeMine(mineId: string, playerId: string): ActionResult {
+ const mine = this.state.mines.get(mineId);
+ if (!mine) return { ok: false, error: 'Mine not found' };
+
+ if (mine.mineState !== MineStateType.POWER_PLANT) {
+ return { ok: false, error: 'Not a power plant' };
+ }
+ if (mine.ownerId !== playerId) {
+ return { ok: false, error: 'Not your power plant' };
+ }
+
+ const refund = Math.floor(
+ MINE_CONFIG.upgradePrices[mine.level - 1] * MINE_CONFIG.downgradeRefundRatio
+ );
+
+ const player = this.state.getPlayer(playerId);
+ if (player) player.money += refund;
+
+ if (mine.level > 1) {
+ mine.level--;
+ mine.hp = MINE_CONFIG.upgradeHp[mine.level - 1];
+ mine.maxHp = MINE_CONFIG.upgradeHp[mine.level - 1];
+ } else {
+ // Level 1 → normal mine
+ mine.mineState = MineStateType.NORMAL;
+ mine.level = 0;
+ mine.hp = 0;
+ mine.maxHp = 0;
+ mine.ownerId = '';
+ mine.radius = MINE_CONFIG.normalRadius;
+ }
+
+ this.energyCalc.markDirty();
+ this.territoryCalc.markDirty();
+
+ return { ok: true, refund };
+ }
+
+ /**
+ * Sell a power plant (refund 50% of total invested)
+ */
+ sellMine(mineId: string, playerId: string): ActionResult {
+ const mine = this.state.mines.get(mineId);
+ if (!mine) return { ok: false, error: 'Mine not found' };
+
+ if (mine.mineState !== MineStateType.POWER_PLANT) {
+ return { ok: false, error: 'Not a power plant' };
+ }
+ if (mine.ownerId !== playerId) {
+ return { ok: false, error: 'Not your power plant' };
+ }
+
+ // Calculate total invested
+ let totalInvested = 0;
+ for (let i = 0; i < mine.level; i++) {
+ totalInvested += MINE_CONFIG.upgradePrices[i];
+ }
+ const refund = Math.floor(totalInvested * MINE_CONFIG.sellRefundRatio);
+
+ const player = this.state.getPlayer(playerId);
+ if (player) player.money += refund;
+
+ // Reset to normal mine
+ mine.mineState = MineStateType.NORMAL;
+ mine.level = 0;
+ mine.hp = 0;
+ mine.maxHp = 0;
+ mine.ownerId = '';
+ mine.radius = MINE_CONFIG.normalRadius;
+ mine.repairing = false;
+ mine.repairProgress = 0;
+
+ this.energyCalc.markDirty();
+ this.territoryCalc.markDirty();
+
+ return { ok: true, refund };
+ }
+
+ /**
+ * Apply damage to a mine (from monster melee).
+ * Returns true if the mine was destroyed (became damaged).
+ */
+ damageMine(mineId: string, damage: number, _sourceId: string): boolean {
+ const mine = this.state.mines.get(mineId);
+ if (!mine || mine.mineState !== MineStateType.POWER_PLANT) return false;
+
+ mine.hp -= damage;
+
+ if (mine.hp <= 0) {
+ // Power plant destroyed → damaged state
+ mine.mineState = MineStateType.DAMAGED;
+ mine.level = 0;
+ mine.hp = 0;
+ mine.maxHp = 0;
+ mine.radius = MINE_CONFIG.normalRadius;
+ mine.repairing = false;
+ mine.repairProgress = 0;
+ // Keep ownerId so client knows who lost it
+
+ this.energyCalc.markDirty();
+ this.territoryCalc.markDirty();
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Process repair progress each tick
+ */
+ updateRepairs(): void {
+ this.state.mines.forEach((mine) => {
+ if (!mine.repairing) return;
+
+ mine.repairProgress++;
+ if (mine.repairProgress >= MINE_CONFIG.repairTicks) {
+ mine.mineState = MineStateType.NORMAL;
+ mine.repairing = false;
+ mine.repairProgress = 0;
+ mine.ownerId = '';
+ mine.radius = MINE_CONFIG.normalRadius;
+ }
+ });
+ }
+
+ /**
+ * Sync mine production data to EnergyCalculator.
+ * Called after territory recalculation.
+ */
+ syncToEnergyCalc(validTerritoryIds: Map>): void {
+ // Clear all existing mines in energy calc and re-register
+ this.energyCalc.clearMines();
+
+ this.state.mines.forEach((mine) => {
+ if (mine.mineState !== MineStateType.POWER_PLANT || !mine.ownerId) return;
+
+ // Check if mine is in owner's valid territory
+ const playerValid = validTerritoryIds.get(mine.ownerId);
+ const inTerritory = playerValid?.has(mine.id) ?? false;
+ const production = inTerritory ? mine.level * MINE_CONFIG.productionPerLevel : 0;
+
+ this.energyCalc.registerMine(mine.id, mine.ownerId, production);
+ });
+ }
+}
diff --git a/server/src/systems/spatial/index.ts b/server/src/systems/spatial/index.ts
new file mode 100644
index 0000000..af2f276
--- /dev/null
+++ b/server/src/systems/spatial/index.ts
@@ -0,0 +1,4 @@
+/**
+ * Spatial Systems
+ */
+export { SpatialHashGrid, type SpatialEntity } from './spatialHashGrid.js';
diff --git a/server/src/systems/spatial/spatialHashGrid.ts b/server/src/systems/spatial/spatialHashGrid.ts
new file mode 100644
index 0000000..f9d649b
--- /dev/null
+++ b/server/src/systems/spatial/spatialHashGrid.ts
@@ -0,0 +1,237 @@
+/**
+ * SpatialHashGrid - Spatial partitioning for fast range queries
+ * Optimized for frequently moving objects (monsters, bullets)
+ * Server-side version adapted for Colyseus schema entities
+ */
+
+/**
+ * Interface for objects that can be stored in the spatial grid
+ */
+export interface SpatialEntity {
+ position: { x: number; y: number };
+ radius: number;
+ id: string;
+}
+
+/**
+ * Internal tracking for grid cell membership
+ */
+interface GridTracking {
+ cells: Set;
+}
+
+export class SpatialHashGrid {
+ private cellSize: number;
+ private invCellSize: number;
+ private cols: number;
+ private cells: Map> = new Map();
+ // Offset to handle negative coordinates
+ private offsetX: number;
+ private offsetY: number;
+
+ // Track which cells each entity is in
+ private entityCells: Map = new Map();
+
+ // Set pool to reduce GC pressure
+ private setPool: Set[] = [];
+
+ constructor(width: number, height: number, cellSize: number = 64) {
+ this.cellSize = cellSize;
+ this.invCellSize = 1 / cellSize;
+ // Support negative coordinates: margin based on max spawn radius
+ const margin = Math.ceil((Math.max(width, height) * 0.7) / cellSize);
+ this.offsetX = margin;
+ this.offsetY = margin;
+ // Total columns = world cells + margin on both sides
+ this.cols = Math.ceil(width / cellSize) + margin * 2;
+ }
+
+ /**
+ * Compute hash for cell coordinates with offset
+ */
+ private hash(cx: number, cy: number): number {
+ return (cy + this.offsetY) * this.cols + (cx + this.offsetX);
+ }
+
+ /**
+ * Acquire a Set from pool or create new one
+ */
+ private acquireSet(): Set {
+ return this.setPool.pop() || new Set();
+ }
+
+ /**
+ * Release a Set back to pool
+ */
+ private releaseSet(set: Set): void {
+ set.clear();
+ this.setPool.push(set);
+ }
+
+ /**
+ * Insert object into grid
+ */
+ insert(obj: T): void {
+ const cells = this.acquireSet();
+ this.fillCells(obj, cells);
+ this.entityCells.set(obj.id, { cells });
+
+ for (const cell of cells) {
+ this.getBucket(cell).add(obj);
+ }
+ }
+
+ /**
+ * Remove object from grid
+ */
+ remove(obj: T): void {
+ const tracking = this.entityCells.get(obj.id);
+ if (tracking) {
+ for (const cell of tracking.cells) {
+ this.cells.get(cell)?.delete(obj);
+ }
+ this.releaseSet(tracking.cells);
+ this.entityCells.delete(obj.id);
+ }
+ }
+
+ /**
+ * Update object position in grid
+ */
+ update(obj: T): void {
+ const newCells = this.acquireSet();
+ this.fillCells(obj, newCells);
+
+ const tracking = this.entityCells.get(obj.id);
+
+ if (!tracking) {
+ // First insert
+ this.entityCells.set(obj.id, { cells: newCells });
+ for (const cell of newCells) {
+ this.getBucket(cell).add(obj);
+ }
+ return;
+ }
+
+ const oldCells = tracking.cells;
+
+ // Incremental update: only process changed cells
+ for (const cell of oldCells) {
+ if (!newCells.has(cell)) {
+ this.cells.get(cell)?.delete(obj);
+ }
+ }
+ for (const cell of newCells) {
+ if (!oldCells.has(cell)) {
+ this.getBucket(cell).add(obj);
+ }
+ }
+
+ this.releaseSet(oldCells);
+ tracking.cells = newCells;
+ }
+
+ /**
+ * Batch update all objects
+ */
+ updateAll(objects: Iterable): void {
+ for (const obj of objects) {
+ this.update(obj);
+ }
+ }
+
+ /**
+ * Query objects in circular range
+ */
+ queryRange(x: number, y: number, radius: number): T[] {
+ const result: T[] = [];
+ const seen = new Set();
+
+ const minCX = Math.floor((x - radius) * this.invCellSize);
+ const maxCX = Math.floor((x + radius) * this.invCellSize);
+ const minCY = Math.floor((y - radius) * this.invCellSize);
+ const maxCY = Math.floor((y + radius) * this.invCellSize);
+
+ for (let cy = minCY; cy <= maxCY; cy++) {
+ for (let cx = minCX; cx <= maxCX; cx++) {
+ const hash = this.hash(cx, cy);
+ const bucket = this.cells.get(hash);
+ if (bucket) {
+ for (const obj of bucket) {
+ if (!seen.has(obj.id)) {
+ seen.add(obj.id);
+ result.push(obj);
+ }
+ }
+ }
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Query objects in rectangular range
+ */
+ queryRect(minX: number, minY: number, maxX: number, maxY: number): T[] {
+ const result: T[] = [];
+ const seen = new Set();
+
+ const minCX = Math.floor(minX * this.invCellSize);
+ const maxCX = Math.floor(maxX * this.invCellSize);
+ const minCY = Math.floor(minY * this.invCellSize);
+ const maxCY = Math.floor(maxY * this.invCellSize);
+
+ for (let cy = minCY; cy <= maxCY; cy++) {
+ for (let cx = minCX; cx <= maxCX; cx++) {
+ const hash = this.hash(cx, cy);
+ const bucket = this.cells.get(hash);
+ if (bucket) {
+ for (const obj of bucket) {
+ if (!seen.has(obj.id)) {
+ seen.add(obj.id);
+ result.push(obj);
+ }
+ }
+ }
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Clear the grid
+ */
+ clear(): void {
+ this.cells.clear();
+ this.entityCells.clear();
+ }
+
+ /**
+ * Fill cells that an object occupies into the provided Set
+ */
+ private fillCells(obj: T, result: Set): void {
+ const r = obj.radius;
+ const minCX = Math.floor((obj.position.x - r) * this.invCellSize);
+ const maxCX = Math.floor((obj.position.x + r) * this.invCellSize);
+ const minCY = Math.floor((obj.position.y - r) * this.invCellSize);
+ const maxCY = Math.floor((obj.position.y + r) * this.invCellSize);
+
+ for (let cy = minCY; cy <= maxCY; cy++) {
+ for (let cx = minCX; cx <= maxCX; cx++) {
+ result.add(this.hash(cx, cy));
+ }
+ }
+ }
+
+ /**
+ * Get or create bucket for a cell
+ */
+ private getBucket(hash: number): Set {
+ let bucket = this.cells.get(hash);
+ if (!bucket) {
+ bucket = new Set();
+ this.cells.set(hash, bucket);
+ }
+ return bucket;
+ }
+}
diff --git a/server/src/systems/territory/index.ts b/server/src/systems/territory/index.ts
new file mode 100644
index 0000000..0c985f3
--- /dev/null
+++ b/server/src/systems/territory/index.ts
@@ -0,0 +1,4 @@
+/**
+ * Territory System
+ */
+export { TerritoryCalculator, type TerritoryConfig, type TerritoryResult } from './territoryCalculator.js';
diff --git a/server/src/systems/territory/territoryCalculator.ts b/server/src/systems/territory/territoryCalculator.ts
new file mode 100644
index 0000000..98e860d
--- /dev/null
+++ b/server/src/systems/territory/territoryCalculator.ts
@@ -0,0 +1,283 @@
+/**
+ * Territory Calculator - Server-side territory connectivity calculation
+ * Uses BFS to determine which buildings are connected to the base
+ *
+ * Port of src/systems/territory/territory.ts for server use
+ */
+
+import type { BuildingState } from '../../schema/BuildingState.js';
+import type { TowerState } from '../../schema/TowerState.js';
+import type { MineState } from '../../schema/MineState.js';
+import type { MapSchema } from '@colyseus/schema';
+import { distSq } from '../../shared/math/vector.js';
+import { MineStateType } from '../../../../shared/config/mineMeta.js';
+
+/**
+ * Configuration for territory calculation
+ */
+export interface TerritoryConfig {
+ territoryRadius: number; // Territory radius in pixels (default 100)
+}
+
+/**
+ * Entity that can be in territory
+ */
+interface TerritoryEntity {
+ id: string;
+ ownerId: string;
+ position: { x: number; y: number };
+ isBase?: boolean;
+ // Territory flags
+ inValidTerritory?: boolean;
+}
+
+/**
+ * Result of territory calculation for a player
+ */
+export interface TerritoryResult {
+ validBuildings: Set; // IDs of buildings in valid territory
+ invalidBuildings: Set; // IDs of buildings in invalid territory
+}
+
+const DEFAULT_CONFIG: TerritoryConfig = {
+ territoryRadius: 100,
+};
+
+/**
+ * Calculate territory connectivity using BFS
+ * Determines which buildings are connected to the player's base
+ */
+export class TerritoryCalculator {
+ private config: TerritoryConfig;
+ private territoryRadiusSq: number;
+ private connectionRadiusSq: number;
+
+ // Cache per player
+ private playerResults: Map = new Map();
+ private dirty: boolean = true;
+
+ constructor(config: Partial = {}) {
+ this.config = { ...DEFAULT_CONFIG, ...config };
+ this.territoryRadiusSq = this.config.territoryRadius ** 2;
+ // Two buildings connect if distance <= 2 * territoryRadius
+ this.connectionRadiusSq = (this.config.territoryRadius * 2) ** 2;
+ }
+
+ /**
+ * Mark territory as needing recalculation
+ */
+ markDirty(): void {
+ this.dirty = true;
+ }
+
+ /**
+ * Check if dirty
+ */
+ isDirty(): boolean {
+ return this.dirty;
+ }
+
+ /**
+ * Recalculate territory for all players
+ */
+ recalculate(
+ buildings: MapSchema,
+ towers: MapSchema,
+ mines?: MapSchema
+ ): Map {
+ if (!this.dirty) return this.playerResults;
+ this.dirty = false;
+
+ this.playerResults.clear();
+
+ // Group entities by owner
+ const playerEntities = new Map();
+ const playerBases = new Map();
+
+ // Process buildings
+ buildings.forEach((building) => {
+ if (!building.ownerId) return;
+
+ const entity: TerritoryEntity = {
+ id: building.id,
+ ownerId: building.ownerId,
+ position: { x: building.position.x, y: building.position.y },
+ isBase: building.isBase,
+ };
+
+ if (building.isBase) {
+ playerBases.set(building.ownerId, entity);
+ }
+
+ if (!playerEntities.has(building.ownerId)) {
+ playerEntities.set(building.ownerId, []);
+ }
+ playerEntities.get(building.ownerId)!.push(entity);
+ });
+
+ // Process towers
+ towers.forEach((tower) => {
+ if (!tower.ownerId) return;
+
+ const entity: TerritoryEntity = {
+ id: tower.id,
+ ownerId: tower.ownerId,
+ position: { x: tower.position.x, y: tower.position.y },
+ isBase: false,
+ };
+
+ if (!playerEntities.has(tower.ownerId)) {
+ playerEntities.set(tower.ownerId, []);
+ }
+ playerEntities.get(tower.ownerId)!.push(entity);
+ });
+
+ // Process mines (only powerPlant state participates in territory)
+ if (mines) {
+ mines.forEach((mine) => {
+ if (!mine.ownerId || mine.mineState !== MineStateType.POWER_PLANT) return;
+
+ const entity: TerritoryEntity = {
+ id: mine.id,
+ ownerId: mine.ownerId,
+ position: { x: mine.position.x, y: mine.position.y },
+ isBase: false,
+ };
+
+ if (!playerEntities.has(mine.ownerId)) {
+ playerEntities.set(mine.ownerId, []);
+ }
+ playerEntities.get(mine.ownerId)!.push(entity);
+ });
+ }
+
+ // Calculate territory for each player
+ for (const [playerId, entities] of playerEntities) {
+ const base = playerBases.get(playerId);
+ if (!base) {
+ // No base, all buildings invalid
+ this.playerResults.set(playerId, {
+ validBuildings: new Set(),
+ invalidBuildings: new Set(entities.map((e) => e.id)),
+ });
+ continue;
+ }
+
+ const result = this.calculatePlayerTerritory(base, entities);
+ this.playerResults.set(playerId, result);
+ }
+
+ return this.playerResults;
+ }
+
+ /**
+ * Calculate territory for a single player using BFS
+ */
+ private calculatePlayerTerritory(
+ base: TerritoryEntity,
+ entities: TerritoryEntity[]
+ ): TerritoryResult {
+ const visited = new Set();
+ const queue: TerritoryEntity[] = [base];
+ let queueIndex = 0;
+ visited.add(base.id);
+
+ // BFS from base to find all connected territory providers
+ while (queueIndex < queue.length) {
+ const current = queue[queueIndex++];
+
+ for (const other of entities) {
+ if (visited.has(other.id)) continue;
+
+ // Check if within connection range (2 * territoryRadius)
+ const dist = distSq(current.position, other.position);
+ if (dist <= this.connectionRadiusSq) {
+ visited.add(other.id);
+ queue.push(other);
+ }
+ }
+ }
+
+ // Separate valid and invalid
+ const validBuildings = new Set();
+ const invalidBuildings = new Set();
+
+ for (const entity of entities) {
+ if (visited.has(entity.id)) {
+ validBuildings.add(entity.id);
+ } else {
+ invalidBuildings.add(entity.id);
+ }
+ }
+
+ return { validBuildings, invalidBuildings };
+ }
+
+ /**
+ * Get territory result for a specific player
+ */
+ getPlayerResult(playerId: string): TerritoryResult | undefined {
+ return this.playerResults.get(playerId);
+ }
+
+ /**
+ * Check if a building/tower is in valid territory
+ */
+ isInValidTerritory(entityId: string, ownerId: string): boolean {
+ const result = this.playerResults.get(ownerId);
+ if (!result) return false;
+ return result.validBuildings.has(entityId);
+ }
+
+ /**
+ * Check if a position is within valid territory of a player
+ * Used for placing new buildings
+ */
+ isPositionInValidTerritory(
+ x: number,
+ y: number,
+ playerId: string,
+ buildings: MapSchema,
+ towers: MapSchema
+ ): boolean {
+ const result = this.playerResults.get(playerId);
+ if (!result) return false;
+
+ const pos = { x, y };
+
+ // Check buildings
+ for (const building of buildings.values()) {
+ if (building.ownerId !== playerId) continue;
+ if (!result.validBuildings.has(building.id)) continue;
+
+ const dist = distSq(pos, { x: building.position.x, y: building.position.y });
+ if (dist <= this.territoryRadiusSq) {
+ return true;
+ }
+ }
+
+ // Check towers
+ for (const tower of towers.values()) {
+ if (tower.ownerId !== playerId) continue;
+ if (!result.validBuildings.has(tower.id)) continue;
+
+ const dist = distSq(pos, { x: tower.position.x, y: tower.position.y });
+ if (dist <= this.territoryRadiusSq) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the territory multiplier for an entity
+ * Returns 1.0 for valid territory, 0.33 for invalid
+ */
+ getTerritoryMultiplier(entityId: string, ownerId: string): number {
+ if (this.isInValidTerritory(entityId, ownerId)) {
+ return 1.0;
+ }
+ return 1 / 3;
+ }
+}
diff --git a/server/src/systems/vision/broadcastFilter.ts b/server/src/systems/vision/broadcastFilter.ts
new file mode 100644
index 0000000..c22bab0
--- /dev/null
+++ b/server/src/systems/vision/broadcastFilter.ts
@@ -0,0 +1,57 @@
+/**
+ * Broadcast Filter Utilities
+ * Send messages only to players who can see the relevant position.
+ */
+
+import type { Room, Client } from '@colyseus/core';
+import type { VisionSystem } from './visionSystem.js';
+
+/**
+ * Send a message only to clients who can see the given position.
+ * Falls back to broadcast if visionSystem is not available.
+ */
+export function sendToVisible(
+ room: Room,
+ visionSystem: VisionSystem | null,
+ messageType: string,
+ payload: unknown,
+ x: number,
+ y: number,
+): void {
+ if (!visionSystem) {
+ room.broadcast(messageType, payload);
+ return;
+ }
+
+ room.clients.forEach((client: Client) => {
+ if (visionSystem.isPositionVisible(client.sessionId, x, y)) {
+ client.send(messageType, payload);
+ }
+ });
+}
+
+/**
+ * Send a message to the entity owner (always) and other clients who can see the position.
+ * Used for events like MONSTER_KILLED where the owner needs the kill credit info.
+ */
+export function sendToEntityOwnerAndVisible(
+ room: Room,
+ visionSystem: VisionSystem | null,
+ messageType: string,
+ payload: unknown,
+ entityOwnerId: string | null,
+ x: number,
+ y: number,
+): void {
+ if (!visionSystem) {
+ room.broadcast(messageType, payload);
+ return;
+ }
+
+ room.clients.forEach((client: Client) => {
+ if (client.sessionId === entityOwnerId ||
+ visionSystem.isPositionVisible(client.sessionId, x, y)) {
+ client.send(messageType, payload);
+ }
+ });
+}
diff --git a/server/src/systems/vision/index.ts b/server/src/systems/vision/index.ts
new file mode 100644
index 0000000..1fcb7e5
--- /dev/null
+++ b/server/src/systems/vision/index.ts
@@ -0,0 +1,9 @@
+/**
+ * Vision System Module
+ * Server-side fog of war: visibility calculation and broadcast filtering.
+ */
+
+export { VisionSystem } from './visionSystem.js';
+export { VisibilityMap } from './visibilityMap.js';
+export { sendToVisible, sendToEntityOwnerAndVisible } from './broadcastFilter.js';
+export type { VisibleEntity, VisionTower, VisionBuilding } from './visibilityMap.js';
diff --git a/server/src/systems/vision/visibilityMap.ts b/server/src/systems/vision/visibilityMap.ts
new file mode 100644
index 0000000..8eaa0e8
--- /dev/null
+++ b/server/src/systems/vision/visibilityMap.ts
@@ -0,0 +1,227 @@
+/**
+ * VisibilityMap - Per-player visibility state
+ * Tracks which entities are visible to a specific player based on vision sources.
+ */
+
+import {
+ VisionType,
+ HEADQUARTERS_VISION,
+ BASIC_TOWER_VISION,
+ RADAR_SWEEP_ANGLE,
+ RADAR_SWEEP_SPEED,
+ getVisionRadius,
+} from '../../../shared/config/visionMeta.js';
+
+/** Circular vision source */
+interface VisionCircle {
+ x: number;
+ y: number;
+ radius: number;
+ radiusSq: number; // Pre-computed for fast distance check
+}
+
+/** Radar sweep source (sector-based) */
+interface RadarSource {
+ x: number;
+ y: number;
+ radius: number;
+ radiusSq: number;
+ sweepAngle: number;
+ baseCircleRadiusSq: number; // Basic tower vision as a circle
+}
+
+/** Minimal entity interface for visibility checks */
+export interface VisibleEntity {
+ id: string;
+ ownerId: string | null;
+ x: number;
+ y: number;
+ radius?: number;
+}
+
+/** Minimal tower interface for building vision sources */
+export interface VisionTower {
+ id: string;
+ ownerId: string;
+ x: number;
+ y: number;
+ visionType: string;
+ visionLevel: number;
+}
+
+/** Minimal building interface for building vision sources */
+export interface VisionBuilding {
+ id: string;
+ ownerId: string;
+ x: number;
+ y: number;
+ isBase: boolean;
+}
+
+const HYSTERESIS_MARGIN = 30;
+
+export class VisibilityMap {
+ readonly playerId: string;
+
+ private _visibleEntities: Set = new Set();
+ private _visionCircles: VisionCircle[] = [];
+ private _radarSources: RadarSource[] = [];
+ private _dirty = true;
+
+ constructor(playerId: string) {
+ this.playerId = playerId;
+ }
+
+ /** Mark that vision sources need rebuilding */
+ markDirty(): void {
+ this._dirty = true;
+ }
+
+ /** Check if an entity is currently visible (O(1)) */
+ isVisible(entityId: string): boolean {
+ return this._visibleEntities.has(entityId);
+ }
+
+ /**
+ * Rebuild vision source lists from player's buildings and towers.
+ * Call this when towers/buildings change (build, sell, upgrade, destroy).
+ */
+ rebuildVisionSources(buildings: VisionBuilding[], towers: VisionTower[]): void {
+ this._visionCircles.length = 0;
+ this._radarSources.length = 0;
+
+ // Buildings: headquarters provide large vision
+ for (const b of buildings) {
+ if (b.ownerId !== this.playerId) continue;
+ if (b.isBase) {
+ const r = HEADQUARTERS_VISION;
+ this._visionCircles.push({ x: b.x, y: b.y, radius: r, radiusSq: r * r });
+ }
+ }
+
+ // Towers: basic vision + special vision types
+ for (const t of towers) {
+ if (t.ownerId !== this.playerId) continue;
+
+ // All own towers provide basic vision
+ const basicR = BASIC_TOWER_VISION;
+ this._visionCircles.push({ x: t.x, y: t.y, radius: basicR, radiusSq: basicR * basicR });
+
+ if (t.visionType === VisionType.OBSERVER && t.visionLevel > 0) {
+ // Observer: larger static circle (replaces basic)
+ const obsR = getVisionRadius(VisionType.OBSERVER, t.visionLevel);
+ if (obsR > basicR) {
+ // Replace the basic circle with the larger observer circle
+ const last = this._visionCircles[this._visionCircles.length - 1];
+ last.radius = obsR;
+ last.radiusSq = obsR * obsR;
+ }
+ } else if (t.visionType === VisionType.RADAR && t.visionLevel > 0) {
+ // Radar: sweep sector with large radius
+ const radarR = getVisionRadius(VisionType.RADAR, t.visionLevel);
+ this._radarSources.push({
+ x: t.x,
+ y: t.y,
+ radius: radarR,
+ radiusSq: radarR * radarR,
+ sweepAngle: RADAR_SWEEP_ANGLE,
+ baseCircleRadiusSq: basicR * basicR,
+ });
+ }
+ }
+
+ this._dirty = false;
+ }
+
+ /**
+ * Recalculate visibility for all non-own entities.
+ * Returns the set of entity IDs whose visibility changed.
+ */
+ recalculate(entities: VisibleEntity[], currentTick: number): Set {
+ const changed = new Set();
+ const newVisible = new Set();
+
+ for (const entity of entities) {
+ // Own entities are always visible (handled by @filterChildren callback)
+ if (entity.ownerId === this.playerId) continue;
+
+ const wasVisible = this._visibleEntities.has(entity.id);
+ const margin = wasVisible ? HYSTERESIS_MARGIN : 0;
+ const isVisible = this._checkVisibility(entity.x, entity.y, entity.radius || 0, margin, currentTick);
+
+ if (isVisible) {
+ newVisible.add(entity.id);
+ }
+
+ if (isVisible !== wasVisible) {
+ changed.add(entity.id);
+ }
+ }
+
+ this._visibleEntities = newVisible;
+ return changed;
+ }
+
+ /**
+ * Check if a world position is visible to this player.
+ * Used for broadcast filtering.
+ */
+ isPositionVisible(x: number, y: number, currentTick: number): boolean {
+ return this._checkVisibility(x, y, 0, 0, currentTick);
+ }
+
+ get isDirty(): boolean {
+ return this._dirty;
+ }
+
+ // --- Private ---
+
+ private _checkVisibility(
+ x: number, y: number, entityRadius: number, margin: number, currentTick: number
+ ): boolean {
+ // Check static vision circles
+ for (const circle of this._visionCircles) {
+ const dx = x - circle.x;
+ const dy = y - circle.y;
+ const distSq = dx * dx + dy * dy;
+ const threshold = circle.radius + entityRadius + margin;
+ if (distSq <= threshold * threshold) {
+ return true;
+ }
+ }
+
+ // Check radar sweep sectors
+ for (const radar of this._radarSources) {
+ const dx = x - radar.x;
+ const dy = y - radar.y;
+ const distSq = dx * dx + dy * dy;
+
+ // Radar towers also have basic vision circle (already included in _visionCircles)
+ // Here we check the sweep sector only
+ const threshold = radar.radius + entityRadius + margin;
+ if (distSq > threshold * threshold) continue;
+
+ // Check if entity is within the sweep sector
+ const currentAngle = (RADAR_SWEEP_SPEED * currentTick) % (Math.PI * 2);
+ const entityAngle = Math.atan2(dy, dx);
+
+ if (isInSweepSector(entityAngle, currentAngle, radar.sweepAngle)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
+
+/**
+ * Check if an angle is within a sweep sector.
+ * Handles wrap-around at ±PI.
+ */
+function isInSweepSector(entityAngle: number, sweepCenter: number, sweepWidth: number): boolean {
+ let diff = entityAngle - sweepCenter;
+ // Normalize to [-PI, PI]
+ while (diff > Math.PI) diff -= Math.PI * 2;
+ while (diff < -Math.PI) diff += Math.PI * 2;
+ return Math.abs(diff) <= sweepWidth / 2;
+}
diff --git a/server/src/systems/vision/visionSystem.ts b/server/src/systems/vision/visionSystem.ts
new file mode 100644
index 0000000..2959b57
--- /dev/null
+++ b/server/src/systems/vision/visionSystem.ts
@@ -0,0 +1,172 @@
+/**
+ * VisionSystem - Coordinates vision calculations for all players
+ * Main entry point for server-side fog of war.
+ */
+
+import {
+ VisibilityMap,
+ type VisibleEntity,
+ type VisionTower,
+ type VisionBuilding,
+} from './visibilityMap.js';
+import type { TowerState } from '../../schema/TowerState.js';
+import type { MonsterState } from '../../schema/MonsterState.js';
+import type { BuildingState } from '../../schema/BuildingState.js';
+import type { MineState } from '../../schema/MineState.js';
+import type { MapSchema } from '@colyseus/schema';
+
+/** Recalculate vision every N ticks to reduce CPU cost */
+const RECALC_INTERVAL = 3;
+
+/** Reusable empty set for throttled recalculate returns */
+const EMPTY_SET: Set = new Set();
+
+export class VisionSystem {
+ private _maps: Map = new Map();
+ private _dirty = true;
+ private _tickCounter = 0;
+
+ // Reusable arrays to reduce per-tick allocations (H5)
+ private _entityBuffer: VisibleEntity[] = [];
+ private _towerBuffer: VisionTower[] = [];
+ private _buildingBuffer: VisionBuilding[] = [];
+
+ addPlayer(playerId: string): void {
+ this._maps.set(playerId, new VisibilityMap(playerId));
+ this._dirty = true;
+ }
+
+ removePlayer(playerId: string): void {
+ this._maps.delete(playerId);
+ }
+
+ /**
+ * Mark vision sources as dirty (call when towers/buildings change).
+ * Next recalculate() will rebuild vision sources before checking visibility.
+ */
+ markDirty(): void {
+ this._dirty = true;
+ }
+
+ /**
+ * Check if an entity is visible to a specific player.
+ * Called by @filterChildren callback - must be O(1).
+ */
+ isEntityVisible(playerId: string, entityId: string, entityOwnerId: string | null): boolean {
+ // Own entities are always visible
+ if (entityOwnerId === playerId) return true;
+
+ const map = this._maps.get(playerId);
+ if (!map) return false; // Fallback: hidden if player map not found
+ return map.isVisible(entityId);
+ }
+
+ /**
+ * Check if a world position is visible to a specific player.
+ * Used for broadcast filtering.
+ */
+ isPositionVisible(playerId: string, x: number, y: number): boolean {
+ const map = this._maps.get(playerId);
+ if (!map) return false;
+ return map.isPositionVisible(x, y, this._tickCounter);
+ }
+
+ /**
+ * Recalculate visibility for all players.
+ * Throttled to every RECALC_INTERVAL ticks.
+ * Returns set of entity IDs whose visibility changed (for touch mechanism).
+ */
+ recalculate(
+ currentTick: number,
+ towers: MapSchema,
+ monsters: MapSchema,
+ buildings: MapSchema,
+ mines: MapSchema,
+ ): Set {
+ this._tickCounter = currentTick;
+
+ // Throttle recalculation
+ if (currentTick % RECALC_INTERVAL !== 0) {
+ return EMPTY_SET;
+ }
+
+ // Rebuild vision sources if dirty
+ if (this._dirty) {
+ const towerList = this._extractTowers(towers);
+ const buildingList = this._extractBuildings(buildings);
+ for (const map of this._maps.values()) {
+ map.rebuildVisionSources(buildingList, towerList);
+ }
+ this._dirty = false;
+ }
+
+ // Collect all entities for visibility checks
+ const allEntities = this._collectEntities(towers, monsters, buildings, mines);
+
+ // Recalculate for each player, collect all changed entity IDs
+ const allChanged = new Set();
+ for (const map of this._maps.values()) {
+ const changed = map.recalculate(allEntities, currentTick);
+ for (const id of changed) {
+ allChanged.add(id);
+ }
+ }
+
+ return allChanged;
+ }
+
+ // --- Private helpers ---
+
+ private _extractTowers(towers: MapSchema): VisionTower[] {
+ this._towerBuffer.length = 0;
+ towers.forEach((tower, _key) => {
+ this._towerBuffer.push({
+ id: tower.id,
+ ownerId: tower.ownerId,
+ x: tower.position.x,
+ y: tower.position.y,
+ visionType: tower.visionType,
+ visionLevel: tower.visionLevel,
+ });
+ });
+ return this._towerBuffer;
+ }
+
+ private _extractBuildings(buildings: MapSchema): VisionBuilding[] {
+ this._buildingBuffer.length = 0;
+ buildings.forEach((building, _key) => {
+ this._buildingBuffer.push({
+ id: building.id,
+ ownerId: building.ownerId,
+ x: building.position.x,
+ y: building.position.y,
+ isBase: building.isBase,
+ });
+ });
+ return this._buildingBuffer;
+ }
+
+ private _collectEntities(
+ towers: MapSchema,
+ monsters: MapSchema,
+ buildings: MapSchema,
+ mines: MapSchema,
+ ): VisibleEntity[] {
+ this._entityBuffer.length = 0;
+
+ towers.forEach((t, _key) => {
+ this._entityBuffer.push({ id: t.id, ownerId: t.ownerId, x: t.position.x, y: t.position.y, radius: t.radius });
+ });
+ monsters.forEach((m, _key) => {
+ this._entityBuffer.push({ id: m.id, ownerId: m.ownerId, x: m.position.x, y: m.position.y, radius: m.radius });
+ });
+ buildings.forEach((b, _key) => {
+ this._entityBuffer.push({ id: b.id, ownerId: b.ownerId, x: b.position.x, y: b.position.y, radius: b.radius });
+ });
+ mines.forEach((mine, _key) => {
+ this._entityBuffer.push({ id: mine.id, ownerId: mine.ownerId, x: mine.position.x, y: mine.position.y, radius: mine.radius });
+ });
+
+ return this._entityBuffer;
+ }
+}
diff --git a/server/src/validation/collisionValidator.ts b/server/src/validation/collisionValidator.ts
new file mode 100644
index 0000000..8482015
--- /dev/null
+++ b/server/src/validation/collisionValidator.ts
@@ -0,0 +1,12 @@
+/**
+ * Collision Validator - Re-exports shared collision detection for server use.
+ *
+ * All collision logic lives in shared/validation/towerValidation.ts
+ * to ensure client and server use identical algorithms and constants.
+ */
+
+export {
+ hasCollision,
+ checkBuildCollision,
+ MIN_BUILD_DISTANCE,
+} from '../shared/validation/index.js';
diff --git a/server/src/validation/index.ts b/server/src/validation/index.ts
new file mode 100644
index 0000000..2e25aca
--- /dev/null
+++ b/server/src/validation/index.ts
@@ -0,0 +1,11 @@
+/**
+ * Server Validation Module
+ */
+export {
+ InputValidator,
+ TowerMetaRegistry,
+ SpawnableMonsterRegistry,
+ BuildingMetaRegistry,
+} from './inputValidator.js';
+
+export { checkBuildCollision, hasCollision, MIN_BUILD_DISTANCE } from './collisionValidator.js';
diff --git a/server/src/validation/inputValidator.ts b/server/src/validation/inputValidator.ts
new file mode 100644
index 0000000..5581cac
--- /dev/null
+++ b/server/src/validation/inputValidator.ts
@@ -0,0 +1,473 @@
+/**
+ * InputValidator - Server-side authoritative validation for all player actions
+ *
+ * Uses shared validation pure functions + server-specific checks
+ * (territory, collision, real-time resource deduction)
+ */
+
+import type { GameState } from '../schema/GameState.js';
+import type { TowerState } from '../schema/TowerState.js';
+import type { BuildingState } from '../schema/BuildingState.js';
+import type { MapSchema } from '@colyseus/schema';
+import type { TerritoryCalculator } from '../systems/territory/territoryCalculator.js';
+import { PVP_CONFIG } from '../config.js';
+import {
+ type ValidationResult,
+ ValidationErrorCode,
+ validationSuccess,
+ validationFailure,
+ validateBuildTowerBasic,
+ validateUpgradeTower,
+ validateSellTower,
+ validateCannonFire,
+ validateSpawnMonster,
+ type TowerMetaData,
+ type PlayerValidationState,
+ type TowerValidationState,
+ type SpawnableMonsterConfig,
+ type SpawnerValidationState,
+} from '../shared/validation/index.js';
+import { checkBuildCollision } from './collisionValidator.js';
+
+/**
+ * Tower metadata registry for server-side validation
+ * Populated from shared config data
+ */
+export class TowerMetaRegistry {
+ private metas: Map = new Map();
+
+ register(id: string, price: number, levelUpArr: string[]): void {
+ this.metas.set(id, { id, price, levelUpArr });
+ }
+
+ get(id: string): TowerMetaData | undefined {
+ return this.metas.get(id);
+ }
+
+ has(id: string): boolean {
+ return this.metas.has(id);
+ }
+}
+
+/**
+ * Spawnable monster config registry
+ */
+export class SpawnableMonsterRegistry {
+ private configs: Map = new Map();
+
+ register(config: SpawnableMonsterConfig): void {
+ this.configs.set(config.monsterId, config);
+ }
+
+ get(monsterId: string): SpawnableMonsterConfig | undefined {
+ return this.configs.get(monsterId);
+ }
+}
+
+/**
+ * Building metadata registry for server-side validation
+ */
+export class BuildingMetaRegistry {
+ private metas: Map = new Map();
+
+ register(id: string, price: number, radius: number, hp: number): void {
+ this.metas.set(id, { id, price, radius, hp });
+ }
+
+ get(id: string): { id: string; price: number; radius: number; hp: number } | undefined {
+ return this.metas.get(id);
+ }
+
+ has(id: string): boolean {
+ return this.metas.has(id);
+ }
+}
+
+/**
+ * Adapt PlayerState to PlayerValidationState interface
+ */
+function toPlayerValidation(
+ player: { id: string; isAlive: boolean; money: number } | undefined
+): PlayerValidationState | undefined {
+ if (!player) return undefined;
+ return { id: player.id, isAlive: player.isAlive, money: player.money };
+}
+
+/**
+ * Adapt TowerState to TowerValidationState interface
+ */
+function toTowerValidation(
+ tower: TowerState | undefined
+): TowerValidationState | undefined {
+ if (!tower) return undefined;
+ return {
+ id: tower.id,
+ ownerId: tower.ownerId,
+ towerType: tower.towerType,
+ position: { x: tower.position.x, y: tower.position.y },
+ radius: tower.radius,
+ attackRadius: tower.attackRadius,
+ isManual: tower.isManual,
+ currentAmmo: tower.currentAmmo,
+ };
+}
+
+/**
+ * Adapt BuildingState to SpawnerValidationState interface
+ */
+function toSpawnerValidation(
+ building: BuildingState | undefined
+): SpawnerValidationState | undefined {
+ if (!building) return undefined;
+ return {
+ id: building.id,
+ ownerId: building.ownerId,
+ isSpawner: building.isSpawner,
+ position: { x: building.position.x, y: building.position.y },
+ getCooldownRemaining(monsterType: string): number {
+ const cd = building.getCooldown(monsterType);
+ return cd ? cd.remainingTicks : 0;
+ },
+ };
+}
+
+/**
+ * Convert MapSchema to iterable with spatial interface
+ */
+function* toSpatialIterable(
+ entities: MapSchema | MapSchema
+): Iterable<{ id: string; position: { x: number; y: number }; radius: number }> {
+ for (const entity of entities.values()) {
+ yield {
+ id: entity.id,
+ position: { x: entity.position.x, y: entity.position.y },
+ radius: entity.radius,
+ };
+ }
+}
+
+/**
+ * Main server-side input validator
+ * Provides authoritative validation for all player actions
+ */
+export class InputValidator {
+ private state: GameState;
+ private territory: TerritoryCalculator;
+ private towerMeta: TowerMetaRegistry;
+ private spawnableMeta: SpawnableMonsterRegistry;
+ private buildingMeta: BuildingMetaRegistry;
+
+ constructor(
+ state: GameState,
+ territory: TerritoryCalculator,
+ towerMeta: TowerMetaRegistry,
+ spawnableMeta: SpawnableMonsterRegistry,
+ buildingMeta: BuildingMetaRegistry
+ ) {
+ this.state = state;
+ this.territory = territory;
+ this.towerMeta = towerMeta;
+ this.spawnableMeta = spawnableMeta;
+ this.buildingMeta = buildingMeta;
+ }
+
+ /**
+ * Update references (call if state or territory is replaced)
+ */
+ updateRefs(state: GameState, territory: TerritoryCalculator): void {
+ this.state = state;
+ this.territory = territory;
+ }
+
+ /**
+ * Validate BUILD_TOWER action
+ * Full server validation: shared checks + territory + collision + money deduction
+ */
+ validateBuildTower(
+ playerId: string,
+ towerType: string,
+ x: number,
+ y: number
+ ): ValidationResult {
+ const player = this.state.getPlayer(playerId);
+ const meta = this.towerMeta.get(towerType);
+ const bounds = {
+ width: this.state.mapConfig.width,
+ height: this.state.mapConfig.height,
+ };
+
+ // Step 1: Shared basic validation (player state, type, bounds)
+ const basicResult = validateBuildTowerBasic(
+ toPlayerValidation(player),
+ towerType,
+ x,
+ y,
+ meta,
+ bounds
+ );
+ if (!basicResult.valid) return basicResult;
+
+ // Step 2: Territory check
+ const inOwnTerritory = this.territory.isPositionInValidTerritory(
+ x,
+ y,
+ playerId,
+ this.state.buildings,
+ this.state.towers
+ );
+
+ // Check if in any enemy territory
+ let inEnemyTerritory = false;
+ for (const otherPlayer of this.state.players.values()) {
+ if (otherPlayer.id === playerId || !otherPlayer.isAlive) continue;
+ if (
+ this.territory.isPositionInValidTerritory(
+ x,
+ y,
+ otherPlayer.id,
+ this.state.buildings,
+ this.state.towers
+ )
+ ) {
+ inEnemyTerritory = true;
+ break;
+ }
+ }
+
+ if (!inOwnTerritory && !inEnemyTerritory) {
+ return validationFailure(ValidationErrorCode.POSITION_NOT_IN_TERRITORY);
+ }
+
+ // Step 3: Collision check
+ const collisionResult = checkBuildCollision(
+ x,
+ y,
+ 15, // default tower radius
+ toSpatialIterable(this.state.towers),
+ toSpatialIterable(this.state.buildings)
+ );
+ if (collisionResult.collides) {
+ return validationFailure(
+ ValidationErrorCode.POSITION_COLLISION,
+ `Collision with entity: ${collisionResult.collidingEntityId}`
+ );
+ }
+
+ // Step 4: Calculate final cost (enemy territory = 2x cost)
+ const basePrice = meta!.price;
+ const costMultiplier = inEnemyTerritory && !inOwnTerritory
+ ? PVP_CONFIG.territory.enemyBuildCostMultiplier
+ : 1;
+ const finalCost = basePrice * costMultiplier;
+
+ // Step 5: Check money
+ if (player!.money < finalCost) {
+ return validationFailure(
+ ValidationErrorCode.INSUFFICIENT_MONEY,
+ `Need ${finalCost}, have ${player!.money}`
+ );
+ }
+
+ return validationSuccess({
+ cost: finalCost,
+ inEnemyTerritory,
+ costMultiplier,
+ towerType,
+ });
+ }
+
+ validateBuildBuilding(
+ playerId: string,
+ buildingType: string,
+ x: number,
+ y: number
+ ): ValidationResult {
+ const player = this.state.getPlayer(playerId);
+ const meta = this.buildingMeta.get(buildingType);
+ const bounds = {
+ width: this.state.mapConfig.width,
+ height: this.state.mapConfig.height,
+ };
+
+ // Step 1: Basic validation
+ if (!player || !player.isAlive) {
+ return validationFailure(ValidationErrorCode.PLAYER_NOT_FOUND);
+ }
+ if (!meta) {
+ return validationFailure(ValidationErrorCode.TOWER_TYPE_INVALID);
+ }
+ if (x < 0 || x > bounds.width || y < 0 || y > bounds.height) {
+ return validationFailure(ValidationErrorCode.POSITION_OUT_OF_BOUNDS);
+ }
+
+ // Step 2: Territory check
+ const inOwnTerritory = this.territory.isPositionInValidTerritory(
+ x,
+ y,
+ playerId,
+ this.state.buildings,
+ this.state.towers
+ );
+
+ let inEnemyTerritory = false;
+ for (const otherPlayer of this.state.players.values()) {
+ if (otherPlayer.id === playerId || !otherPlayer.isAlive) continue;
+ if (
+ this.territory.isPositionInValidTerritory(
+ x,
+ y,
+ otherPlayer.id,
+ this.state.buildings,
+ this.state.towers
+ )
+ ) {
+ inEnemyTerritory = true;
+ break;
+ }
+ }
+
+ if (!inOwnTerritory && !inEnemyTerritory) {
+ return validationFailure(ValidationErrorCode.POSITION_NOT_IN_TERRITORY);
+ }
+
+ // Step 3: Collision check
+ const collisionResult = checkBuildCollision(
+ x,
+ y,
+ meta.radius,
+ toSpatialIterable(this.state.towers),
+ toSpatialIterable(this.state.buildings)
+ );
+ if (collisionResult.collides) {
+ return validationFailure(
+ ValidationErrorCode.POSITION_COLLISION,
+ `Collision with entity: ${collisionResult.collidingEntityId}`
+ );
+ }
+
+ // Step 4: Calculate final cost
+ const basePrice = meta.price;
+ const costMultiplier = inEnemyTerritory && !inOwnTerritory
+ ? PVP_CONFIG.territory.enemyBuildCostMultiplier
+ : 1;
+ const finalCost = basePrice * costMultiplier;
+
+ // Step 5: Check money
+ if (player.money < finalCost) {
+ return validationFailure(
+ ValidationErrorCode.INSUFFICIENT_MONEY,
+ `Need ${finalCost}, have ${player.money}`
+ );
+ }
+
+ return validationSuccess({
+ cost: finalCost,
+ inEnemyTerritory,
+ costMultiplier,
+ buildingType,
+ });
+ }
+
+ /**
+ * Validate UPGRADE_TOWER action
+ */
+ validateUpgradeTower(
+ playerId: string,
+ towerId: string,
+ targetType: string
+ ): ValidationResult {
+ const player = this.state.getPlayer(playerId);
+ const tower = this.state.towers.get(towerId);
+ const currentMeta = tower ? this.towerMeta.get(tower.towerType) : undefined;
+ const targetMeta = this.towerMeta.get(targetType);
+
+ return validateUpgradeTower(
+ toPlayerValidation(player),
+ toTowerValidation(tower),
+ targetType,
+ currentMeta,
+ targetMeta
+ );
+ }
+
+ /**
+ * Validate SELL_TOWER action
+ */
+ validateSellTower(
+ playerId: string,
+ towerId: string
+ ): ValidationResult {
+ const player = this.state.getPlayer(playerId);
+ const tower = this.state.towers.get(towerId);
+ const meta = tower ? this.towerMeta.get(tower.towerType) : undefined;
+
+ return validateSellTower(
+ toPlayerValidation(player),
+ toTowerValidation(tower),
+ meta,
+ 0.5 // 50% refund rate
+ );
+ }
+
+ /**
+ * Validate SPAWN_MONSTER action
+ */
+ validateSpawnMonster(
+ playerId: string,
+ spawnerId: string,
+ monsterType: string,
+ targetPlayerId: string
+ ): ValidationResult {
+ const player = this.state.getPlayer(playerId);
+ const spawner = this.state.buildings.get(spawnerId);
+ const monsterConfig = this.spawnableMeta.get(monsterType);
+
+ // Territory check for spawner
+ if (spawner && spawner.isSpawner) {
+ const inTerritory = this.territory.isInValidTerritory(
+ spawner.id,
+ spawner.ownerId
+ );
+ if (!inTerritory) {
+ return validationFailure(
+ ValidationErrorCode.SPAWNER_NOT_IN_TERRITORY,
+ 'Spawner is not in valid territory'
+ );
+ }
+ }
+
+ return validateSpawnMonster(
+ toPlayerValidation(player),
+ toSpawnerValidation(spawner),
+ monsterType,
+ targetPlayerId,
+ this.state.wave.currentWave,
+ monsterConfig,
+ (id: string) => {
+ const p = this.state.getPlayer(id);
+ if (!p) return undefined;
+ return { id: p.id, isAlive: p.isAlive };
+ }
+ );
+ }
+
+ /**
+ * Validate CANNON_FIRE action
+ */
+ validateCannonFire(
+ playerId: string,
+ towerId: string,
+ targetX: number,
+ targetY: number
+ ): ValidationResult {
+ const player = this.state.getPlayer(playerId);
+ const tower = this.state.towers.get(towerId);
+
+ return validateCannonFire(
+ toPlayerValidation(player),
+ toTowerValidation(tower),
+ targetX,
+ targetY
+ );
+ }
+}
diff --git a/server/tsconfig.json b/server/tsconfig.json
new file mode 100644
index 0000000..56a44fa
--- /dev/null
+++ b/server/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "lib": ["ES2022"],
+ "outDir": "./dist",
+ "rootDir": "..",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "experimentalDecorators": true,
+ "emitDecoratorMetadata": true,
+ "baseUrl": "."
+ },
+ "include": ["src/**/*", "../shared/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/shared/config/buildingMeta.ts b/shared/config/buildingMeta.ts
new file mode 100644
index 0000000..9a86d1e
--- /dev/null
+++ b/shared/config/buildingMeta.ts
@@ -0,0 +1,32 @@
+export interface BuildingMetaData {
+ id: string;
+ price: number;
+ radius: number;
+ hp: number;
+ displayName: string;
+}
+
+export const BUILDING_META: Record = {
+ Collector: {
+ id: 'Collector',
+ price: 800,
+ radius: 15,
+ hp: 3000,
+ displayName: 'Gold Mine'
+ },
+ Treatment: {
+ id: 'Treatment',
+ price: 1200,
+ radius: 10,
+ hp: 7500,
+ displayName: 'Repair Tower'
+ }
+};
+
+export function getBuildingMeta(buildingType: string): BuildingMetaData | undefined {
+ return BUILDING_META[buildingType];
+}
+
+export function isBuildingTypeValid(buildingType: string): boolean {
+ return buildingType in BUILDING_META;
+}
diff --git a/shared/config/bulletCombatMeta.ts b/shared/config/bulletCombatMeta.ts
new file mode 100644
index 0000000..d955125
--- /dev/null
+++ b/shared/config/bulletCombatMeta.ts
@@ -0,0 +1,187 @@
+/**
+ * Bullet Combat Metadata
+ * Server-side combat properties for all bullet types
+ * Extracted from client src/bullets/variants/*.ts
+ */
+
+export interface BulletCombatData {
+ id: string;
+ damage: number;
+ radius: number;
+ // Explosive
+ isExplosive: boolean;
+ explosionDamage: number;
+ explosionRadius: number;
+ // Tracking
+ isTracking: boolean;
+ trackingRadius: number;
+ // Penetrating
+ isPenetrating: boolean;
+ penetrationCount: number;
+ // Freeze (1 = none, <1 = slow)
+ freezeMultiplier: number;
+ // Burn
+ burnRate: number;
+ // Targets buildings instead of monsters
+ targetsTowers: boolean;
+}
+
+/** Default values for fields not specified */
+const DEFAULTS: Omit = {
+ isExplosive: false,
+ explosionDamage: 0,
+ explosionRadius: 0,
+ isTracking: false,
+ trackingRadius: 0,
+ isPenetrating: false,
+ penetrationCount: 0,
+ freezeMultiplier: 1,
+ burnRate: 0,
+ targetsTowers: false,
+};
+
+/** Helper to create bullet data with defaults */
+function bullet(
+ id: string,
+ damage: number,
+ radius: number,
+ overrides: Partial = {}
+): BulletCombatData {
+ return { ...DEFAULTS, id, damage, radius, ...overrides };
+}
+
+/**
+ * All bullet combat metadata keyed by bullet type name.
+ *
+ * Sources:
+ * basic.ts - 14 types
+ * machinegun.ts - 3 types
+ * explosive.ts - 5 types
+ * freeze.ts - 3 types
+ * penetrating.ts - 3 types
+ * split.ts - 7 types
+ * special.ts - 9 types
+ */
+export const BULLET_COMBAT_META: Record = {
+ // ==================== basic.ts ====================
+ Normal: bullet('Normal', 5, 2.5),
+ littleStone: bullet('littleStone', 7, 3),
+ Arrow: bullet('Arrow', 10, 2),
+ Arrow_L: bullet('Arrow_L', 15, 2.5),
+ Arrow_LL: bullet('Arrow_LL', 70, 2.7),
+ CannonStone_S: bullet('CannonStone_S', 500, 4),
+ CannonStone_M: bullet('CannonStone_M', 800, 6),
+ CannonStone_L: bullet('CannonStone_L', 1000, 8),
+ Bully_S: bullet('Bully_S', 40, 1.5),
+ Bully_M: bullet('Bully_M', 50, 1.7),
+ Bully_L: bullet('Bully_L', 75, 2),
+ Rifle_Bully_S: bullet('Rifle_Bully_S', 70, 1),
+ Rifle_Bully_M: bullet('Rifle_Bully_M', 100, 1.1),
+ Rifle_Bully_L: bullet('Rifle_Bully_L', 150, 1.2),
+
+ // ==================== machinegun.ts ====================
+ F_S: bullet('F_S', 10, 0.8),
+ F_M: bullet('F_M', 30, 0.9),
+ F_L: bullet('F_L', 30, 1),
+
+ // ==================== explosive.ts ====================
+ H_S: bullet('H_S', 100, 4, {
+ isExplosive: true, explosionDamage: 1000, explosionRadius: 35,
+ }),
+ H_L: bullet('H_L', 200, 6, {
+ isExplosive: true, explosionDamage: 2000, explosionRadius: 60,
+ }),
+ H_LL: bullet('H_LL', 500, 10, {
+ isExplosive: true, explosionDamage: 3500, explosionRadius: 120,
+ }),
+ H_Target_S: bullet('H_Target_S', 100, 6, {
+ isExplosive: true, explosionDamage: 100, explosionRadius: 100,
+ isTracking: true, trackingRadius: 50,
+ }),
+ ManualCannon_Shell: bullet('ManualCannon_Shell', 50, 5, {
+ isExplosive: true, explosionDamage: 100, explosionRadius: 50,
+ targetsTowers: true,
+ }),
+
+ // ==================== freeze.ts ====================
+ Frozen_S: bullet('Frozen_S', 0.1, 1, {
+ isExplosive: true, explosionDamage: 0.1, explosionRadius: 10,
+ freezeMultiplier: 0.98,
+ }),
+ Frozen_M: bullet('Frozen_M', 0.1, 2, {
+ isExplosive: true, explosionDamage: 0.1, explosionRadius: 20,
+ freezeMultiplier: 0.9,
+ }),
+ Frozen_L: bullet('Frozen_L', 0.1, 5, {
+ isExplosive: true, explosionDamage: 0.1, explosionRadius: 30,
+ freezeMultiplier: 0.7,
+ }),
+
+ // ==================== penetrating.ts ====================
+ // penetrationCount = floor(radius / throughCutNum)
+ T_M: bullet('T_M', 30, 2.5, {
+ isPenetrating: true, penetrationCount: 25,
+ }),
+ T_L: bullet('T_L', 40, 4, {
+ isPenetrating: true, penetrationCount: 4,
+ }),
+ T_LL: bullet('T_LL', 120, 8, {
+ isPenetrating: true, penetrationCount: 8,
+ }),
+
+ // ==================== split.ts ====================
+ // Split mechanics are simplified on server: treated as explosive AOE
+ SS_S: bullet('SS_S', 30, 5, {
+ isExplosive: true, explosionDamage: 40, explosionRadius: 30,
+ }),
+ SS_M: bullet('SS_M', 20, 6, {
+ isExplosive: true, explosionDamage: 50, explosionRadius: 50,
+ }),
+ SS_L: bullet('SS_L', 50, 8, {
+ isExplosive: true, explosionDamage: 100, explosionRadius: 50,
+ }),
+ SS_Second: bullet('SS_Second', 100, 15, {
+ isExplosive: true, explosionDamage: 200, explosionRadius: 80,
+ }),
+ SS_Third: bullet('SS_Third', 120, 20, {
+ isExplosive: true, explosionDamage: 300, explosionRadius: 100,
+ }),
+ SpikeBully: bullet('SpikeBully', 5, 10, {
+ isExplosive: true, explosionDamage: 5, explosionRadius: 40,
+ }),
+ CactusNeedle: bullet('CactusNeedle', 5, 1),
+
+ // ==================== special.ts ====================
+ S: bullet('S', 40, 2),
+ R_M: bullet('R_M', 20, 5),
+ Powder: bullet('Powder', 0.8, 8, {
+ isPenetrating: true, penetrationCount: 100,
+ }),
+ Fire_M: bullet('Fire_M', 1, 10, {
+ isPenetrating: true, penetrationCount: 100,
+ burnRate: 0.0001,
+ }),
+ Fire_L: bullet('Fire_L', 0.5, 20, {
+ isPenetrating: true, penetrationCount: 100,
+ burnRate: 0.0001,
+ }),
+ Fire_LL: bullet('Fire_LL', 1, 10, {
+ isPenetrating: true, penetrationCount: 100,
+ burnRate: 0.0001,
+ }),
+ P_L: bullet('P_L', 3, 2, {
+ isPenetrating: true, penetrationCount: 1,
+ }),
+ P_M: bullet('P_M', 5, 10, {
+ isPenetrating: true, penetrationCount: 5,
+ }),
+ ThunderBall: bullet('ThunderBall', 100, 10, {
+ isExplosive: true, explosionDamage: 200, explosionRadius: 160,
+ isTracking: true, trackingRadius: 300,
+ }),
+};
+
+/** Get bullet combat data by type name */
+export function getBulletCombatData(bulletType: string): BulletCombatData | undefined {
+ return BULLET_COMBAT_META[bulletType];
+}
diff --git a/shared/config/index.ts b/shared/config/index.ts
new file mode 100644
index 0000000..0a0024c
--- /dev/null
+++ b/shared/config/index.ts
@@ -0,0 +1,40 @@
+/**
+ * Shared Config Exports
+ */
+export { TOWER_META, getTowerMeta, isTowerTypeValid } from './towerMeta.js';
+export type { TowerMetaData } from '../validation/towerValidation.js';
+
+export {
+ SPAWNABLE_MONSTER_META,
+ getMonsterMeta,
+ isMonsterTypeValid,
+ getMonstersForWave,
+} from './monsterMeta.js';
+export type { MonsterMetaData } from './monsterMeta.js';
+
+export { BULLET_COMBAT_META, getBulletCombatData } from './bulletCombatMeta.js';
+export type { BulletCombatData } from './bulletCombatMeta.js';
+
+export { TOWER_COMBAT_META, getTowerCombatData } from './towerCombatMeta.js';
+export type { TowerCombatData } from './towerCombatMeta.js';
+
+export { TOWER_BASE_META, getTowerBaseMeta } from './towerBaseMeta.js';
+export type { TowerBaseMeta } from './towerBaseMeta.js';
+
+export { MINE_CONFIG, MineStateType, MINE_GENERATION } from './mineMeta.js';
+
+export { PLAYER_COLORS } from './playerMeta.js';
+
+export {
+ VisionType,
+ HEADQUARTERS_VISION, BASIC_TOWER_VISION,
+ OBSERVER_RADIUS, OBSERVER_PRICE, OBSERVER_MAX_LEVEL,
+ RADAR_RADIUS, RADAR_PRICE, RADAR_MAX_LEVEL,
+ RADAR_SWEEP_ANGLE, RADAR_SWEEP_SPEED,
+ getVisionRadius, getVisionUpgradePrice, canUpgradeVision,
+} from './visionMeta.js';
+
+export { TERRITORY_PENALTY } from './territoryMeta.js';
+
+export { BUILDING_META, getBuildingMeta, isBuildingTypeValid } from './buildingMeta.js';
+export type { BuildingMetaData } from './buildingMeta.js';
diff --git a/shared/config/mineMeta.ts b/shared/config/mineMeta.ts
new file mode 100644
index 0000000..9b9a3c2
--- /dev/null
+++ b/shared/config/mineMeta.ts
@@ -0,0 +1,46 @@
+/**
+ * Mine Shared Configuration
+ * Constants shared between client and server for mine/power plant mechanics
+ */
+
+/** Mine upgrade/HP/production configuration */
+export const MINE_CONFIG = {
+ // Upgrade prices per level (index 0 = normal→lv1, index 1 = lv1→lv2, etc.)
+ upgradePrices: [150, 200, 250] as readonly number[],
+ // HP per level (index 0 = lv1, index 1 = lv2, index 2 = lv3)
+ upgradeHp: [3000, 5000, 10000] as readonly number[],
+ // Production per level = level * productionPerLevel
+ productionPerLevel: 2,
+ // Max level
+ maxLevel: 3,
+ // Repair cost
+ repairCost: 50,
+ // Repair duration in ticks
+ repairTicks: 1000,
+ // Sell refund ratio (of total invested)
+ sellRefundRatio: 0.5,
+ // Downgrade refund ratio (of current level price)
+ downgradeRefundRatio: 0.25,
+ // Mine entity radius (for collision)
+ normalRadius: 15,
+ powerPlantRadius: 20,
+} as const;
+
+/** Mine state types */
+export const MineStateType = {
+ NORMAL: 'normal',
+ DAMAGED: 'damaged',
+ POWER_PLANT: 'powerPlant',
+} as const;
+
+/** Mine generation config for PvP maps */
+export const MINE_GENERATION = {
+ guaranteedNearBase: 3,
+ nearBaseMinDist: 100,
+ nearBaseMaxDist: 300,
+ minesPerSide: 80,
+ centerMines: 10,
+ minDistFromEdge: 100,
+ minDistFromBase: 200,
+ minDistBetweenMines: 50,
+} as const;
diff --git a/shared/config/monsterMeta.ts b/shared/config/monsterMeta.ts
new file mode 100644
index 0000000..34e3541
--- /dev/null
+++ b/shared/config/monsterMeta.ts
@@ -0,0 +1,305 @@
+/**
+ * Shared Monster Metadata
+ * Minimal monster data needed for server-side spawning and validation
+ * Contains spawnable monsters suitable for PvP multiplayer mode
+ */
+
+/**
+ * Monster metadata for server-side use
+ */
+export interface MonsterMetaData {
+ /** Monster type ID (matches client registry) */
+ monsterId: string;
+ /** Display name (Chinese) */
+ name: string;
+ /** Cost to spawn this monster */
+ cost: number;
+ /** Cooldown in ticks before spawning again */
+ cooldownTicks: number;
+ /** Wave number required to unlock */
+ unlockWave: number;
+ /** Reward for killing this monster */
+ reward: number;
+ /** Base HP value */
+ baseHp: number;
+ /** Movement speed */
+ speed: number;
+ /** Collision radius */
+ radius: number;
+}
+
+/**
+ * Spawnable monster metadata registry
+ * Contains ~21 monsters suitable for PvP multiplayer
+ */
+export const SPAWNABLE_MONSTER_META: Record = {
+ // === Basic Monsters ===
+ Normal: {
+ monsterId: 'Normal',
+ name: '普通人',
+ cost: 20,
+ cooldownTicks: 60,
+ unlockWave: 1,
+ reward: 10,
+ baseHp: 100,
+ speed: 0.3,
+ radius: 10,
+ },
+ Runner: {
+ monsterId: 'Runner',
+ name: '跑人',
+ cost: 20,
+ cooldownTicks: 80,
+ unlockWave: 3,
+ reward: 10,
+ baseHp: 80,
+ speed: 1,
+ radius: 10,
+ },
+ Ox1: {
+ monsterId: 'Ox1',
+ name: '冲锋1级',
+ cost: 30,
+ cooldownTicks: 120,
+ unlockWave: 5,
+ reward: 10,
+ baseHp: 120,
+ speed: 0.01,
+ radius: 10,
+ },
+ Ox3: {
+ monsterId: 'Ox3',
+ name: '冲锋3级',
+ cost: 50,
+ cooldownTicks: 140,
+ unlockWave: 6,
+ reward: 15,
+ baseHp: 150,
+ speed: 0.01,
+ radius: 10,
+ },
+
+ // === Bomber Monsters ===
+ Bomber1: {
+ monsterId: 'Bomber1',
+ name: '炸弹1级',
+ cost: 40,
+ cooldownTicks: 160,
+ unlockWave: 8,
+ reward: 10,
+ baseHp: 100,
+ speed: 0.5,
+ radius: 10,
+ },
+ Bomber2: {
+ monsterId: 'Bomber2',
+ name: '炸弹2级',
+ cost: 60,
+ cooldownTicks: 160,
+ unlockWave: 7,
+ reward: 20,
+ baseHp: 120,
+ speed: 0.55,
+ radius: 10,
+ },
+ Bomber3: {
+ monsterId: 'Bomber3',
+ name: '炸弹3级',
+ cost: 100,
+ cooldownTicks: 200,
+ unlockWave: 12,
+ reward: 20,
+ baseHp: 150,
+ speed: 0.6,
+ radius: 10,
+ },
+
+ // === Elite Monsters ===
+ Exciting: {
+ monsterId: 'Exciting',
+ name: '激动人',
+ cost: 35,
+ cooldownTicks: 100,
+ unlockWave: 5,
+ reward: 10,
+ baseHp: 80,
+ speed: 3,
+ radius: 10,
+ },
+ Visitor: {
+ monsterId: 'Visitor',
+ name: '旋转人',
+ cost: 35,
+ cooldownTicks: 100,
+ unlockWave: 5,
+ reward: 10,
+ baseHp: 80,
+ speed: 3,
+ radius: 10,
+ },
+ Mts: {
+ monsterId: 'Mts',
+ name: '忍者',
+ cost: 100,
+ cooldownTicks: 200,
+ unlockWave: 10,
+ reward: 50,
+ baseHp: 200,
+ speed: 1,
+ radius: 35,
+ },
+ T800: {
+ monsterId: 'T800',
+ name: '恐怖机器人',
+ cost: 1200,
+ cooldownTicks: 600,
+ unlockWave: 15,
+ reward: 600,
+ baseHp: 5000,
+ speed: 0.5,
+ radius: 40,
+ },
+
+ // === Defender Monsters ===
+ BulletWearer: {
+ monsterId: 'BulletWearer',
+ name: '子弹削子',
+ cost: 45,
+ cooldownTicks: 120,
+ unlockWave: 6,
+ reward: 15,
+ baseHp: 100,
+ speed: 0.35,
+ radius: 10,
+ },
+ BulletRepellent: {
+ monsterId: 'BulletRepellent',
+ name: '子弹排斥',
+ cost: 50,
+ cooldownTicks: 140,
+ unlockWave: 7,
+ reward: 15,
+ baseHp: 100,
+ speed: 0.25,
+ radius: 10,
+ },
+
+ // === Shouter Monsters ===
+ Shouter: {
+ monsterId: 'Shouter',
+ name: '射击者',
+ cost: 55,
+ cooldownTicks: 150,
+ unlockWave: 8,
+ reward: 15,
+ baseHp: 100,
+ speed: 0.35,
+ radius: 20,
+ },
+ Shouter_Stone: {
+ monsterId: 'Shouter_Stone',
+ name: '石头蛋子射击者',
+ cost: 70,
+ cooldownTicks: 180,
+ unlockWave: 10,
+ reward: 15,
+ baseHp: 120,
+ speed: 0.3,
+ radius: 20,
+ },
+
+ // === Slime Monsters ===
+ Slime_L: {
+ monsterId: 'Slime_L',
+ name: '大史莱姆',
+ cost: 80,
+ cooldownTicks: 200,
+ unlockWave: 9,
+ reward: 20,
+ baseHp: 300,
+ speed: 0.4,
+ radius: 50,
+ },
+
+ // === Support Monsters ===
+ Medic: {
+ monsterId: 'Medic',
+ name: '加血辅助',
+ cost: 60,
+ cooldownTicks: 160,
+ unlockWave: 8,
+ reward: 20,
+ baseHp: 150,
+ speed: 0.5,
+ radius: 30,
+ },
+ SpeedAdder: {
+ monsterId: 'SpeedAdder',
+ name: '加速辅助',
+ cost: 55,
+ cooldownTicks: 150,
+ unlockWave: 7,
+ reward: 20,
+ baseHp: 100,
+ speed: 0.35,
+ radius: 10,
+ },
+
+ // === Special Monsters ===
+ BlackHole: {
+ monsterId: 'BlackHole',
+ name: '黑洞',
+ cost: 90,
+ cooldownTicks: 200,
+ unlockWave: 11,
+ reward: 20,
+ baseHp: 200,
+ speed: 0.2,
+ radius: 30,
+ },
+ Glans: {
+ monsterId: 'Glans',
+ name: '激光防御',
+ cost: 70,
+ cooldownTicks: 180,
+ unlockWave: 9,
+ reward: 20,
+ baseHp: 150,
+ speed: 0.3,
+ radius: 30,
+ },
+ witch_N: {
+ monsterId: 'witch_N',
+ name: '召唤师',
+ cost: 85,
+ cooldownTicks: 220,
+ unlockWave: 10,
+ reward: 20,
+ baseHp: 180,
+ speed: 0.3,
+ radius: 30,
+ },
+};
+
+/**
+ * Get monster metadata by ID
+ */
+export function getMonsterMeta(monsterId: string): MonsterMetaData | undefined {
+ return SPAWNABLE_MONSTER_META[monsterId];
+}
+
+/**
+ * Check if a monster type is valid for spawning
+ */
+export function isMonsterTypeValid(monsterId: string): boolean {
+ return monsterId in SPAWNABLE_MONSTER_META;
+}
+
+/**
+ * Get all monsters available for a given wave number
+ */
+export function getMonstersForWave(waveNumber: number): MonsterMetaData[] {
+ return Object.values(SPAWNABLE_MONSTER_META).filter(
+ (meta) => meta.unlockWave <= waveNumber
+ );
+}
diff --git a/shared/config/playerMeta.ts b/shared/config/playerMeta.ts
new file mode 100644
index 0000000..47966b5
--- /dev/null
+++ b/shared/config/playerMeta.ts
@@ -0,0 +1,15 @@
+/**
+ * Player configuration metadata
+ * Single source of truth for player colors in multiplayer mode
+ */
+
+/**
+ * Player colors for multiplayer mode
+ * Index 0 = Player 1 (left), Index 1 = Player 2 (right), etc.
+ */
+export const PLAYER_COLORS = [
+ '#3498db', // Blue - Player 1
+ '#e74c3c', // Red - Player 2
+ '#2ecc71', // Green - Player 3
+ '#f1c40f', // Yellow - Player 4
+] as const;
diff --git a/shared/config/territoryMeta.ts b/shared/config/territoryMeta.ts
new file mode 100644
index 0000000..0cb2ca0
--- /dev/null
+++ b/shared/config/territoryMeta.ts
@@ -0,0 +1,15 @@
+/**
+ * Territory Penalty Configuration
+ */
+
+/** Territory penalty multipliers for buildings in invalid territory */
+export const TERRITORY_PENALTY = {
+ /** Damage multiplier (1/3) */
+ DAMAGE_MULTIPLIER: 1 / 3,
+
+ /** Attack range multiplier (2/3) */
+ RANGE_MULTIPLIER: 2 / 3,
+
+ /** HP multiplier (1/2) */
+ HP_MULTIPLIER: 0.5,
+} as const;
diff --git a/shared/config/towerBaseMeta.ts b/shared/config/towerBaseMeta.ts
new file mode 100644
index 0000000..67690ac
--- /dev/null
+++ b/shared/config/towerBaseMeta.ts
@@ -0,0 +1,133 @@
+/**
+ * Tower Base Metadata for Non-Bullet Towers
+ * Server-side physical stats for Laser/Hammer/Boomerang/Hell/Ray towers
+ * Extracted from client src/towers/config/*.ts and base class defaults
+ *
+ * NOTE: Bullet-type towers (baseClass: 'Tower') are in towerCombatMeta.ts
+ */
+
+export interface TowerBaseMeta {
+ id: string;
+ hp: number;
+ radius: number; // 15 + rAdd (ManualCannon: 12)
+ attackRadius: number; // rangeR
+ // ManualCannon-only fields
+ isManual?: boolean;
+ maxAmmo?: number;
+ reloadTicks?: number;
+}
+
+/**
+ * Build a TowerBaseMeta entry.
+ * Default hp comes from base class constructors:
+ * TowerLaser/TowerHell/TowerRay: 5000
+ * TowerHammer/TowerBoomerang: 1000 (Tower base)
+ */
+function meta(
+ id: string,
+ hp: number,
+ rAdd: number,
+ rangeR: number,
+ extra?: Partial>
+): TowerBaseMeta {
+ return {
+ id,
+ hp,
+ radius: 15 + rAdd,
+ attackRadius: rangeR,
+ ...extra,
+ };
+}
+
+/**
+ * All non-bullet tower base metadata (48 entries)
+ */
+export const TOWER_BASE_META: Record = {
+ // ==================== TowerLaser (7) ====================
+ // Base class default hp=5000 when not specified in config
+ Laser: meta('Laser', 5000, 6, 120),
+ Laser_Blue_1: meta('Laser_Blue_1', 5000, 7, 130),
+ Laser_Blue_2: meta('Laser_Blue_2', 5000, 10, 150),
+ Laser_Blue_3: meta('Laser_Blue_3', 5000, 13, 170),
+ Laser_Green_1: meta('Laser_Green_1', 5000, 7, 200),
+ Laser_Green_2: meta('Laser_Green_2', 5000, 8, 250),
+ Laser_Green_3: meta('Laser_Green_3', 5000, 10, 300),
+
+ // ==================== TowerHell (2) ====================
+ // Base class default hp=5000, rangeR=200
+ Laser_Hell_1: meta('Laser_Hell_1', 5000, 13, 200),
+ Laser_Hell_2: meta('Laser_Hell_2', 5000, 15, 200),
+
+ // ==================== TowerRay - Red Laser (5) ====================
+ // Base class default hp=5000, rangeR=200
+ Laser_Red: meta('Laser_Red', 10000, 7, 250),
+ Laser_Red_Alpha_1: meta('Laser_Red_Alpha_1', 20000, 9, 300),
+ Laser_Red_Alpha_2: meta('Laser_Red_Alpha_2', 30000, 11, 350),
+ Laser_Red_Beta_1: meta('Laser_Red_Beta_1', 30000, 10, 0), // scanningAttack: no range limit
+ Laser_Red_Beta_2: meta('Laser_Red_Beta_2', 50000, 13, 0), // scanningAttack: no range limit
+
+ // ==================== Thunder - TowerLaser (6) ====================
+ // Base class default hp=5000
+ Thunder_1: meta('Thunder_1', 5000, 5, 250),
+ Thunder_2: meta('Thunder_2', 5000, 10, 270),
+ Thunder_Far_1: meta('Thunder_Far_1', 5000, 12, 300),
+ Thunder_Far_2: meta('Thunder_Far_2', 5000, 13, 320),
+ Thunder_Power_1: meta('Thunder_Power_1', 5000, 10, 250),
+ Thunder_Power_2: meta('Thunder_Power_2', 5000, 12, 245),
+
+ // ==================== Earthquake - TowerLaser (5) ====================
+ Earthquake: meta('Earthquake', 5000, 5, 120),
+ Earthquake_Power_1: meta('Earthquake_Power_1', 10000, 10, 250),
+ Earthquake_Power_2: meta('Earthquake_Power_2', 100000, 15, 260),
+ Earthquake_Speed_1: meta('Earthquake_Speed_1', 6000, 10, 250),
+ Earthquake_Speed_2: meta('Earthquake_Speed_2', 9000, 12, 300),
+
+ // ==================== TowerRay - Air Cannon (3) ====================
+ AirCannon_1: meta('AirCannon_1', 4000, 5, 120),
+ AirCannon_2: meta('AirCannon_2', 5000, 7, 130),
+ AirCannon_3: meta('AirCannon_3', 10000, 8, 150),
+
+ // ==================== TowerRay - Future Cannon (5) ====================
+ FutureCannon_1: meta('FutureCannon_1', 5000, 1, 150),
+ FutureCannon_2: meta('FutureCannon_2', 10000, 5, 170),
+ FutureCannon_3: meta('FutureCannon_3', 20000, 7, 200),
+ FutureCannon_4: meta('FutureCannon_4', 50000, 9, 250),
+ FutureCannon_5: meta('FutureCannon_5', 100000, 12, 280),
+
+ // ==================== TowerHammer (7) ====================
+ // Base class default hp=1000 (Tower base)
+ Hammer: meta('Hammer', 5000, 3, 80),
+ Hammer_Fast_1: meta('Hammer_Fast_1', 6000, 5, 100),
+ Hammer_Fast_2: meta('Hammer_Fast_2', 10000, 7, 150),
+ Hammer_Fast_3: meta('Hammer_Fast_3', 18000, 8, 180),
+ Hammer_Power_1: meta('Hammer_Power_1', 10000, 3, 90),
+ Hammer_Power_2: meta('Hammer_Power_2', 20000, 5, 100),
+ Hammer_Power_3: meta('Hammer_Power_3', 30000, 7, 110),
+
+ // ==================== TowerBoomerang (7) ====================
+ // Base class default hp=1000 (Tower base)
+ Boomerang: meta('Boomerang', 3000, 2, 120),
+ Boomerang_Far_1: meta('Boomerang_Far_1', 6000, 3, 140),
+ Boomerang_Far_2: meta('Boomerang_Far_2', 6000, 4, 160),
+ Boomerang_Far_3: meta('Boomerang_Far_3', 6000, 5, 200),
+ Boomerang_Power_1: meta('Boomerang_Power_1', 3000, 2, 100),
+ Boomerang_Power_2: meta('Boomerang_Power_2', 5000, 3, 100),
+ Boomerang_Power_3: meta('Boomerang_Power_3', 10000, 4, 110),
+
+ // ==================== ManualCannon (1) ====================
+ // Special: r=12 (not 15+rAdd), maxAmmo=3
+ ManualCannon: {
+ id: 'ManualCannon',
+ hp: 2000,
+ radius: 12,
+ attackRadius: 400,
+ isManual: true,
+ maxAmmo: 3,
+ reloadTicks: 60,
+ },
+};
+
+/** Get non-bullet tower base meta by type name */
+export function getTowerBaseMeta(towerType: string): TowerBaseMeta | undefined {
+ return TOWER_BASE_META[towerType];
+}
diff --git a/shared/config/towerCombatMeta.ts b/shared/config/towerCombatMeta.ts
new file mode 100644
index 0000000..cb88b2c
--- /dev/null
+++ b/shared/config/towerCombatMeta.ts
@@ -0,0 +1,353 @@
+/**
+ * Tower Combat Metadata
+ * Server-side combat parameters for all bullet-type towers (baseClass: 'Tower')
+ * Extracted from client src/towers/config/*.ts
+ *
+ * NOTE: Does NOT include Laser/Hammer/Boomerang/Hell/Ray towers (future task)
+ */
+
+import { scaleSpeed, scalePeriod } from '../constants/speedScale.js';
+import { BULLET_COMBAT_META, type BulletCombatData } from './bulletCombatMeta.js';
+
+export interface TowerCombatData {
+ id: string;
+ hp: number;
+ radius: number; // 15 + rAdd
+ attackRadius: number; // rangeR
+ attackClock: number; // scalePeriod(clock)
+ // Bullet config
+ bulletType: string;
+ bulletDamage: number; // from BULLET_COMBAT_META
+ bulletRadius: number; // from BULLET_COMBAT_META
+ bulletSpeed: number; // scaleSpeed(bullySpeed)
+ bulletSlideRate: number; // bullySlideRate
+ bulletCount: number; // attackBullyNum
+ bulletSpread: number; // bullyRotate
+ isShrapnel: boolean; // attackType === 'shrapnelAttack'
+ // Special properties (inherited from bullet meta)
+ isExplosive: boolean;
+ explosionRadius: number;
+ explosionDamage: number;
+ isTracking: boolean;
+ trackingRadius: number;
+ isPenetrating: boolean;
+ penetrationCount: number;
+ freezeMultiplier: number;
+ burnRate: number;
+ targetsTowers: boolean;
+}
+
+// Default values matching Tower base class constructor
+const DEFAULT_HP = 1000;
+const DEFAULT_RANGE_R = 100;
+const DEFAULT_CLOCK = 5;
+const DEFAULT_BULLY_SPEED = 8;
+const DEFAULT_BULLY_SLIDE_RATE = 1;
+const DEFAULT_ATTACK_BULLY_NUM = 1;
+const DEFAULT_BULLY_ROTATE = 0;
+const DEFAULT_BULLET_TYPE = 'Normal';
+
+interface TowerRawConfig {
+ id: string;
+ hp?: number;
+ rAdd?: number;
+ rangeR?: number;
+ clock?: number;
+ bulletType?: string;
+ bullySpeed?: number;
+ bullySlideRate?: number;
+ attackBullyNum?: number;
+ bullyRotate?: number;
+ isShrapnel?: boolean;
+}
+
+/** Build TowerCombatData from raw config + bullet meta */
+function tower(cfg: TowerRawConfig): TowerCombatData {
+ const bulletType = cfg.bulletType ?? DEFAULT_BULLET_TYPE;
+ const bulletMeta: BulletCombatData | undefined = BULLET_COMBAT_META[bulletType];
+
+ return {
+ id: cfg.id,
+ hp: cfg.hp ?? DEFAULT_HP,
+ radius: 15 + (cfg.rAdd ?? 0),
+ attackRadius: cfg.rangeR ?? DEFAULT_RANGE_R,
+ attackClock: scalePeriod(cfg.clock ?? DEFAULT_CLOCK),
+ bulletType,
+ bulletDamage: bulletMeta?.damage ?? 5,
+ bulletRadius: bulletMeta?.radius ?? 2.5,
+ bulletSpeed: scaleSpeed(cfg.bullySpeed ?? DEFAULT_BULLY_SPEED),
+ bulletSlideRate: cfg.bullySlideRate ?? DEFAULT_BULLY_SLIDE_RATE,
+ bulletCount: cfg.attackBullyNum ?? DEFAULT_ATTACK_BULLY_NUM,
+ bulletSpread: cfg.bullyRotate ?? DEFAULT_BULLY_ROTATE,
+ isShrapnel: cfg.isShrapnel ?? false,
+ isExplosive: bulletMeta?.isExplosive ?? false,
+ explosionRadius: bulletMeta?.explosionRadius ?? 0,
+ explosionDamage: bulletMeta?.explosionDamage ?? 0,
+ isTracking: bulletMeta?.isTracking ?? false,
+ trackingRadius: bulletMeta?.trackingRadius ?? 0,
+ isPenetrating: bulletMeta?.isPenetrating ?? false,
+ penetrationCount: bulletMeta?.penetrationCount ?? 0,
+ freezeMultiplier: bulletMeta?.freezeMultiplier ?? 1,
+ burnRate: bulletMeta?.burnRate ?? 0,
+ targetsTowers: bulletMeta?.targetsTowers ?? false,
+ };
+}
+
+/**
+ * All bullet-type tower combat data (baseClass: 'Tower', ~56 towers)
+ */
+export const TOWER_COMBAT_META: Record = {
+ // ==================== Basic Towers (2) ====================
+ BasicCannon: tower({ id: 'BasicCannon' }),
+ AncientCannon: tower({
+ id: 'AncientCannon', hp: 2000, rAdd: 1, rangeR: 105,
+ bulletType: 'littleStone',
+ }),
+
+ // ==================== Traditional Towers (5) ====================
+ TraditionalCannon: tower({
+ id: 'TraditionalCannon', hp: 5000, rAdd: 1, rangeR: 105,
+ }),
+ TraditionalCannon_Small: tower({
+ id: 'TraditionalCannon_Small', rAdd: 2, rangeR: 200, clock: 3,
+ bulletType: 'Bully_S',
+ }),
+ TraditionalCannon_Middle: tower({
+ id: 'TraditionalCannon_Middle', rAdd: 3, rangeR: 200, clock: 3,
+ bulletType: 'Bully_M',
+ }),
+ TraditionalCannon_Large: tower({
+ id: 'TraditionalCannon_Large', rAdd: 4, rangeR: 200, clock: 3,
+ bulletType: 'Bully_L',
+ }),
+ TraditionalCannon_MultiTube: tower({
+ id: 'TraditionalCannon_MultiTube', rAdd: 4, rangeR: 200, clock: 4,
+ bulletType: 'Bully_M', bullyRotate: Math.PI / 36, attackBullyNum: 2,
+ isShrapnel: true,
+ }),
+
+ // ==================== Gun Towers (9) ====================
+ Rifle_1: tower({
+ id: 'Rifle_1', rAdd: 3, rangeR: 200, clock: 4,
+ bulletType: 'Rifle_Bully_L', bullySpeed: 8,
+ }),
+ Rifle_2: tower({
+ id: 'Rifle_2', rAdd: 3, rangeR: 230, clock: 3,
+ bulletType: 'Rifle_Bully_M', bullySpeed: 9,
+ }),
+ Rifle_3: tower({
+ id: 'Rifle_3', rAdd: 4, rangeR: 260, clock: 3,
+ bulletType: 'Rifle_Bully_L', bullySpeed: 10,
+ }),
+ MachineGun_1: tower({
+ id: 'MachineGun_1', hp: 2000, rAdd: 3, rangeR: 220, clock: 2,
+ bulletType: 'F_S', bullySpeed: 7,
+ }),
+ MachineGun_2: tower({
+ id: 'MachineGun_2', hp: 5000, rAdd: 5, rangeR: 190, clock: 1,
+ bulletType: 'F_M', bullySpeed: 2, bullySlideRate: 1.1, attackBullyNum: 3,
+ }),
+ MachineGun_3: tower({
+ id: 'MachineGun_3', hp: 10000, rAdd: 7, rangeR: 250, clock: 1,
+ bulletType: 'F_L', bullySpeed: 8.2, attackBullyNum: 3,
+ }),
+ ArmorPiercing_1: tower({
+ id: 'ArmorPiercing_1', hp: 1500, rAdd: 5, rangeR: 200, clock: 4,
+ bulletType: 'T_M', bullySpeed: 8, bullySlideRate: 3,
+ }),
+ ArmorPiercing_2: tower({
+ id: 'ArmorPiercing_2', hp: 5000, rAdd: 7, rangeR: 220, clock: 2,
+ bulletType: 'T_L', bullySpeed: 8, bullySlideRate: 5,
+ }),
+ ArmorPiercing_3: tower({
+ id: 'ArmorPiercing_3', hp: 10000, rAdd: 9, rangeR: 230, clock: 10,
+ bulletType: 'T_LL', bullySpeed: 4, bullySlideRate: 5,
+ }),
+
+ // ==================== Arrow Towers (7) ====================
+ ArrowBow_1: tower({
+ id: 'ArrowBow_1', hp: 1500, rAdd: 2, rangeR: 200, clock: 15,
+ bulletType: 'Arrow',
+ }),
+ ArrowBow_2: tower({
+ id: 'ArrowBow_2', hp: 2000, rAdd: 3, rangeR: 250, clock: 12,
+ bulletType: 'Arrow_L', bullySpeed: 10,
+ }),
+ ArrowBow_3: tower({
+ id: 'ArrowBow_3', hp: 5000, rAdd: 4, rangeR: 300, clock: 10,
+ bulletType: 'Arrow_L', bullySpeed: 12,
+ }),
+ ArrowBow_4: tower({
+ id: 'ArrowBow_4', hp: 8000, rAdd: 5, rangeR: 320, clock: 8,
+ bulletType: 'Arrow_LL', bullySpeed: 13,
+ }),
+ Crossbow_1: tower({
+ id: 'Crossbow_1', hp: 6000, rAdd: 3, rangeR: 160, clock: 11,
+ bulletType: 'Arrow', bullySpeed: 10, attackBullyNum: 2,
+ }),
+ Crossbow_2: tower({
+ id: 'Crossbow_2', hp: 10000, rAdd: 5, rangeR: 200, clock: 9,
+ bulletType: 'Arrow', bullySpeed: 13, attackBullyNum: 3,
+ }),
+ Crossbow_3: tower({
+ id: 'Crossbow_3', hp: 20000, rAdd: 7, rangeR: 250, clock: 5,
+ bulletType: 'Arrow_L', bullySpeed: 15, attackBullyNum: 4,
+ }),
+
+ // ==================== Shot Towers (5) ====================
+ ThreeTubeCannon: tower({
+ id: 'ThreeTubeCannon', rAdd: 6, rangeR: 230, clock: 4,
+ bulletType: 'Bully_M', bullySpeed: 3,
+ bullyRotate: Math.PI / 12, attackBullyNum: 3, isShrapnel: true,
+ }),
+ Shotgun_1: tower({
+ id: 'Shotgun_1', hp: 5000, rAdd: 10, rangeR: 250, clock: 3,
+ bulletType: 'Bully_M', bullySpeed: 3,
+ bullyRotate: Math.PI / 10, attackBullyNum: 5, isShrapnel: true,
+ }),
+ Shotgun_2: tower({
+ id: 'Shotgun_2', hp: 10000, rAdd: 23, rangeR: 260, clock: 2,
+ bulletType: 'Bully_M', bullySpeed: 2.8,
+ bullyRotate: Math.PI / 6, attackBullyNum: 10, isShrapnel: true,
+ }),
+ ShotCannon_1: tower({
+ id: 'ShotCannon_1', hp: 5000, rAdd: 10, rangeR: 225, clock: 15,
+ bulletType: 'Bully_M', bullySpeed: 3, attackBullyNum: 40,
+ }),
+ ShotCannon_2: tower({
+ id: 'ShotCannon_2', hp: 5000, rAdd: 11, rangeR: 335, clock: 18,
+ bulletType: 'Bully_M', bullySpeed: 3, bullySlideRate: 1.1,
+ attackBullyNum: 100,
+ }),
+
+ // ==================== Artillery Towers (6) ====================
+ Artillery_1: tower({
+ id: 'Artillery_1', hp: 5000, rAdd: 7, rangeR: 300, clock: 30,
+ bulletType: 'H_S', bullySpeed: 1, bullySlideRate: 1.2,
+ }),
+ Artillery_2: tower({
+ id: 'Artillery_2', hp: 8800, rAdd: 9, rangeR: 250, clock: 35,
+ bulletType: 'H_L', bullySpeed: 1, bullySlideRate: 1.2,
+ bullyRotate: Math.PI / 12, attackBullyNum: 2, isShrapnel: true,
+ }),
+ Artillery_3: tower({
+ id: 'Artillery_3', hp: 30000, rAdd: 11, rangeR: 300, clock: 50,
+ bulletType: 'H_LL', bullySpeed: 1, bullySlideRate: 1.1,
+ bullyRotate: Math.PI / 12, attackBullyNum: 2, isShrapnel: true,
+ }),
+ MissileGun_1: tower({
+ id: 'MissileGun_1', hp: 10000, rAdd: 8, rangeR: 250, clock: 20,
+ bulletType: 'H_Target_S', bullySpeed: 7, bullySlideRate: 6,
+ }),
+ MissileGun_2: tower({
+ id: 'MissileGun_2', hp: 10000, rAdd: 11, rangeR: 250, clock: 20,
+ bulletType: 'H_Target_S', bullySpeed: 8, bullySlideRate: 6,
+ bullyRotate: Math.PI / 6, attackBullyNum: 3, isShrapnel: true,
+ }),
+ MissileGun_3: tower({
+ id: 'MissileGun_3', hp: 10000, rAdd: 15, rangeR: 250, clock: 20,
+ bulletType: 'H_Target_S', bullySpeed: 10, bullySlideRate: 6,
+ bullyRotate: Math.PI / 6, attackBullyNum: 5, isShrapnel: true,
+ }),
+
+ // ==================== Spray Towers (5) ====================
+ SprayCannon_1: tower({
+ id: 'SprayCannon_1', rAdd: 10, rangeR: 200, clock: 30,
+ bulletType: 'SS_S', bullySpeed: 5,
+ }),
+ SprayCannon_2: tower({
+ id: 'SprayCannon_2', hp: 3000, rAdd: 11, rangeR: 220, clock: 30,
+ bulletType: 'SS_M', bullySpeed: 8,
+ }),
+ SprayCannon_3: tower({
+ id: 'SprayCannon_3', hp: 5000, rAdd: 12, rangeR: 250, clock: 30,
+ bulletType: 'SS_L', bullySpeed: 11,
+ }),
+ SprayCannon_Double: tower({
+ id: 'SprayCannon_Double', hp: 10000, rAdd: 13, rangeR: 250, clock: 30,
+ bulletType: 'SS_Second', bullySpeed: 15,
+ }),
+ SprayCannon_Three: tower({
+ id: 'SprayCannon_Three', hp: 10000, rAdd: 15, rangeR: 250, clock: 30,
+ bulletType: 'SS_Third', bullySpeed: 15,
+ }),
+
+ // ==================== Stone Towers (7) ====================
+ StoneCannon: tower({
+ id: 'StoneCannon', hp: 3000, rAdd: 3, rangeR: 120, clock: 20,
+ bulletType: 'CannonStone_S', bullySpeed: 3, bullySlideRate: 1.5,
+ }),
+ StoneCannon_Far_1: tower({
+ id: 'StoneCannon_Far_1', hp: 3000, rAdd: 4, rangeR: 260, clock: 20,
+ bulletType: 'CannonStone_S', bullySpeed: 7, bullySlideRate: 2,
+ }),
+ StoneCannon_Far_2: tower({
+ id: 'StoneCannon_Far_2', rAdd: 5, rangeR: 270, clock: 20,
+ bulletType: 'CannonStone_M', bullySpeed: 7, bullySlideRate: 2.2,
+ }),
+ StoneCannon_Far_3: tower({
+ id: 'StoneCannon_Far_3', rAdd: 6, rangeR: 300, clock: 20,
+ bulletType: 'CannonStone_M', bullySpeed: 7, bullySlideRate: 2.2,
+ }),
+ StoneCannon_Power_1: tower({
+ id: 'StoneCannon_Power_1', hp: 9000, rAdd: 4, rangeR: 180, clock: 50,
+ bulletType: 'CannonStone_M', bullySpeed: 8, bullySlideRate: 1.5,
+ }),
+ StoneCannon_Power_2: tower({
+ id: 'StoneCannon_Power_2', hp: 30000, rAdd: 5, rangeR: 200, clock: 50,
+ bulletType: 'CannonStone_L', bullySpeed: 8, bullySlideRate: 2.5,
+ }),
+ StoneCannon_Power_3: tower({
+ id: 'StoneCannon_Power_3', hp: 100000, rAdd: 6, rangeR: 230, clock: 65,
+ bulletType: 'CannonStone_L', bullySpeed: 10,
+ }),
+
+ // ==================== Elemental Towers (7) ====================
+ PowderCannon: tower({
+ id: 'PowderCannon', rAdd: 5, rangeR: 150, clock: 1,
+ bulletType: 'Powder', bullySpeed: 10,
+ }),
+ Flamethrower_1: tower({
+ id: 'Flamethrower_1', hp: 5000, rAdd: 7, rangeR: 200, clock: 1,
+ bulletType: 'Fire_L', bullySpeed: 15, attackBullyNum: 2,
+ }),
+ Flamethrower_2: tower({
+ id: 'Flamethrower_2', hp: 10000, rAdd: 9, rangeR: 200, clock: 1,
+ bulletType: 'Fire_LL', bullySpeed: 18, attackBullyNum: 2,
+ }),
+ FrozenCannon_1: tower({
+ id: 'FrozenCannon_1', hp: 2000, rAdd: 7, rangeR: 150, clock: 10,
+ bulletType: 'Frozen_L', bullySpeed: 4,
+ }),
+ FrozenCannon_2: tower({
+ id: 'FrozenCannon_2', hp: 3000, rAdd: 8, rangeR: 200, clock: 3,
+ bulletType: 'Frozen_L', bullySpeed: 6, attackBullyNum: 3,
+ }),
+ Poison_1: tower({
+ id: 'Poison_1', hp: 10000, rAdd: 8, rangeR: 250, clock: 10,
+ bulletType: 'P_L', bullySpeed: 9, attackBullyNum: 10,
+ }),
+ Poison_2: tower({
+ id: 'Poison_2', hp: 15000, rAdd: 9.5, rangeR: 260, clock: 13,
+ bulletType: 'P_M', bullySpeed: 9, attackBullyNum: 10,
+ }),
+
+ // ==================== Thunder Towers (baseClass Tower only: 3) ====================
+ ThunderBall_1: tower({
+ id: 'ThunderBall_1', hp: 15000, rAdd: 7, rangeR: 280, clock: 30,
+ bulletType: 'ThunderBall', bullySpeed: 10,
+ }),
+ ThunderBall_2: tower({
+ id: 'ThunderBall_2', hp: 16000, rAdd: 12, rangeR: 290, clock: 18,
+ bulletType: 'ThunderBall', bullySpeed: 15,
+ }),
+ ThunderBall_3: tower({
+ id: 'ThunderBall_3', hp: 20000, rAdd: 13, rangeR: 300, clock: 16,
+ bulletType: 'ThunderBall', bullySpeed: 20,
+ }),
+};
+
+/** Get tower combat data by type name */
+export function getTowerCombatData(towerType: string): TowerCombatData | undefined {
+ return TOWER_COMBAT_META[towerType];
+}
diff --git a/shared/config/towerMeta.ts b/shared/config/towerMeta.ts
new file mode 100644
index 0000000..e8af62c
--- /dev/null
+++ b/shared/config/towerMeta.ts
@@ -0,0 +1,167 @@
+/**
+ * Shared Tower Metadata
+ * Minimal tower data needed for server-side validation
+ * Generated from client tower configs for P0-7 multiplayer validation
+ */
+
+import type { TowerMetaData } from '../validation/towerValidation.js';
+
+// Re-export TowerMetaData for convenience
+export type { TowerMetaData } from '../validation/towerValidation.js';
+
+/**
+ * Tower metadata registry - maps tower ID to price and upgrade paths
+ */
+export const TOWER_META: Record = {
+ // === Basic Towers ===
+ BasicCannon: { id: 'BasicCannon', price: 50, levelUpArr: ['AncientCannon', 'TraditionalCannon', 'FutureCannon_1'] },
+ AncientCannon: { id: 'AncientCannon', price: 60, levelUpArr: ['Boomerang', 'ArrowBow_1', 'Hammer', 'StoneCannon'] },
+
+ // === Laser Towers ===
+ Laser: { id: 'Laser', price: 350, levelUpArr: ['Laser_Blue_1', 'Laser_Red', 'Laser_Green_1'] },
+ Laser_Blue_1: { id: 'Laser_Blue_1', price: 600, levelUpArr: ['Laser_Blue_2', 'Laser_Hell_1'] },
+ Laser_Blue_2: { id: 'Laser_Blue_2', price: 1200, levelUpArr: ['Laser_Blue_3'] },
+ Laser_Blue_3: { id: 'Laser_Blue_3', price: 1000, levelUpArr: [] },
+ Laser_Green_1: { id: 'Laser_Green_1', price: 400, levelUpArr: ['Laser_Green_2'] },
+ Laser_Green_2: { id: 'Laser_Green_2', price: 500, levelUpArr: ['Laser_Green_3'] },
+ Laser_Green_3: { id: 'Laser_Green_3', price: 700, levelUpArr: [] },
+ Laser_Hell_1: { id: 'Laser_Hell_1', price: 2000, levelUpArr: ['Laser_Hell_2'] },
+ Laser_Hell_2: { id: 'Laser_Hell_2', price: 1000, levelUpArr: [] },
+ Laser_Red: { id: 'Laser_Red', price: 800, levelUpArr: ['Laser_Red_Alpha_1', 'Laser_Red_Beta_1'] },
+ Laser_Red_Alpha_1: { id: 'Laser_Red_Alpha_1', price: 800, levelUpArr: ['Laser_Red_Alpha_2'] },
+ Laser_Red_Alpha_2: { id: 'Laser_Red_Alpha_2', price: 1000, levelUpArr: [] },
+ Laser_Red_Beta_1: { id: 'Laser_Red_Beta_1', price: 800, levelUpArr: ['Laser_Red_Beta_2'] },
+ Laser_Red_Beta_2: { id: 'Laser_Red_Beta_2', price: 1000, levelUpArr: [] },
+
+ // === Hammer Towers ===
+ Hammer: { id: 'Hammer', price: 230, levelUpArr: ['Hammer_Fast_1', 'Hammer_Power_1'] },
+ Hammer_Fast_1: { id: 'Hammer_Fast_1', price: 300, levelUpArr: ['Hammer_Fast_2'] },
+ Hammer_Fast_2: { id: 'Hammer_Fast_2', price: 370, levelUpArr: ['Hammer_Fast_3'] },
+ Hammer_Fast_3: { id: 'Hammer_Fast_3', price: 320, levelUpArr: [] },
+ Hammer_Power_1: { id: 'Hammer_Power_1', price: 400, levelUpArr: ['Hammer_Power_2'] },
+ Hammer_Power_2: { id: 'Hammer_Power_2', price: 450, levelUpArr: ['Hammer_Power_3'] },
+ Hammer_Power_3: { id: 'Hammer_Power_3', price: 500, levelUpArr: [] },
+
+ // === Gun Towers ===
+ Rifle_1: { id: 'Rifle_1', price: 160, levelUpArr: ['Rifle_2'] },
+ Rifle_2: { id: 'Rifle_2', price: 170, levelUpArr: ['Rifle_3'] },
+ Rifle_3: { id: 'Rifle_3', price: 180, levelUpArr: [] },
+ MachineGun_1: { id: 'MachineGun_1', price: 250, levelUpArr: ['MachineGun_2'] },
+ MachineGun_2: { id: 'MachineGun_2', price: 300, levelUpArr: ['MachineGun_3'] },
+ MachineGun_3: { id: 'MachineGun_3', price: 500, levelUpArr: [] },
+ ArmorPiercing_1: { id: 'ArmorPiercing_1', price: 220, levelUpArr: ['ArmorPiercing_2'] },
+ ArmorPiercing_2: { id: 'ArmorPiercing_2', price: 250, levelUpArr: ['ArmorPiercing_3'] },
+ ArmorPiercing_3: { id: 'ArmorPiercing_3', price: 400, levelUpArr: [] },
+
+ // === Shot Towers ===
+ ThreeTubeCannon: { id: 'ThreeTubeCannon', price: 400, levelUpArr: ['Shotgun_1', 'ShotCannon_1'] },
+ Shotgun_1: { id: 'Shotgun_1', price: 500, levelUpArr: ['Shotgun_2'] },
+ Shotgun_2: { id: 'Shotgun_2', price: 800, levelUpArr: [] },
+ ShotCannon_1: { id: 'ShotCannon_1', price: 600, levelUpArr: ['ShotCannon_2'] },
+ ShotCannon_2: { id: 'ShotCannon_2', price: 900, levelUpArr: [] },
+
+ // === Artillery Towers ===
+ Artillery_1: { id: 'Artillery_1', price: 500, levelUpArr: ['Artillery_2'] },
+ Artillery_2: { id: 'Artillery_2', price: 800, levelUpArr: ['Artillery_3'] },
+ Artillery_3: { id: 'Artillery_3', price: 1000, levelUpArr: [] },
+ MissileGun_1: { id: 'MissileGun_1', price: 700, levelUpArr: ['MissileGun_2'] },
+ MissileGun_2: { id: 'MissileGun_2', price: 750, levelUpArr: ['MissileGun_3'] },
+ MissileGun_3: { id: 'MissileGun_3', price: 1000, levelUpArr: [] },
+
+ // === Arrow Towers ===
+ ArrowBow_1: { id: 'ArrowBow_1', price: 60, levelUpArr: ['ArrowBow_2', 'Crossbow_1'] },
+ ArrowBow_2: { id: 'ArrowBow_2', price: 70, levelUpArr: ['ArrowBow_3'] },
+ ArrowBow_3: { id: 'ArrowBow_3', price: 80, levelUpArr: ['ArrowBow_4'] },
+ ArrowBow_4: { id: 'ArrowBow_4', price: 150, levelUpArr: [] },
+ Crossbow_1: { id: 'Crossbow_1', price: 120, levelUpArr: ['Crossbow_2'] },
+ Crossbow_2: { id: 'Crossbow_2', price: 130, levelUpArr: ['Crossbow_3'] },
+ Crossbow_3: { id: 'Crossbow_3', price: 200, levelUpArr: [] },
+
+ // === Spray Towers ===
+ SprayCannon_1: { id: 'SprayCannon_1', price: 250, levelUpArr: ['SprayCannon_2', 'SprayCannon_Double'] },
+ SprayCannon_2: { id: 'SprayCannon_2', price: 360, levelUpArr: ['SprayCannon_3'] },
+ SprayCannon_3: { id: 'SprayCannon_3', price: 500, levelUpArr: [] },
+ SprayCannon_Double: { id: 'SprayCannon_Double', price: 600, levelUpArr: ['SprayCannon_Three'] },
+ SprayCannon_Three: { id: 'SprayCannon_Three', price: 900, levelUpArr: [] },
+
+ // === Boomerang Towers ===
+ Boomerang: { id: 'Boomerang', price: 190, levelUpArr: ['Boomerang_Far_1', 'Boomerang_Power_1'] },
+ Boomerang_Far_1: { id: 'Boomerang_Far_1', price: 230, levelUpArr: ['Boomerang_Far_2'] },
+ Boomerang_Far_2: { id: 'Boomerang_Far_2', price: 350, levelUpArr: ['Boomerang_Far_3'] },
+ Boomerang_Far_3: { id: 'Boomerang_Far_3', price: 300, levelUpArr: [] },
+ Boomerang_Power_1: { id: 'Boomerang_Power_1', price: 300, levelUpArr: ['Boomerang_Power_2'] },
+ Boomerang_Power_2: { id: 'Boomerang_Power_2', price: 350, levelUpArr: ['Boomerang_Power_3'] },
+ Boomerang_Power_3: { id: 'Boomerang_Power_3', price: 400, levelUpArr: [] },
+
+ // === Stone Towers ===
+ StoneCannon: { id: 'StoneCannon', price: 100, levelUpArr: ['StoneCannon_Far_1', 'StoneCannon_Power_1'] },
+ StoneCannon_Far_1: { id: 'StoneCannon_Far_1', price: 200, levelUpArr: ['StoneCannon_Far_2'] },
+ StoneCannon_Far_2: { id: 'StoneCannon_Far_2', price: 250, levelUpArr: ['StoneCannon_Far_3'] },
+ StoneCannon_Far_3: { id: 'StoneCannon_Far_3', price: 300, levelUpArr: [] },
+ StoneCannon_Power_1: { id: 'StoneCannon_Power_1', price: 120, levelUpArr: ['StoneCannon_Power_2'] },
+ StoneCannon_Power_2: { id: 'StoneCannon_Power_2', price: 300, levelUpArr: ['StoneCannon_Power_3'] },
+ StoneCannon_Power_3: { id: 'StoneCannon_Power_3', price: 320, levelUpArr: [] },
+
+ // === Traditional Towers ===
+ TraditionalCannon: { id: 'TraditionalCannon', price: 120, levelUpArr: ['TraditionalCannon_Small', 'TraditionalCannon_Middle', 'TraditionalCannon_Large', 'TraditionalCannon_MultiTube'] },
+ TraditionalCannon_Small: { id: 'TraditionalCannon_Small', price: 120, levelUpArr: ['Rifle_1', 'MachineGun_1', 'ArmorPiercing_1'] },
+ TraditionalCannon_Middle: { id: 'TraditionalCannon_Middle', price: 130, levelUpArr: ['AirCannon_1', 'Earthquake'] },
+ TraditionalCannon_Large: { id: 'TraditionalCannon_Large', price: 140, levelUpArr: ['Artillery_1', 'MissileGun_1'] },
+ TraditionalCannon_MultiTube: { id: 'TraditionalCannon_MultiTube', price: 135, levelUpArr: ['ThreeTubeCannon', 'SprayCannon_1', 'PowderCannon'] },
+
+ // === Air Towers ===
+ AirCannon_1: { id: 'AirCannon_1', price: 300, levelUpArr: ['AirCannon_2'] },
+ AirCannon_2: { id: 'AirCannon_2', price: 320, levelUpArr: ['AirCannon_3'] },
+ AirCannon_3: { id: 'AirCannon_3', price: 400, levelUpArr: [] },
+
+ // === Thunder Towers ===
+ Thunder_1: { id: 'Thunder_1', price: 400, levelUpArr: ['Thunder_2', 'ThunderBall_1'] },
+ Thunder_2: { id: 'Thunder_2', price: 600, levelUpArr: ['Thunder_Far_1', 'Thunder_Power_1'] },
+ Thunder_Far_1: { id: 'Thunder_Far_1', price: 600, levelUpArr: ['Thunder_Far_2'] },
+ Thunder_Far_2: { id: 'Thunder_Far_2', price: 600, levelUpArr: [] },
+ Thunder_Power_1: { id: 'Thunder_Power_1', price: 400, levelUpArr: ['Thunder_Power_2'] },
+ Thunder_Power_2: { id: 'Thunder_Power_2', price: 1000, levelUpArr: [] },
+ ThunderBall_1: { id: 'ThunderBall_1', price: 1000, levelUpArr: ['ThunderBall_2'] },
+ ThunderBall_2: { id: 'ThunderBall_2', price: 600, levelUpArr: ['ThunderBall_3'] },
+ ThunderBall_3: { id: 'ThunderBall_3', price: 600, levelUpArr: [] },
+
+ // === Earthquake Towers ===
+ Earthquake: { id: 'Earthquake', price: 250, levelUpArr: ['Earthquake_Power_1', 'Earthquake_Speed_1'] },
+ Earthquake_Power_1: { id: 'Earthquake_Power_1', price: 260, levelUpArr: ['Earthquake_Power_2'] },
+ Earthquake_Power_2: { id: 'Earthquake_Power_2', price: 300, levelUpArr: [] },
+ Earthquake_Speed_1: { id: 'Earthquake_Speed_1', price: 260, levelUpArr: ['Earthquake_Speed_2'] },
+ Earthquake_Speed_2: { id: 'Earthquake_Speed_2', price: 400, levelUpArr: [] },
+
+ // === Elemental Towers ===
+ PowderCannon: { id: 'PowderCannon', price: 260, levelUpArr: ['Flamethrower_1', 'FrozenCannon_1', 'Poison_1'] },
+ Flamethrower_1: { id: 'Flamethrower_1', price: 420, levelUpArr: ['Flamethrower_2'] },
+ Flamethrower_2: { id: 'Flamethrower_2', price: 500, levelUpArr: [] },
+ FrozenCannon_1: { id: 'FrozenCannon_1', price: 620, levelUpArr: ['FrozenCannon_2'] },
+ FrozenCannon_2: { id: 'FrozenCannon_2', price: 1200, levelUpArr: [] },
+ Poison_1: { id: 'Poison_1', price: 400, levelUpArr: ['Poison_2'] },
+ Poison_2: { id: 'Poison_2', price: 600, levelUpArr: [] },
+
+ // === Future Towers ===
+ FutureCannon_1: { id: 'FutureCannon_1', price: 800, levelUpArr: ['FutureCannon_2', 'Thunder_1', 'Laser'] },
+ FutureCannon_2: { id: 'FutureCannon_2', price: 300, levelUpArr: ['FutureCannon_3'] },
+ FutureCannon_3: { id: 'FutureCannon_3', price: 600, levelUpArr: ['FutureCannon_4'] },
+ FutureCannon_4: { id: 'FutureCannon_4', price: 800, levelUpArr: ['FutureCannon_5'] },
+ FutureCannon_5: { id: 'FutureCannon_5', price: 1200, levelUpArr: [] },
+
+ // === Manual Cannon (Multiplayer) ===
+ ManualCannon: { id: 'ManualCannon', price: 800, levelUpArr: [] },
+};
+
+/**
+ * Get tower metadata by ID
+ */
+export function getTowerMeta(towerType: string): TowerMetaData | undefined {
+ return TOWER_META[towerType];
+}
+
+/**
+ * Check if a tower type exists
+ */
+export function isTowerTypeValid(towerType: string): boolean {
+ return towerType in TOWER_META;
+}
diff --git a/shared/config/visionMeta.ts b/shared/config/visionMeta.ts
new file mode 100644
index 0000000..2771f68
--- /dev/null
+++ b/shared/config/visionMeta.ts
@@ -0,0 +1,83 @@
+/**
+ * Shared Vision Configuration
+ * Used by both server and client for vision calculations.
+ * Client-only rendering params remain in src/systems/fog/visionConfig.ts.
+ */
+
+/** Vision type enum - shared between server and client */
+export enum VisionType {
+ NONE = 'none',
+ OBSERVER = 'observer',
+ RADAR = 'radar'
+}
+
+// --- Basic vision radii ---
+export const HEADQUARTERS_VISION = 200;
+export const BASIC_TOWER_VISION = 120;
+
+// --- Observer tower config ---
+export const OBSERVER_RADIUS: Record = { 1: 180, 2: 250, 3: 350 };
+export const OBSERVER_PRICE: Record = { 1: 60, 2: 120, 3: 180 };
+export const OBSERVER_MAX_LEVEL = 3;
+
+// --- Radar tower config ---
+export const RADAR_RADIUS: Record = { 1: 300, 2: 550, 3: 800, 4: 1050, 5: 1300 };
+export const RADAR_PRICE: Record = { 1: 100, 2: 150, 3: 200, 4: 250, 5: 300 };
+export const RADAR_MAX_LEVEL = 5;
+export const RADAR_SWEEP_ANGLE = Math.PI / 6; // 30 degree sweep width
+export const RADAR_SWEEP_SPEED = 0.03; // Rotation speed (rad/frame)
+
+/**
+ * Get vision radius for given type and level.
+ * Returns basic tower vision for NONE or unknown types.
+ */
+export function getVisionRadius(type: VisionType | string, level: number): number {
+ switch (type) {
+ case VisionType.OBSERVER:
+ return OBSERVER_RADIUS[level] || BASIC_TOWER_VISION;
+ case VisionType.RADAR: {
+ if (RADAR_RADIUS[level] !== undefined) {
+ return RADAR_RADIUS[level];
+ }
+ const maxLevel = Math.max(...Object.keys(RADAR_RADIUS).map(Number));
+ return RADAR_RADIUS[maxLevel] || BASIC_TOWER_VISION;
+ }
+ default:
+ return BASIC_TOWER_VISION;
+ }
+}
+
+/**
+ * Get price for upgrading to next vision level.
+ * Returns 0 if upgrade is not possible.
+ */
+export function getVisionUpgradePrice(
+ currentType: VisionType | string,
+ currentLevel: number,
+ targetType: VisionType | string
+): number {
+ const nextLevel = currentType === targetType ? currentLevel + 1 : 1;
+ if (targetType === VisionType.OBSERVER) {
+ return OBSERVER_PRICE[nextLevel] || 0;
+ } else if (targetType === VisionType.RADAR) {
+ return RADAR_PRICE[nextLevel] || 0;
+ }
+ return 0;
+}
+
+/**
+ * Check if a tower can upgrade its vision to the specified type.
+ * Returns false if the tower already has a different vision type,
+ * or if the tower is already at max level for this type.
+ */
+export function canUpgradeVision(
+ currentType: VisionType | string,
+ currentLevel: number,
+ targetType: VisionType | string
+): boolean {
+ if (currentType !== VisionType.NONE && currentType !== targetType) {
+ return false;
+ }
+ const maxLevel = targetType === VisionType.OBSERVER ? OBSERVER_MAX_LEVEL : RADAR_MAX_LEVEL;
+ return currentLevel < maxLevel;
+}
diff --git a/shared/constants/index.ts b/shared/constants/index.ts
new file mode 100644
index 0000000..b07b16c
--- /dev/null
+++ b/shared/constants/index.ts
@@ -0,0 +1 @@
+export { SPEED_SCALE_FACTOR, scaleSpeed, scalePeriod } from './speedScale.js';
diff --git a/shared/constants/speedScale.ts b/shared/constants/speedScale.ts
new file mode 100644
index 0000000..d86a988
--- /dev/null
+++ b/shared/constants/speedScale.ts
@@ -0,0 +1,21 @@
+/**
+ * Speed scale constants for game calculations
+ * Shared between client and server
+ *
+ * Speed scale factor reduces tick calculations:
+ * Setting to 3 means: speed x3, period /3, so 1x speed = original 3x effect
+ */
+
+export const SPEED_SCALE_FACTOR = 3;
+
+/**
+ * Scale speed value (multiply by factor)
+ * Used for linear speeds (movement, bullet velocity, etc.)
+ */
+export const scaleSpeed = (v: number): number => v * SPEED_SCALE_FACTOR;
+
+/**
+ * Scale period/interval value (divide by factor)
+ * Used for time periods/intervals (attack cooldown, animation period, etc.)
+ */
+export const scalePeriod = (v: number): number => Math.max(1, Math.round(v / SPEED_SCALE_FACTOR));
diff --git a/shared/formulas/gameBalance.ts b/shared/formulas/gameBalance.ts
new file mode 100644
index 0000000..6aca5cb
--- /dev/null
+++ b/shared/formulas/gameBalance.ts
@@ -0,0 +1,178 @@
+/**
+ * Game balance formulas
+ * Shared between client and server
+ *
+ * Pure functions for calculating monster stats, wave parameters, rewards, etc.
+ */
+
+// ============================================================================
+// Monster HP scaling
+// ============================================================================
+
+/** Monster HP increase based on world tick */
+export function timeMonsterHp(t: number): number {
+ return t / 3;
+}
+
+export function timeMonsterAtt(t: number): number {
+ return t / 5;
+}
+
+/** Monster HP cap based on wave - Easy difficulty */
+export function levelMonsterHpAddedEasy(level: number): number {
+ let res = Math.floor(Math.pow(level, 2) + Math.pow(level, 0.5) * 60);
+ if (res > 5000) {
+ res = 5000;
+ }
+ return res;
+}
+
+/** Monster HP cap based on wave - Normal difficulty */
+export function levelMonsterHpAddedNormal(level: number): number {
+ let res = Math.floor(Math.pow(level, 2.5) + Math.pow(level, 0.5) * 60);
+ if (res > 100000) {
+ res = 100000;
+ }
+ return res;
+}
+
+/** Monster HP cap based on wave - Hard difficulty */
+export function levelMonsterHpAddedHard(level: number): number {
+ return Math.floor(Math.pow(level, 2.7) + Math.pow(level, 0.5) * 60);
+}
+
+/** Monster HP increase based on tick - Easy */
+export function tickMonsterHpAddedEasy(t: number): number {
+ return Math.floor(Math.pow(t / 500, 2.5) + Math.pow(t / 500, 0.5) * 60);
+}
+
+/** Monster HP increase based on tick - Hard */
+export function tickMonsterHpAddedHard(t: number): number {
+ return Math.floor(Math.pow(t / 500, 2.52) + Math.pow(t / 500, 0.5) * 60);
+}
+
+// ============================================================================
+// Wave / spawn count
+// ============================================================================
+
+/** Monster spawn count based on time */
+export function timeMonsterAddedNum(t: number): number {
+ const res = Math.floor(Math.pow(t / 20, 0.2));
+ return res < 0 ? 1 : res;
+}
+
+/** Total monsters in wave n - Normal difficulty */
+export function levelMonsterFlowNum(level: number): number {
+ let res = Math.floor(Math.pow(level / 2, 1.1)) + 2;
+ res += Math.log(level + 1) * 5;
+ res = Math.floor(res);
+ return res <= 0 ? 1 : res;
+}
+
+/** Total monsters in wave n - Hard difficulty */
+export function levelMonsterFlowNumHard(level: number): number {
+ let res = Math.floor(Math.pow(level / 2, 1.35)) + 2;
+ res += Math.log(level + 1) * 10;
+ res = Math.floor(res);
+ return res <= 0 ? 1 : res;
+}
+
+/** Monster spawn count per tick - Easy (endless mode) */
+export function tickAddMonsterNumEasy(t: number): number {
+ return Math.floor(t / 200);
+}
+
+/** Monster spawn count per tick - Hard (endless mode) */
+export function tickAddMonsterNumHard(t: number): number {
+ return Math.floor(t / 180);
+}
+
+/** T800 count based on wave */
+export function levelT800Count(level: number): number {
+ const res = Math.floor(Math.pow(level, 1.2) / 20);
+ const count = res < 1 ? 1 : res;
+ return count > 10 ? 10 : count;
+}
+
+export function levelT800CountHard(level: number): number {
+ const res = Math.floor(Math.pow(level, 1.5) / 10);
+ return res < 1 ? 1 : res;
+}
+
+// ============================================================================
+// Rewards / economy
+// ============================================================================
+
+/** Monster kill reward based on time */
+export function timeAddPrise(tick: number): number {
+ let res = Math.floor(Math.log10(tick));
+ if (res <= 0) {
+ res = 1;
+ }
+ return res;
+}
+
+/** Monster kill reward based on wave - Easy */
+export function levelAddPrice(level: number): number {
+ return Math.floor(Math.log(level) * (level / 10)) + 10;
+}
+
+/** Monster kill reward based on wave - Normal */
+export function levelAddPriceNormal(level: number): number {
+ return Math.floor(Math.log(level) * (level / 10));
+}
+
+/** Monster kill reward based on wave - Hard */
+export function levelAddPriceHard(level: number): number {
+ return Math.floor(Math.log(level) * (level / 20));
+}
+
+/** Tower price increase based on count */
+export function TowerNumPriceAdded(num: number): number {
+ if (num < 8) return 0;
+ const n = num - 7;
+ return Math.floor((n * n * n * n) / 3);
+}
+
+export function TowerNumPriceAdded2(num: number): number {
+ let x = num - 6;
+ if (x < 0) {
+ x = 0;
+ }
+ return Math.floor(Math.pow(x, 1.7));
+}
+
+// ============================================================================
+// Combat
+// ============================================================================
+
+/** Monster collision damage based on wave */
+export function levelCollideAdded(level: number): number {
+ return Math.floor(Math.pow(level, 1.55));
+}
+
+export function levelCollideAddedHard(level: number): number {
+ return Math.floor(Math.pow(level, 2));
+}
+
+/** Hell tower damage based on lock time */
+export function timeHellTowerDamage_E(tick: number): number {
+ return Math.exp(tick) / 100000_0000;
+}
+
+export function timeHellTowerDamage(tick: number): number {
+ return Math.pow(tick, 2) / 1000;
+}
+
+// ============================================================================
+// Visual effects
+// ============================================================================
+
+/** Effect alpha based on progress (tr: 0~1) */
+export function timeRateAlpha(tr: number): number {
+ return (1 - tr) * 0.25;
+}
+
+export function timeRateAlphaDownFast(tr: number): number {
+ return Math.pow((1 - tr) * 0.25, 2);
+}
diff --git a/shared/formulas/index.ts b/shared/formulas/index.ts
new file mode 100644
index 0000000..d2c86a8
--- /dev/null
+++ b/shared/formulas/index.ts
@@ -0,0 +1 @@
+export * from './gameBalance.js';
diff --git a/shared/index.ts b/shared/index.ts
new file mode 100644
index 0000000..ba3b80e
--- /dev/null
+++ b/shared/index.ts
@@ -0,0 +1,9 @@
+/**
+ * Shared module - common code between client and server
+ */
+export * from './math/index.js';
+export * from './constants/index.js';
+export * from './config/index.js';
+export * from './formulas/index.js';
+export * from './types/index.js';
+export * from './validation/index.js';
diff --git a/shared/math/circleCollision.ts b/shared/math/circleCollision.ts
new file mode 100644
index 0000000..5dd4af7
--- /dev/null
+++ b/shared/math/circleCollision.ts
@@ -0,0 +1,170 @@
+/**
+ * Circle collision detection utilities
+ * Shared between client and server
+ *
+ * Pure functions for static and sweep collision detection between circles.
+ * Critical for bullet-monster collision in the two-phase update system.
+ */
+
+/**
+ * Check if two circles collide (static)
+ */
+export function collides(
+ x1: number,
+ y1: number,
+ r1: number,
+ x2: number,
+ y2: number,
+ r2: number,
+): boolean {
+ const dx = x2 - x1;
+ const dy = y2 - y1;
+ const distSq = dx * dx + dy * dy;
+ const radiusSum = r1 + r2;
+ return distSq <= radiusSum * radiusSum;
+}
+
+/**
+ * Sweep collision: moving circle vs static circle
+ * Checks if a circle moving from (x1,y1) to (x2,y2) collides with static circle
+ * @param x1 Moving circle start x
+ * @param y1 Moving circle start y
+ * @param x2 Moving circle end x
+ * @param y2 Moving circle end y
+ * @param movingR Moving circle radius
+ * @param cx Static circle center x
+ * @param cy Static circle center y
+ * @param targetR Static circle radius
+ * @returns true if collision occurs during movement
+ */
+export function sweepCollides(
+ x1: number,
+ y1: number,
+ x2: number,
+ y2: number,
+ movingR: number,
+ cx: number,
+ cy: number,
+ targetR: number,
+): boolean {
+ const combinedR = movingR + targetR;
+ const combinedRSq = combinedR * combinedR;
+
+ // Quick check: start point already in collision range
+ const dx1 = x1 - cx;
+ const dy1 = y1 - cy;
+ if (dx1 * dx1 + dy1 * dy1 <= combinedRSq) return true;
+
+ // Quick check: end point already in collision range
+ const dx2 = x2 - cx;
+ const dy2 = y2 - cy;
+ if (dx2 * dx2 + dy2 * dy2 <= combinedRSq) return true;
+
+ // Line segment direction vector
+ const dx = x2 - x1;
+ const dy = y2 - y1;
+ const lenSq = dx * dx + dy * dy;
+ if (lenSq === 0) return false;
+
+ // Solve quadratic: at^2 + bt + c = 0
+ // Find closest point on segment, check if in t in [0,1] range
+ const fx = x1 - cx;
+ const fy = y1 - cy;
+ const a = lenSq;
+ const b = 2 * (fx * dx + fy * dy);
+ const c = fx * fx + fy * fy - combinedRSq;
+
+ const discriminant = b * b - 4 * a * c;
+ if (discriminant < 0) return false;
+
+ const sqrtD = Math.sqrt(discriminant);
+ const t1 = (-b - sqrtD) / (2 * a);
+ const t2 = (-b + sqrtD) / (2 * a);
+
+ // Check if any solution in [0,1] range
+ return (t1 >= 0 && t1 <= 1) || (t2 >= 0 && t2 <= 1);
+}
+
+/**
+ * Relative velocity sweep collision: two moving circles
+ * Transforms to target's reference frame (target appears static)
+ * @param ax1 Object A start x
+ * @param ay1 Object A start y
+ * @param ax2 Object A end x
+ * @param ay2 Object A end y
+ * @param aR Object A radius
+ * @param bx1 Object B start x
+ * @param by1 Object B start y
+ * @param bx2 Object B end x
+ * @param by2 Object B end y
+ * @param bR Object B radius
+ * @returns true if collision occurs during movement
+ */
+export function sweepCollidesRelative(
+ ax1: number,
+ ay1: number,
+ ax2: number,
+ ay2: number,
+ aR: number,
+ bx1: number,
+ by1: number,
+ bx2: number,
+ by2: number,
+ bR: number,
+): boolean {
+ // Transform to B's reference frame: B appears static at origin
+ const relX1 = ax1 - bx1;
+ const relY1 = ay1 - by1;
+ // A's relative end = relative start + (A displacement - B displacement)
+ const relX2 = relX1 + (ax2 - ax1) - (bx2 - bx1);
+ const relY2 = relY1 + (ay2 - ay1) - (by2 - by1);
+
+ return sweepCollides(relX1, relY1, relX2, relY2, aR, 0, 0, bR);
+}
+
+/**
+ * Get collision time (t in [0,1]) for sweep collision
+ * Returns -1 if no collision
+ */
+export function sweepCollisionTime(
+ x1: number,
+ y1: number,
+ x2: number,
+ y2: number,
+ movingR: number,
+ cx: number,
+ cy: number,
+ targetR: number,
+): number {
+ const combinedR = movingR + targetR;
+ const combinedRSq = combinedR * combinedR;
+
+ // Check if already colliding at start
+ const dx1 = x1 - cx;
+ const dy1 = y1 - cy;
+ if (dx1 * dx1 + dy1 * dy1 <= combinedRSq) return 0;
+
+ const dx = x2 - x1;
+ const dy = y2 - y1;
+ const lenSq = dx * dx + dy * dy;
+ if (lenSq === 0) return -1;
+
+ const fx = x1 - cx;
+ const fy = y1 - cy;
+ const a = lenSq;
+ const b = 2 * (fx * dx + fy * dy);
+ const c = fx * fx + fy * fy - combinedRSq;
+
+ const discriminant = b * b - 4 * a * c;
+ if (discriminant < 0) return -1;
+
+ const sqrtD = Math.sqrt(discriminant);
+ const t1 = (-b - sqrtD) / (2 * a);
+
+ if (t1 >= 0 && t1 <= 1) return t1;
+
+ const t2 = (-b + sqrtD) / (2 * a);
+ if (t2 >= 0 && t2 <= 1) return t2;
+
+ return -1;
+}
diff --git a/shared/math/index.ts b/shared/math/index.ts
new file mode 100644
index 0000000..435e404
--- /dev/null
+++ b/shared/math/index.ts
@@ -0,0 +1,26 @@
+export {
+ collides,
+ sweepCollides,
+ sweepCollidesRelative,
+ sweepCollisionTime,
+} from './circleCollision.js';
+
+export type { Vec2 } from './vector.js';
+export {
+ add,
+ sub,
+ mul,
+ distSq,
+ dist,
+ magSq,
+ mag,
+ normalize,
+ dot,
+ rotate,
+ rotate90,
+ toAngle,
+ fromAngle,
+ lerp,
+ randUnit,
+ zero,
+} from './vector.js';
diff --git a/shared/math/vector.ts b/shared/math/vector.ts
new file mode 100644
index 0000000..f5fcaf1
--- /dev/null
+++ b/shared/math/vector.ts
@@ -0,0 +1,137 @@
+/**
+ * Vector utilities for server-side calculations
+ * Pure functions for 2D vector operations (no class instantiation for performance)
+ */
+
+export interface Vec2 {
+ x: number;
+ y: number;
+}
+
+/**
+ * Add two vectors
+ */
+export function add(a: Vec2, b: Vec2): Vec2 {
+ return { x: a.x + b.x, y: a.y + b.y };
+}
+
+/**
+ * Subtract b from a
+ */
+export function sub(a: Vec2, b: Vec2): Vec2 {
+ return { x: a.x - b.x, y: a.y - b.y };
+}
+
+/**
+ * Multiply vector by scalar
+ */
+export function mul(v: Vec2, n: number): Vec2 {
+ return { x: v.x * n, y: v.y * n };
+}
+
+/**
+ * Get squared distance between two points (faster, no sqrt)
+ */
+export function distSq(a: Vec2, b: Vec2): number {
+ const dx = a.x - b.x;
+ const dy = a.y - b.y;
+ return dx * dx + dy * dy;
+}
+
+/**
+ * Get distance between two points
+ */
+export function dist(a: Vec2, b: Vec2): number {
+ return Math.sqrt(distSq(a, b));
+}
+
+/**
+ * Get squared magnitude (faster, no sqrt)
+ */
+export function magSq(v: Vec2): number {
+ return v.x * v.x + v.y * v.y;
+}
+
+/**
+ * Get magnitude
+ */
+export function mag(v: Vec2): number {
+ return Math.sqrt(magSq(v));
+}
+
+/**
+ * Normalize to unit vector
+ */
+export function normalize(v: Vec2): Vec2 {
+ const m = mag(v);
+ if (m === 0) return { x: 0, y: 0 };
+ return { x: v.x / m, y: v.y / m };
+}
+
+/**
+ * Dot product
+ */
+export function dot(a: Vec2, b: Vec2): number {
+ return a.x * b.x + a.y * b.y;
+}
+
+/**
+ * Rotate vector by angle (radians)
+ */
+export function rotate(v: Vec2, angle: number): Vec2 {
+ const cos = Math.cos(angle);
+ const sin = Math.sin(angle);
+ return {
+ x: v.x * cos - v.y * sin,
+ y: v.x * sin + v.y * cos,
+ };
+}
+
+/**
+ * Rotate 90 degrees counter-clockwise
+ */
+export function rotate90(v: Vec2): Vec2 {
+ return { x: -v.y, y: v.x };
+}
+
+/**
+ * Convert to angle (radians)
+ */
+export function toAngle(v: Vec2): number {
+ return Math.atan2(v.x, v.y);
+}
+
+/**
+ * Create vector from angle and magnitude
+ */
+export function fromAngle(angle: number, magnitude: number = 1): Vec2 {
+ return {
+ x: Math.sin(angle) * magnitude,
+ y: Math.cos(angle) * magnitude,
+ };
+}
+
+/**
+ * Linear interpolation between two vectors
+ */
+export function lerp(a: Vec2, b: Vec2, t: number): Vec2 {
+ return {
+ x: a.x + (b.x - a.x) * t,
+ y: a.y + (b.y - a.y) * t,
+ };
+}
+
+/**
+ * Random unit vector
+ */
+export function randUnit(): Vec2 {
+ const angle = Math.random() * Math.PI * 2;
+ return { x: Math.sin(angle), y: Math.cos(angle) };
+}
+
+/**
+ * Zero vector
+ */
+export function zero(): Vec2 {
+ return { x: 0, y: 0 };
+}
diff --git a/shared/tsconfig.json b/shared/tsconfig.json
new file mode 100644
index 0000000..5fa55ad
--- /dev/null
+++ b/shared/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "declaration": true,
+ "declarationMap": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true
+ }
+}
diff --git a/shared/types/index.ts b/shared/types/index.ts
new file mode 100644
index 0000000..59f5d0c
--- /dev/null
+++ b/shared/types/index.ts
@@ -0,0 +1,36 @@
+export type { OwnedEntity, OwnedEntityString } from './ownership.js';
+export {
+ isEnemy,
+ isFriendly,
+ belongsTo,
+ isNeutral,
+ filterEnemies,
+ filterFriendlies,
+} from './ownership.js';
+
+export {
+ ClientMessage,
+ ServerMessage,
+ type ClientMessageType,
+ type ServerMessageType,
+ type BuildTowerPayload,
+ type UpgradeTowerPayload,
+ type SellTowerPayload,
+ type SpawnMonsterPayload,
+ type CannonAimPayload,
+ type CannonFirePayload,
+ type CannonSetAutoTargetPayload,
+ type ChatMessagePayload,
+ type GameEndedPayload,
+ type WaveStartingPayload,
+ type MonsterDamagedPayload,
+ type MonsterKilledPayload,
+ type BuildingDamagedPayload,
+ type BuildingDestroyedPayload,
+ type PlayerEliminatedPayload,
+ type ErrorPayload,
+ type ActionRejectedPayload,
+ type BulletFiredPayload,
+ type BulletHitPayload,
+ type BulletExplosionPayload,
+} from './messages.js';
diff --git a/shared/types/messages.ts b/shared/types/messages.ts
new file mode 100644
index 0000000..f467d1b
--- /dev/null
+++ b/shared/types/messages.ts
@@ -0,0 +1,320 @@
+/**
+ * Network Message Types
+ * Define all client-server message types
+ */
+
+/**
+ * Client -> Server Messages
+ */
+export const ClientMessage = {
+ // Player actions
+ PLAYER_READY: 'player_ready',
+ PLAYER_NOT_READY: 'player_not_ready',
+
+ // Building actions
+ BUILD_TOWER: 'build_tower',
+ UPGRADE_TOWER: 'upgrade_tower',
+ SELL_TOWER: 'sell_tower',
+ BUILD_SPAWNER: 'build_spawner',
+ BUILD_BUILDING: 'build_building',
+
+ // Spawner actions
+ SPAWN_MONSTER: 'spawn_monster',
+ SET_SPAWN_TARGET: 'set_spawn_target',
+
+ // Manual cannon actions
+ CANNON_AIM: 'cannon_aim',
+ CANNON_FIRE: 'cannon_fire',
+ CANNON_SET_AUTO_TARGET: 'cannon_set_auto_target',
+
+ // Mine actions
+ UPGRADE_MINE: 'upgrade_mine',
+ REPAIR_MINE: 'repair_mine',
+ DOWNGRADE_MINE: 'downgrade_mine',
+ SELL_MINE: 'sell_mine',
+
+ // Vision actions
+ UPGRADE_VISION: 'upgrade_vision',
+
+ // Game control
+ SURRENDER: 'surrender',
+ PAUSE_REQUEST: 'pause_request',
+ RESUME_REQUEST: 'resume_request',
+
+ // Chat (optional)
+ CHAT_MESSAGE: 'chat_message',
+} as const;
+
+export type ClientMessageType = (typeof ClientMessage)[keyof typeof ClientMessage];
+
+/**
+ * Server -> Client Messages
+ */
+export const ServerMessage = {
+ // Game state events
+ GAME_STARTING: 'game_starting',
+ GAME_STARTED: 'game_started',
+ GAME_PAUSED: 'game_paused',
+ GAME_RESUMED: 'game_resumed',
+ GAME_ENDED: 'game_ended',
+
+ // Wave events
+ WAVE_STARTING: 'wave_starting',
+ WAVE_COMPLETED: 'wave_completed',
+
+ // Combat events (for client visual effects)
+ BULLET_FIRED: 'bullet_fired',
+ BULLET_HIT: 'bullet_hit',
+ BULLET_EXPLOSION: 'bullet_explosion',
+ MONSTER_DAMAGED: 'monster_damaged',
+ MONSTER_KILLED: 'monster_killed',
+ BUILDING_DAMAGED: 'building_damaged',
+ BUILDING_DESTROYED: 'building_destroyed',
+
+ // Mine events
+ MINE_DESTROYED: 'mine_destroyed',
+
+ // Player events
+ PLAYER_JOINED: 'player_joined',
+ PLAYER_LEFT: 'player_left',
+ PLAYER_ELIMINATED: 'player_eliminated',
+ PLAYER_DISCONNECTED: 'player_disconnected',
+ PLAYER_RECONNECTED: 'player_reconnected',
+
+ // Error messages
+ ERROR: 'error',
+ ACTION_REJECTED: 'action_rejected',
+
+ // Chat (optional)
+ CHAT_MESSAGE: 'chat_message',
+
+ // Territory sync
+ TERRITORY_SYNC: 'territory_sync',
+} as const;
+
+export type ServerMessageType = (typeof ServerMessage)[keyof typeof ServerMessage];
+
+/**
+ * Lobby Messages (used by both client and server lobby rooms)
+ */
+export const LobbyMessage = {
+ // Client -> Server
+ CREATE_ROOM: 'create_room',
+ JOIN_ROOM: 'join_room',
+ QUICK_MATCH: 'quick_match',
+ CANCEL_SEARCH: 'cancel_search',
+ REFRESH_ROOMS: 'refresh_rooms',
+
+ // Server -> Client
+ ROOM_CREATED: 'room_created',
+ MATCH_FOUND: 'match_found',
+ ROOM_LIST_UPDATED: 'room_list_updated',
+ ERROR: 'error',
+} as const;
+
+export type LobbyMessageType = (typeof LobbyMessage)[keyof typeof LobbyMessage];
+
+/**
+ * Message payload types
+ */
+
+// Client messages
+export interface BuildTowerPayload {
+ towerType: string;
+ x: number;
+ y: number;
+}
+
+export interface UpgradeTowerPayload {
+ towerId: string;
+ targetType: string; // Required: target tower type for upgrade
+}
+
+export interface SellTowerPayload {
+ towerId: string;
+}
+
+export interface BuildBuildingPayload {
+ buildingType: string;
+ x: number;
+ y: number;
+}
+
+export interface UpgradeVisionPayload {
+ towerId: string;
+ visionType: 'observer' | 'radar';
+}
+
+export interface SpawnMonsterPayload {
+ spawnerId: string;
+ monsterType: string;
+ targetPlayerId: string;
+}
+
+export interface CannonAimPayload {
+ towerId: string;
+ targetX: number;
+ targetY: number;
+}
+
+export interface CannonFirePayload {
+ towerId: string;
+ targetX: number;
+ targetY: number;
+}
+
+export interface CannonSetAutoTargetPayload {
+ towerId: string;
+ targetX: number;
+ targetY: number;
+ radius: number;
+ clear?: boolean;
+}
+
+export interface ChatMessagePayload {
+ message: string;
+}
+
+// Server messages
+export interface GameEndedPayload {
+ winnerId: string;
+ reason: string;
+ stats: {
+ playerId: string;
+ towersBuilt: number;
+ monstersKilled: number;
+ monstersSpawned: number;
+ }[];
+}
+
+export interface WaveStartingPayload {
+ waveNumber: number;
+ monsterCount: number;
+}
+
+export interface MonsterDamagedPayload {
+ monsterId: string;
+ damage: number;
+ sourceId: string;
+}
+
+export interface MonsterKilledPayload {
+ monsterId: string;
+ killerId: string;
+ reward: number;
+}
+
+export interface BuildingDamagedPayload {
+ buildingId: string;
+ damage: number;
+ sourceId: string;
+}
+
+export interface BuildingDestroyedPayload {
+ buildingId: string;
+ sourceId: string;
+ wasBase: boolean;
+}
+
+export interface PlayerEliminatedPayload {
+ playerId: string;
+ eliminatedBy: string;
+}
+
+export interface ErrorPayload {
+ code: string;
+ message: string;
+}
+
+export interface ActionRejectedPayload {
+ action: string;
+ reason: string;
+ errorCode?: string;
+}
+
+// Bullet event payloads
+export interface BulletFiredPayload {
+ bulletId: string;
+ bulletType: string;
+ towerId: string;
+ ownerId: string;
+ x: number;
+ y: number;
+ vx: number;
+ vy: number;
+ radius: number;
+ maxRange: number;
+}
+
+export interface BulletHitPayload {
+ bulletId: string;
+ targetId: string;
+ targetType: 'monster' | 'building';
+ x: number;
+ y: number;
+ damage: number;
+}
+
+export interface BulletExplosionPayload {
+ bulletId: string;
+ x: number;
+ y: number;
+ radius: number;
+ damage: number;
+ hitTargets: string[];
+}
+
+// ==================== Room / Lobby Payload Types ====================
+
+export interface RoomInfo {
+ roomId: string;
+ roomName: string;
+ hostName: string;
+ mapSize: string;
+ playerCount: number;
+ maxPlayers: number;
+ isPrivate: boolean;
+ isPlaying: boolean;
+}
+
+export interface MatchFoundPayload {
+ roomId: string;
+ reservation: unknown; // Colyseus reservation object
+}
+
+// ==================== Mine Payload Types ====================
+
+export interface UpgradeMinePayload {
+ mineId: string;
+}
+
+export interface RepairMinePayload {
+ mineId: string;
+}
+
+export interface DowngradeMinePayload {
+ mineId: string;
+}
+
+export interface SellMinePayload {
+ mineId: string;
+}
+
+export interface MineDestroyedPayload {
+ mineId: string;
+ destroyedBy: string;
+}
+
+
+/**
+ * Territory synchronization payload
+ * Server sends authoritative territory state to clients
+ */
+export interface TerritorySyncPayload {
+ territories: {
+ [playerId: string]: {
+ validBuildings: string[];
+ invalidBuildings: string[];
+ };
+ };
+}
diff --git a/shared/types/ownership.ts b/shared/types/ownership.ts
new file mode 100644
index 0000000..052e547
--- /dev/null
+++ b/shared/types/ownership.ts
@@ -0,0 +1,98 @@
+/**
+ * Ownership utility types and functions for multiplayer support
+ * Shared between client and server
+ *
+ * Determines friend/enemy relationships between entities.
+ *
+ * NOTE: Neutral entities use `null` as the canonical value.
+ * For Colyseus compatibility, empty string `''` is also treated as neutral.
+ */
+
+/**
+ * Interface for entities with ownership
+ * ownerId is null or '' for neutral entities ('' for Colyseus compatibility)
+ */
+export interface OwnedEntity {
+ ownerId: string | null;
+}
+
+/**
+ * Looser interface for Colyseus schemas where ownerId is always string
+ */
+export interface OwnedEntityString {
+ ownerId: string;
+}
+
+/**
+ * Check if an ownerId represents a neutral entity
+ * Handles both null and '' (Colyseus compatibility)
+ */
+function isNeutralId(ownerId: string | null): boolean {
+ return ownerId === null || ownerId === '';
+}
+
+/**
+ * Check if two entities are enemies
+ *
+ * Rules:
+ * - Neutral entities (ownerId === null or '') are enemies to everyone
+ * - Different owners are enemies
+ * - Same owner (both non-null/non-empty) = not enemies
+ *
+ * @param a First entity
+ * @param b Second entity
+ * @returns true if entities are enemies
+ */
+export function isEnemy(a: OwnedEntity | OwnedEntityString, b: OwnedEntity | OwnedEntityString): boolean {
+ // Neutral entities are enemies to everyone
+ if (isNeutralId(a.ownerId) || isNeutralId(b.ownerId)) {
+ return true;
+ }
+ // Different owners are enemies
+ return a.ownerId !== b.ownerId;
+}
+
+/**
+ * Check if two entities are friendly (same team)
+ *
+ * @param a First entity
+ * @param b Second entity
+ * @returns true if entities are friendly
+ */
+export function isFriendly(a: OwnedEntity | OwnedEntityString, b: OwnedEntity | OwnedEntityString): boolean {
+ // Neutral entities are never friendly
+ if (isNeutralId(a.ownerId) || isNeutralId(b.ownerId)) {
+ return false;
+ }
+ // Same owner = friendly
+ return a.ownerId === b.ownerId;
+}
+
+/**
+ * Check if an entity belongs to a specific player
+ */
+export function belongsTo(entity: OwnedEntity | OwnedEntityString, playerId: string): boolean {
+ return entity.ownerId === playerId;
+}
+
+/**
+ * Check if an entity is neutral (no owner)
+ * Handles both null and '' (Colyseus compatibility)
+ */
+export function isNeutral(entity: OwnedEntity | OwnedEntityString): boolean {
+ return isNeutralId(entity.ownerId);
+}
+
+/**
+ * Filter entities to get only enemies of the given entity
+ */
+export function filterEnemies(entities: T[], self: OwnedEntity | OwnedEntityString): T[] {
+ return entities.filter((e) => isEnemy(self, e));
+}
+
+/**
+ * Filter entities to get only friendlies of the given entity
+ */
+export function filterFriendlies(entities: T[], self: OwnedEntity | OwnedEntityString): T[] {
+ return entities.filter((e) => isFriendly(self, e));
+}
diff --git a/shared/validation/index.ts b/shared/validation/index.ts
new file mode 100644
index 0000000..1347a5d
--- /dev/null
+++ b/shared/validation/index.ts
@@ -0,0 +1,36 @@
+/**
+ * Shared Validation Module
+ * Provides pure validation functions used by both client and server
+ */
+
+export {
+ ValidationErrorCode,
+ validationSuccess,
+ validationFailure,
+ type ValidationErrorCodeType,
+ type ValidationResult,
+} from './types.js';
+
+export {
+ isPositionInBounds,
+ hasCollision,
+ checkBuildCollision,
+ MIN_BUILD_DISTANCE,
+ validateBuildTowerBasic,
+ validateUpgradeTower,
+ validateSellTower,
+ validateCannonFire,
+ type TowerMetaData,
+ type Position,
+ type CollidableEntity,
+ type PlayerValidationState,
+ type TowerValidationState,
+ type MapBounds,
+} from './towerValidation.js';
+
+export {
+ validateSpawnMonster,
+ type SpawnableMonsterConfig,
+ type SpawnerValidationState,
+ type TargetPlayerState,
+} from './monsterValidation.js';
diff --git a/shared/validation/monsterValidation.ts b/shared/validation/monsterValidation.ts
new file mode 100644
index 0000000..3956252
--- /dev/null
+++ b/shared/validation/monsterValidation.ts
@@ -0,0 +1,139 @@
+/**
+ * Monster/Spawner Validation Pure Functions
+ * Shared between client and server for consistent validation
+ */
+
+import type { ValidationResult } from './types.js';
+import {
+ ValidationErrorCode,
+ validationSuccess,
+ validationFailure,
+} from './types.js';
+import type { PlayerValidationState, Position } from './towerValidation.js';
+
+/**
+ * Spawnable monster configuration
+ */
+export interface SpawnableMonsterConfig {
+ monsterId: string;
+ cost: number;
+ cooldownTicks: number;
+ unlockWave: number;
+}
+
+/**
+ * Spawner state minimal interface for validation
+ */
+export interface SpawnerValidationState {
+ id: string;
+ ownerId: string;
+ isSpawner: boolean;
+ position: Position;
+ /** Get remaining cooldown ticks for a monster type */
+ getCooldownRemaining(monsterType: string): number;
+}
+
+/**
+ * Target player minimal interface
+ */
+export interface TargetPlayerState {
+ id: string;
+ isAlive: boolean;
+}
+
+/**
+ * Validate SPAWN_MONSTER action
+ */
+export function validateSpawnMonster(
+ player: PlayerValidationState | undefined,
+ spawner: SpawnerValidationState | undefined,
+ monsterType: string,
+ targetPlayerId: string,
+ currentWave: number,
+ monsterConfig: SpawnableMonsterConfig | undefined,
+ getTargetPlayer: (id: string) => TargetPlayerState | undefined
+): ValidationResult {
+ // 1. Player must exist and be alive
+ if (!player) {
+ return validationFailure(ValidationErrorCode.PLAYER_NOT_FOUND);
+ }
+ if (!player.isAlive) {
+ return validationFailure(ValidationErrorCode.PLAYER_NOT_ALIVE);
+ }
+
+ // 2. Spawner must exist
+ if (!spawner) {
+ return validationFailure(ValidationErrorCode.SPAWNER_NOT_FOUND);
+ }
+
+ // 3. Spawner must be a valid spawner
+ if (!spawner.isSpawner) {
+ return validationFailure(ValidationErrorCode.SPAWNER_INVALID);
+ }
+
+ // 4. Spawner must be owned by player
+ if (spawner.ownerId !== player.id) {
+ return validationFailure(ValidationErrorCode.SPAWNER_NOT_OWNED);
+ }
+
+ // 5. Monster type must be valid
+ if (!monsterConfig) {
+ return validationFailure(
+ ValidationErrorCode.MONSTER_TYPE_INVALID,
+ `Unknown monster type: ${monsterType}`
+ );
+ }
+
+ // 6. Monster must be unlocked (wave requirement)
+ if (currentWave < monsterConfig.unlockWave) {
+ return validationFailure(
+ ValidationErrorCode.MONSTER_NOT_UNLOCKED,
+ `${monsterType} unlocks at wave ${monsterConfig.unlockWave}, current wave: ${currentWave}`
+ );
+ }
+
+ // 7. Spawner must not be on cooldown for this monster type
+ const remainingCooldown = spawner.getCooldownRemaining(monsterType);
+ if (remainingCooldown > 0) {
+ return validationFailure(
+ ValidationErrorCode.SPAWN_ON_COOLDOWN,
+ `Cooldown remaining: ${remainingCooldown} ticks`
+ );
+ }
+
+ // 8. Player must have enough money
+ if (player.money < monsterConfig.cost) {
+ return validationFailure(
+ ValidationErrorCode.INSUFFICIENT_MONEY,
+ `Need ${monsterConfig.cost}, have ${player.money}`
+ );
+ }
+
+ // 9. Target player must be valid and alive (and not self)
+ if (targetPlayerId === player.id) {
+ return validationFailure(
+ ValidationErrorCode.TARGET_PLAYER_INVALID,
+ 'Cannot target yourself'
+ );
+ }
+
+ const targetPlayer = getTargetPlayer(targetPlayerId);
+ if (!targetPlayer) {
+ return validationFailure(
+ ValidationErrorCode.TARGET_PLAYER_INVALID,
+ 'Target player not found'
+ );
+ }
+ if (!targetPlayer.isAlive) {
+ return validationFailure(
+ ValidationErrorCode.TARGET_PLAYER_INVALID,
+ 'Target player is not alive'
+ );
+ }
+
+ return validationSuccess({
+ cost: monsterConfig.cost,
+ cooldownTicks: monsterConfig.cooldownTicks,
+ monsterId: monsterConfig.monsterId,
+ });
+}
diff --git a/shared/validation/towerValidation.ts b/shared/validation/towerValidation.ts
new file mode 100644
index 0000000..06caf90
--- /dev/null
+++ b/shared/validation/towerValidation.ts
@@ -0,0 +1,323 @@
+/**
+ * Tower Validation Pure Functions
+ * Shared between client and server for consistent validation
+ */
+
+import type { ValidationResult } from './types.js';
+import {
+ ValidationErrorCode,
+ validationSuccess,
+ validationFailure,
+} from './types.js';
+
+/**
+ * Minimal tower metadata needed for validation
+ */
+export interface TowerMetaData {
+ id: string;
+ price: number;
+ levelUpArr: string[];
+}
+
+/**
+ * Position with x, y coordinates
+ */
+export interface Position {
+ x: number;
+ y: number;
+}
+
+/**
+ * Entity with position and radius (for collision checking)
+ */
+export interface CollidableEntity {
+ position: Position;
+ radius: number;
+ id?: string;
+}
+
+/**
+ * Player state minimal interface for validation
+ */
+export interface PlayerValidationState {
+ id: string;
+ isAlive: boolean;
+ money: number;
+}
+
+/**
+ * Tower state minimal interface for validation
+ */
+export interface TowerValidationState {
+ id: string;
+ ownerId: string;
+ towerType: string;
+ position: Position;
+ radius: number;
+ attackRadius: number;
+ isManual: boolean;
+ currentAmmo: number;
+}
+
+/**
+ * Map bounds
+ */
+export interface MapBounds {
+ width: number;
+ height: number;
+}
+
+/**
+ * Check if a position is within map bounds
+ */
+export function isPositionInBounds(
+ x: number,
+ y: number,
+ bounds: MapBounds,
+ margin: number = 0
+): boolean {
+ return (
+ x >= margin &&
+ y >= margin &&
+ x <= bounds.width - margin &&
+ y <= bounds.height - margin
+ );
+}
+
+/**
+ * Minimum distance gap between entity edges when building.
+ * With tower radius ~15 and building radius ~30, this ensures
+ * a comfortable spacing between placed entities.
+ */
+export const MIN_BUILD_DISTANCE = 35;
+
+/**
+ * Check for collision with existing entities.
+ * Returns the colliding entity if found, null otherwise.
+ */
+export function hasCollision(
+ x: number,
+ y: number,
+ radius: number,
+ entities: Iterable,
+ minDistance: number = 0
+): CollidableEntity | null {
+ const checkRadius = radius + minDistance;
+ for (const entity of entities) {
+ const dx = x - entity.position.x;
+ const dy = y - entity.position.y;
+ const distSq = dx * dx + dy * dy;
+ const combinedRadius = checkRadius + entity.radius;
+ if (distSq < combinedRadius * combinedRadius) {
+ return entity;
+ }
+ }
+ return null;
+}
+
+/**
+ * Check if a build position collides with any existing tower or building.
+ * Uses MIN_BUILD_DISTANCE as the spacing requirement.
+ */
+export function checkBuildCollision(
+ x: number,
+ y: number,
+ radius: number,
+ towers: Iterable,
+ buildings: Iterable
+): { collides: boolean; collidingEntityId?: string } {
+ const collidingTower = hasCollision(x, y, radius, towers, MIN_BUILD_DISTANCE);
+ if (collidingTower) {
+ return { collides: true, collidingEntityId: collidingTower.id };
+ }
+
+ const collidingBuilding = hasCollision(x, y, radius, buildings, MIN_BUILD_DISTANCE);
+ if (collidingBuilding) {
+ return { collides: true, collidingEntityId: collidingBuilding.id };
+ }
+
+ return { collides: false };
+}
+
+/**
+ * Validate BUILD_TOWER action
+ * Does NOT validate territory (server-specific with TerritoryCalculator)
+ */
+export function validateBuildTowerBasic(
+ player: PlayerValidationState | undefined,
+ towerType: string,
+ x: number,
+ y: number,
+ towerMeta: TowerMetaData | undefined,
+ bounds: MapBounds
+): ValidationResult {
+ // 1. Player must exist and be alive
+ if (!player) {
+ return validationFailure(ValidationErrorCode.PLAYER_NOT_FOUND);
+ }
+ if (!player.isAlive) {
+ return validationFailure(ValidationErrorCode.PLAYER_NOT_ALIVE);
+ }
+
+ // 2. Tower type must be valid
+ if (!towerMeta) {
+ return validationFailure(
+ ValidationErrorCode.TOWER_TYPE_INVALID,
+ `Unknown tower type: ${towerType}`
+ );
+ }
+
+ // 3. Position must be within map bounds (with margin for tower radius)
+ const towerRadius = 15; // Default tower radius
+ if (!isPositionInBounds(x, y, bounds, towerRadius)) {
+ return validationFailure(ValidationErrorCode.POSITION_OUT_OF_BOUNDS);
+ }
+
+ // Return success with base price (territory cost multiplier applied separately)
+ return validationSuccess({ basePrice: towerMeta.price, towerType });
+}
+
+/**
+ * Validate UPGRADE_TOWER action
+ */
+export function validateUpgradeTower(
+ player: PlayerValidationState | undefined,
+ tower: TowerValidationState | undefined,
+ targetType: string,
+ currentTowerMeta: TowerMetaData | undefined,
+ targetTowerMeta: TowerMetaData | undefined
+): ValidationResult {
+ // 1. Player must exist and be alive
+ if (!player) {
+ return validationFailure(ValidationErrorCode.PLAYER_NOT_FOUND);
+ }
+ if (!player.isAlive) {
+ return validationFailure(ValidationErrorCode.PLAYER_NOT_ALIVE);
+ }
+
+ // 2. Tower must exist
+ if (!tower) {
+ return validationFailure(ValidationErrorCode.TOWER_NOT_FOUND);
+ }
+
+ // 3. Tower must be owned by player
+ if (tower.ownerId !== player.id) {
+ return validationFailure(ValidationErrorCode.TOWER_NOT_OWNED);
+ }
+
+ // 4. Current tower meta must exist
+ if (!currentTowerMeta) {
+ return validationFailure(
+ ValidationErrorCode.TOWER_TYPE_INVALID,
+ `Unknown current tower type: ${tower.towerType}`
+ );
+ }
+
+ // 5. Target type must be in levelUpArr
+ if (!currentTowerMeta.levelUpArr.includes(targetType)) {
+ return validationFailure(
+ ValidationErrorCode.TOWER_UPGRADE_INVALID,
+ `Cannot upgrade from ${tower.towerType} to ${targetType}`
+ );
+ }
+
+ // 6. Target tower meta must exist
+ if (!targetTowerMeta) {
+ return validationFailure(
+ ValidationErrorCode.TOWER_TYPE_INVALID,
+ `Unknown target tower type: ${targetType}`
+ );
+ }
+
+ // 7. Player must have enough money
+ if (player.money < targetTowerMeta.price) {
+ return validationFailure(
+ ValidationErrorCode.INSUFFICIENT_MONEY,
+ `Need ${targetTowerMeta.price}, have ${player.money}`
+ );
+ }
+
+ return validationSuccess({ cost: targetTowerMeta.price, targetType });
+}
+
+/**
+ * Validate SELL_TOWER action
+ */
+export function validateSellTower(
+ player: PlayerValidationState | undefined,
+ tower: TowerValidationState | undefined,
+ towerMeta: TowerMetaData | undefined,
+ sellRefundRate: number = 0.5
+): ValidationResult {
+ // 1. Player must exist and be alive
+ if (!player) {
+ return validationFailure(ValidationErrorCode.PLAYER_NOT_FOUND);
+ }
+ if (!player.isAlive) {
+ return validationFailure(ValidationErrorCode.PLAYER_NOT_ALIVE);
+ }
+
+ // 2. Tower must exist
+ if (!tower) {
+ return validationFailure(ValidationErrorCode.TOWER_NOT_FOUND);
+ }
+
+ // 3. Tower must be owned by player
+ if (tower.ownerId !== player.id) {
+ return validationFailure(ValidationErrorCode.TOWER_NOT_OWNED);
+ }
+
+ // Calculate refund (default 50% of base price)
+ const basePrice = towerMeta?.price || 50;
+ const refund = Math.floor(basePrice * sellRefundRate);
+
+ return validationSuccess({ refund, towerId: tower.id });
+}
+
+/**
+ * Validate CANNON_FIRE action
+ */
+export function validateCannonFire(
+ player: PlayerValidationState | undefined,
+ tower: TowerValidationState | undefined,
+ targetX: number,
+ targetY: number
+): ValidationResult {
+ // 1. Player must exist and be alive
+ if (!player) {
+ return validationFailure(ValidationErrorCode.PLAYER_NOT_FOUND);
+ }
+ if (!player.isAlive) {
+ return validationFailure(ValidationErrorCode.PLAYER_NOT_ALIVE);
+ }
+
+ // 2. Tower must exist
+ if (!tower) {
+ return validationFailure(ValidationErrorCode.CANNON_NOT_FOUND);
+ }
+
+ // 3. Tower must be owned by player
+ if (tower.ownerId !== player.id) {
+ return validationFailure(ValidationErrorCode.CANNON_NOT_OWNED);
+ }
+
+ // 4. Tower must be a manual cannon
+ if (!tower.isManual) {
+ return validationFailure(ValidationErrorCode.CANNON_NOT_MANUAL);
+ }
+
+ // 5. Tower must have ammo
+ if (tower.currentAmmo <= 0) {
+ return validationFailure(ValidationErrorCode.CANNON_NO_AMMO);
+ }
+
+ // 6. Target must be within attack radius
+ const dx = targetX - tower.position.x;
+ const dy = targetY - tower.position.y;
+ const distSq = dx * dx + dy * dy;
+ if (distSq > tower.attackRadius * tower.attackRadius) {
+ return validationFailure(ValidationErrorCode.CANNON_TARGET_OUT_OF_RANGE);
+ }
+
+ return validationSuccess();
+}
diff --git a/shared/validation/types.ts b/shared/validation/types.ts
new file mode 100644
index 0000000..7b22e5e
--- /dev/null
+++ b/shared/validation/types.ts
@@ -0,0 +1,85 @@
+/**
+ * Validation Result Types
+ * Shared between client and server validation layers
+ */
+
+/**
+ * Error codes for validation failures
+ * Prefix indicates category:
+ * - PLAYER_* : Player state errors
+ * - TOWER_* : Tower validation errors
+ * - SPAWNER_* : Monster spawning errors
+ * - CANNON_* : Manual cannon errors
+ * - MAP_* : Position/boundary errors
+ * - RESOURCE_* : Money/resource errors
+ */
+export const ValidationErrorCode = {
+ // Player errors
+ PLAYER_NOT_FOUND: 'PLAYER_NOT_FOUND',
+ PLAYER_NOT_ALIVE: 'PLAYER_NOT_ALIVE',
+
+ // Tower errors
+ TOWER_TYPE_INVALID: 'TOWER_TYPE_INVALID',
+ TOWER_NOT_FOUND: 'TOWER_NOT_FOUND',
+ TOWER_NOT_OWNED: 'TOWER_NOT_OWNED',
+ TOWER_UPGRADE_INVALID: 'TOWER_UPGRADE_INVALID',
+
+ // Map/position errors
+ POSITION_OUT_OF_BOUNDS: 'POSITION_OUT_OF_BOUNDS',
+ POSITION_NOT_IN_TERRITORY: 'POSITION_NOT_IN_TERRITORY',
+ POSITION_COLLISION: 'POSITION_COLLISION',
+
+ // Resource errors
+ INSUFFICIENT_MONEY: 'INSUFFICIENT_MONEY',
+
+ // Spawner errors
+ SPAWNER_NOT_FOUND: 'SPAWNER_NOT_FOUND',
+ SPAWNER_NOT_OWNED: 'SPAWNER_NOT_OWNED',
+ SPAWNER_INVALID: 'SPAWNER_INVALID',
+ SPAWNER_NOT_IN_TERRITORY: 'SPAWNER_NOT_IN_TERRITORY',
+ MONSTER_TYPE_INVALID: 'MONSTER_TYPE_INVALID',
+ MONSTER_NOT_UNLOCKED: 'MONSTER_NOT_UNLOCKED',
+ SPAWN_ON_COOLDOWN: 'SPAWN_ON_COOLDOWN',
+ TARGET_PLAYER_INVALID: 'TARGET_PLAYER_INVALID',
+
+ // Cannon errors
+ CANNON_NOT_FOUND: 'CANNON_NOT_FOUND',
+ CANNON_NOT_OWNED: 'CANNON_NOT_OWNED',
+ CANNON_NOT_MANUAL: 'CANNON_NOT_MANUAL',
+ CANNON_NO_AMMO: 'CANNON_NO_AMMO',
+ CANNON_TARGET_OUT_OF_RANGE: 'CANNON_TARGET_OUT_OF_RANGE',
+} as const;
+
+export type ValidationErrorCodeType = (typeof ValidationErrorCode)[keyof typeof ValidationErrorCode];
+
+/**
+ * Validation result - either success or failure with details
+ */
+export interface ValidationResult {
+ valid: boolean;
+ errorCode?: ValidationErrorCodeType;
+ errorMessage?: string;
+ /** Extra data for successful validation (e.g., calculated cost) */
+ data?: Record;
+}
+
+/**
+ * Create a success result
+ */
+export function validationSuccess(data?: Record): ValidationResult {
+ return { valid: true, data };
+}
+
+/**
+ * Create a failure result
+ */
+export function validationFailure(
+ errorCode: ValidationErrorCodeType,
+ errorMessage?: string
+): ValidationResult {
+ return {
+ valid: false,
+ errorCode,
+ errorMessage: errorMessage || errorCode,
+ };
+}
diff --git a/src/buildings/building.ts b/src/buildings/building.ts
index 1894a6d..7ffe4bb 100644
--- a/src/buildings/building.ts
+++ b/src/buildings/building.ts
@@ -9,6 +9,7 @@ import { MyColor } from '../entities/myColor';
import { CircleObject } from '../entities/base/circleObject';
import { EffectCircle } from '../effects/effectCircle';
import { BuildingRegistry } from './buildingRegistry';
+import { renderBuilding, renderBuildingStatic, renderBuildingDynamic } from './rendering/buildingRenderer';
import { scalePeriod } from '../core/speedScale';
import { renderStatusBar, BAR_OFFSET } from '../entities/statusBar';
@@ -36,11 +37,11 @@ interface TowerLike {
interface WorldLike {
width: number;
height: number;
- user: UserLike;
territory?: TerritoryLike;
buildings: Set;
batterys: TowerLike[];
addEffect(effect: unknown): void;
+ addMoneyToOwner(ownerId: string | null, amount: number): void;
}
type LevelUpFunc = () => Building;
@@ -121,7 +122,8 @@ export class Building extends CircleObject {
// Add money (gold mines cannot produce in invalid territory)
if (this.moneyAddedAble && this.inValidTerritory) {
if (this.liveTime % this.moneyAddedFreezeTime === 0) {
- this.world.user.money += this.moneyAddedNum;
+ // Gold production: dispatch to building owner (multiplayer compatible)
+ this.world.addMoneyToOwner(this.ownerId, this.moneyAddedNum);
// Add collection effect
const e = EffectCircle.acquire(this.pos);
e.circle.r = this.r;
@@ -167,65 +169,21 @@ export class Building extends CircleObject {
}
render(ctx: CanvasRenderingContext2D): void {
- this.renderStatic(ctx);
- this.renderDynamic(ctx);
+ renderBuilding(this as any, ctx);
}
/**
* Render static parts (body, healing range) - can be cached to static layer
*/
renderStatic(ctx: CanvasRenderingContext2D): void {
- if (!this.isInScreen()) {
- return;
- }
-
- const c = this.getBodyCircle();
- c.render(ctx);
-
- // Draw healing range circle (cache Circle object)
- if (this.otherHpAddAble) {
- if (!this._hpAddRangeCircle) {
- this._hpAddRangeCircle = new Circle(this.pos.x, this.pos.y, this.otherHpAddRadius);
- this._hpAddRangeCircle.fillColor.setRGBA(0, 0, 0, 0);
- this._hpAddRangeCircle.strokeColor.setRGBA(81, 139, 60, 1);
- this._hpAddRangeCircle.setStrokeWidth(0.5);
- } else {
- // Update position (buildings don't move, but just in case)
- this._hpAddRangeCircle.x = this.pos.x;
- this._hpAddRangeCircle.y = this.pos.y;
- }
- this._hpAddRangeCircle.render(ctx);
- }
+ renderBuildingStatic(this as any, ctx);
}
/**
* Render dynamic parts (HP bar) - must be rendered every frame
*/
renderDynamic(ctx: CanvasRenderingContext2D): void {
- if (!this.isInScreen()) {
- return;
- }
-
- // Render HP bar (copied from CircleObject.render)
- if (this.maxHp > 0 && !this.isDead()) {
- const barH = this.hpBarHeight;
- const barX = this.pos.x - this.r;
- const barY = this.pos.y - this.r + BAR_OFFSET.HP_TOP * barH;
- const barW = this.r * 2;
- const hpRate = this.hp / this.maxHp;
-
- renderStatusBar(ctx, {
- x: barX,
- y: barY,
- width: barW,
- height: barH,
- fillRate: hpRate,
- fillColor: this.hpColor,
- showText: true,
- textValue: this.hp,
- cache: this._hpBarCache
- });
- }
+ renderBuildingDynamic(this as any, ctx);
}
}
diff --git a/src/buildings/index.ts b/src/buildings/index.ts
index fdf9f0f..b6b1f0c 100644
--- a/src/buildings/index.ts
+++ b/src/buildings/index.ts
@@ -30,6 +30,7 @@ export function getBuildingFuncArr(): (BuildingCreator | undefined)[] {
// BuildingRegistry.getCreator('Root'), // Root is not in UI selection
BuildingRegistry.getCreator('Collector'),
BuildingRegistry.getCreator('Treatment'),
+ BuildingRegistry.getCreator('MonsterSpawner'),
].filter(Boolean) as (BuildingCreator | undefined)[];
}
diff --git a/src/buildings/rendering/buildingRenderer.ts b/src/buildings/rendering/buildingRenderer.ts
new file mode 100644
index 0000000..378ae18
--- /dev/null
+++ b/src/buildings/rendering/buildingRenderer.ts
@@ -0,0 +1,109 @@
+/**
+ * BuildingRenderer - Rendering functions for Building
+ * Extracted from Building class
+ */
+import { Circle } from '../../core/math/circle';
+import { Vector } from '../../core/math/vector';
+import {
+ renderStatusBar,
+ BAR_OFFSET,
+ type StatusBarCache
+} from '../../entities/statusBar';
+
+// ============================================================================
+// Type interfaces for loose coupling
+// ============================================================================
+
+interface BuildingLike {
+ pos: Vector;
+ r: number;
+ hp: number;
+ maxHp: number;
+ hpBarHeight: number;
+ hpColor: { r: number; g: number; b: number; a: number };
+ _hpBarCache: StatusBarCache;
+
+ // Healing range properties
+ otherHpAddAble: boolean;
+ otherHpAddRadius: number;
+
+ // Methods
+ isInScreen(): boolean;
+ isDead(): boolean;
+ getBodyCircle(): Circle;
+}
+
+// Cached healing range circle per building (keyed by object identity)
+const _hpAddRangeCircles = new WeakMap();
+
+// ============================================================================
+// Core rendering functions
+// ============================================================================
+
+/**
+ * Render building static parts (body circle + healing range)
+ */
+export function renderBuildingStatic(building: BuildingLike, ctx: CanvasRenderingContext2D): void {
+ if (!building.isInScreen()) return;
+
+ // Body circle
+ const c = building.getBodyCircle();
+ c.render(ctx);
+
+ // Healing range circle
+ if (building.otherHpAddAble) {
+ let rangeCircle = _hpAddRangeCircles.get(building);
+ if (!rangeCircle) {
+ rangeCircle = new Circle(building.pos.x, building.pos.y, building.otherHpAddRadius);
+ rangeCircle.fillColor.setRGBA(0, 0, 0, 0);
+ rangeCircle.strokeColor.setRGBA(81, 139, 60, 1);
+ rangeCircle.setStrokeWidth(0.5);
+ _hpAddRangeCircles.set(building, rangeCircle);
+ } else {
+ rangeCircle.x = building.pos.x;
+ rangeCircle.y = building.pos.y;
+ }
+ rangeCircle.render(ctx);
+ }
+}
+
+/**
+ * Render building dynamic parts (HP bar)
+ */
+export function renderBuildingDynamic(building: BuildingLike, ctx: CanvasRenderingContext2D): void {
+ if (!building.isInScreen()) return;
+
+ if (building.maxHp > 0 && !building.isDead()) {
+ const barH = building.hpBarHeight;
+ const barX = building.pos.x - building.r;
+ const barY = building.pos.y - building.r + BAR_OFFSET.HP_TOP * barH;
+ const barW = building.r * 2;
+ const hpRate = building.hp / building.maxHp;
+
+ renderStatusBar(ctx, {
+ x: barX,
+ y: barY,
+ width: barW,
+ height: barH,
+ fillRate: hpRate,
+ fillColor: building.hpColor,
+ showText: true,
+ textValue: building.hp,
+ cache: building._hpBarCache
+ });
+ }
+}
+
+/**
+ * Render complete building (static + dynamic)
+ */
+export function renderBuilding(building: BuildingLike, ctx: CanvasRenderingContext2D): void {
+ renderBuildingStatic(building, ctx);
+ renderBuildingDynamic(building, ctx);
+}
+
+// ============================================================================
+// Export types
+// ============================================================================
+
+export type { BuildingLike as BuildingRenderable };
diff --git a/src/buildings/rendering/index.ts b/src/buildings/rendering/index.ts
new file mode 100644
index 0000000..0883665
--- /dev/null
+++ b/src/buildings/rendering/index.ts
@@ -0,0 +1,4 @@
+/**
+ * Building rendering module
+ */
+export * from './buildingRenderer';
diff --git a/src/buildings/spawnerConfig.ts b/src/buildings/spawnerConfig.ts
new file mode 100644
index 0000000..48b5e9a
--- /dev/null
+++ b/src/buildings/spawnerConfig.ts
@@ -0,0 +1,41 @@
+/**
+ * Spawnable monster configuration for MonsterSpawner building
+ *
+ * Derives from shared MonsterMetaData (single source of truth).
+ * This file provides the client-side SpawnableMonster interface
+ * used by MonsterSpawner, SpawnerPanel, and ClientValidator.
+ */
+
+import { SPAWNABLE_MONSTER_META } from '@shared/config/monsterMeta';
+
+/**
+ * Configuration for a spawnable monster
+ */
+export interface SpawnableMonster {
+ /** MonsterRegistry ID */
+ monsterId: string;
+ /** Display name (Chinese) */
+ name: string;
+ /** Spawn cost (energy) */
+ cost: number;
+ /** Cooldown in ticks after spawning */
+ cooldownTicks: number;
+ /** Minimum wave number to unlock this monster */
+ unlockWave: number;
+}
+
+/**
+ * List of all spawnable monsters, derived from shared config.
+ * Sorted by unlock wave, then by cost for display purposes.
+ */
+export const SPAWNABLE_MONSTERS: SpawnableMonster[] = Object.values(
+ SPAWNABLE_MONSTER_META
+)
+ .map((meta) => ({
+ monsterId: meta.monsterId,
+ name: meta.name,
+ cost: meta.cost,
+ cooldownTicks: meta.cooldownTicks,
+ unlockWave: meta.unlockWave,
+ }))
+ .sort((a, b) => a.unlockWave - b.unlockWave || a.cost - b.cost);
diff --git a/src/buildings/variants/index.ts b/src/buildings/variants/index.ts
index 87d985a..68946cd 100644
--- a/src/buildings/variants/index.ts
+++ b/src/buildings/variants/index.ts
@@ -4,6 +4,7 @@
// Import all variant files to trigger registration
import * as buildings from './buildings';
+import * as monsterSpawner from './monsterSpawner';
// Re-export all variants for direct access if needed
-export { buildings };
+export { buildings, monsterSpawner };
diff --git a/src/buildings/variants/monsterSpawner.ts b/src/buildings/variants/monsterSpawner.ts
new file mode 100644
index 0000000..281e367
--- /dev/null
+++ b/src/buildings/variants/monsterSpawner.ts
@@ -0,0 +1,289 @@
+/**
+ * MonsterSpawner Building - Allows players to spawn monsters to attack enemies
+ *
+ * Only functional in multiplayer mode. Each monster type has its own cooldown.
+ */
+
+import { Vector } from '../../core/math/vector';
+import { MyColor } from '../../entities/myColor';
+import { Building } from '../building';
+import { BuildingRegistry } from '../buildingRegistry';
+import { MonsterRegistry } from '../../monsters/monsterRegistry';
+import { SPAWNABLE_MONSTERS, SpawnableMonster } from '../spawnerConfig';
+import type { PlayerManager } from '../../game/player/playerManager';
+import type { MonsterLike } from '../../types/worldLike';
+
+// Extended world interface (uses type assertion at runtime)
+interface SpawnerWorld {
+ getPlayerManager(): PlayerManager | null;
+ monsterFlow?: { level: number };
+ addMonster(monster: MonsterLike): void;
+ width: number;
+ height: number;
+ getMoney(playerId?: string): number;
+ spendMoneyFromOwner(ownerId: string | null, amount: number): boolean;
+ addMoneyToOwner(playerId: string | null, amount: number): void;
+}
+
+/**
+ * MonsterSpawner class - Building that can spawn monsters
+ */
+export class MonsterSpawner extends Building {
+ /** Flag to identify this building type */
+ canSpawnMonsters: boolean = true;
+
+ /** Cooldowns for each monster type (monsterId -> remaining ticks) */
+ private cooldowns: Map = new Map();
+
+ constructor(pos: Vector, world: any) {
+ super(pos, world);
+ this.gameType = "Building";
+ this.name = "怪物生成塔";
+ this.price = 500;
+ this.r = 15;
+ this.hpInit(3000);
+
+ // Dark red theme
+ this.bodyColor = new MyColor(80, 20, 20, 0.9);
+ this.bodyStrokeColor = new MyColor(120, 30, 30, 1);
+ this.bodyStrokeWidth = 3;
+
+ // Initialize cooldowns for all spawnable monsters
+ for (const config of SPAWNABLE_MONSTERS) {
+ this.cooldowns.set(config.monsterId, 0);
+ }
+ }
+
+ /** Get world with extended interface */
+ private get spawnerWorld(): SpawnerWorld {
+ return this.world as unknown as SpawnerWorld;
+ }
+
+ /**
+ * Override goStep to handle cooldown countdown
+ */
+ goStep(): void {
+ super.goStep();
+
+ // Decrement all active cooldowns
+ for (const [monsterId, ticks] of this.cooldowns) {
+ if (ticks > 0) {
+ this.cooldowns.set(monsterId, ticks - 1);
+ }
+ }
+ }
+
+ /**
+ * Get current wave number from game state
+ */
+ getCurrentWave(): number {
+ return this.spawnerWorld.monsterFlow?.level ?? 1;
+ }
+
+ /**
+ * Check if a monster type is available to spawn
+ */
+ isMonsterAvailable(config: SpawnableMonster): boolean {
+ // Check wave unlock
+ if (this.getCurrentWave() < config.unlockWave) {
+ return false;
+ }
+
+ // Check cooldown
+ const remainingCooldown = this.cooldowns.get(config.monsterId) ?? 0;
+ if (remainingCooldown > 0) {
+ return false;
+ }
+
+ // Check valid territory
+ if (!this.inValidTerritory) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get remaining cooldown for a monster type
+ */
+ getCooldown(monsterId: string): number {
+ return this.cooldowns.get(monsterId) ?? 0;
+ }
+
+ /**
+ * Check if player can afford to spawn a monster
+ */
+ canAfford(config: SpawnableMonster): boolean {
+ const playerMoney = this.spawnerWorld.getMoney(this.ownerId ?? undefined);
+ return playerMoney >= config.cost;
+ }
+
+ /**
+ * Spawn a monster targeting a specific player
+ * @returns true if spawn was successful
+ */
+ spawnMonster(config: SpawnableMonster, targetPlayerId: string): boolean {
+ // Validate availability
+ if (!this.isMonsterAvailable(config)) {
+ return false;
+ }
+
+ // Check cost
+ if (!this.canAfford(config)) {
+ return false;
+ }
+
+ // Deduct cost
+ const world = this.spawnerWorld;
+ const success = world.spendMoneyFromOwner(this.ownerId, config.cost);
+ if (!success) {
+ return false;
+ }
+
+ // Create monster
+ const monster = MonsterRegistry.create(config.monsterId, this.world) as MonsterLike | null;
+ if (!monster) {
+ // Refund if monster creation failed
+ world.addMoneyToOwner(this.ownerId, config.cost);
+ return false;
+ }
+
+ // Set monster owner to spawner's owner
+ monster.ownerId = this.ownerId;
+
+ // Calculate spawn position near target player's base
+ const spawnPos = this.calcSpawnPosition(targetPlayerId);
+ monster.pos = spawnPos;
+
+ // Set destination to target player's base
+ const targetBase = this.getTargetBase(targetPlayerId);
+ if (targetBase) {
+ monster.destination = new Vector(targetBase.pos.x, targetBase.pos.y);
+ }
+
+ // Add monster to world
+ world.addMonster(monster);
+
+ // Set cooldown
+ this.cooldowns.set(config.monsterId, config.cooldownTicks);
+
+ return true;
+ }
+
+ /**
+ * Get target player's base building
+ */
+ private getTargetBase(targetPlayerId: string): any {
+ const playerManager = this.spawnerWorld.getPlayerManager();
+ if (!playerManager) return null;
+ return playerManager.getBaseBuilding(targetPlayerId);
+ }
+
+ /**
+ * Calculate spawn position near target player's base
+ *
+ * Spawns at the edge of target's territory, on the side facing the spawner.
+ */
+ calcSpawnPosition(targetPlayerId: string): Vector {
+ const world = this.spawnerWorld;
+ const targetBase = this.getTargetBase(targetPlayerId);
+ if (!targetBase) {
+ // Fallback: spawn near this building
+ return new Vector(
+ this.pos.x + (Math.random() - 0.5) * 60,
+ this.pos.y + (Math.random() - 0.5) * 60
+ );
+ }
+
+ // Direction from spawner to target base
+ const dx = targetBase.pos.x - this.pos.x;
+ const dy = targetBase.pos.y - this.pos.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+
+ if (dist < 1) {
+ // Same position, random direction
+ return new Vector(
+ targetBase.pos.x + (Math.random() - 0.5) * 100,
+ targetBase.pos.y + (Math.random() - 0.5) * 100
+ );
+ }
+
+ // Normalize direction (from target base towards spawner)
+ const dirX = -dx / dist;
+ const dirY = -dy / dist;
+
+ // Spawn at territory edge + some distance (20~60 units)
+ const territoryRadius = 200; // Approximate territory radius
+ const spawnDistance = territoryRadius + 20 + Math.random() * 40;
+
+ // Add perpendicular random offset to avoid stacking
+ const perpX = -dirY;
+ const perpY = dirX;
+ const perpOffset = (Math.random() - 0.5) * 80;
+
+ let spawnX = targetBase.pos.x + dirX * spawnDistance + perpX * perpOffset;
+ let spawnY = targetBase.pos.y + dirY * spawnDistance + perpY * perpOffset;
+
+ // Clamp to world bounds
+ const margin = 30;
+ spawnX = Math.max(margin, Math.min(world.width - margin, spawnX));
+ spawnY = Math.max(margin, Math.min(world.height - margin, spawnY));
+
+ return new Vector(spawnX, spawnY);
+ }
+
+ /**
+ * Get enemy players (for UI display)
+ */
+ getEnemyPlayers(): { id: string; name: string }[] {
+ const playerManager = this.spawnerWorld.getPlayerManager();
+ if (!playerManager) return [];
+
+ const allPlayers = playerManager.getAllPlayers();
+ return allPlayers
+ .filter((p: any) => p.id !== this.ownerId && p.isAlive)
+ .map((p: any) => ({ id: p.id, name: p.name }));
+ }
+
+ /**
+ * Check if multiplayer mode
+ */
+ isMultiplayerMode(): boolean {
+ const playerManager = this.spawnerWorld.getPlayerManager();
+ return playerManager?.isMultiplayer ?? false;
+ }
+
+ /**
+ * Custom serialization for save system
+ */
+ toJSON(): Record {
+ return {
+ cooldowns: Object.fromEntries(this.cooldowns),
+ };
+ }
+
+ /**
+ * Restore from save data
+ */
+ fromJSON(data: Record): void {
+ if (data.cooldowns && typeof data.cooldowns === 'object') {
+ const cooldownData = data.cooldowns as Record;
+ for (const [monsterId, ticks] of Object.entries(cooldownData)) {
+ if (typeof ticks === 'number') {
+ this.cooldowns.set(monsterId, ticks);
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Factory function for MonsterSpawner
+ */
+export function MonsterSpawnerFactory(world: unknown): MonsterSpawner {
+ return new MonsterSpawner(Vector.zero(), world);
+}
+
+// Register with BuildingRegistry
+BuildingRegistry.register('MonsterSpawner', MonsterSpawnerFactory as any);
+BuildingRegistry.registerClassType('MonsterSpawner', () => MonsterSpawner);
diff --git a/src/bullets/bullet.ts b/src/bullets/bullet.ts
index d26485c..9102b52 100644
--- a/src/bullets/bullet.ts
+++ b/src/bullets/bullet.ts
@@ -7,6 +7,7 @@ import { Circle } from '../core/math/circle';
import { MyColor } from '../entities/myColor';
import { CircleObject } from '../entities/base/circleObject';
import { BulletRegistry } from './bulletRegistry';
+import { isEnemy } from '@/game/player/ownership';
// Declare globals for non-migrated modules
declare const EffectCircle: {
@@ -47,7 +48,7 @@ interface EffectCircleLike {
interface EntityLike {
pos: VectorLike;
getBodyCircle(): CircleLike;
- hpChange(delta: number): void;
+ hpChange(delta: number, attackerId?: string | null): void;
isDead(): boolean;
teleportingAble?: boolean;
teleporting?(): void;
@@ -55,9 +56,11 @@ interface EntityLike {
burnRate?: number;
bodyColor?: { change(dr: number, dg: number, db: number, da: number): void };
changedSpeed?: VectorLike;
- // 上一帧位置(用于扫掠碰撞检测)
+ // Previous frame position (for sweep collision detection)
prevX?: number;
prevY?: number;
+ // Owner ID for friend/enemy determination
+ ownerId?: string | null;
}
interface TowerLike {
@@ -339,6 +342,11 @@ export class Bully extends CircleObject {
arr = this.world.getMonstersInRange(this.pos.x, this.pos.y, this.r + 100);
}
for (const m of arr) {
+ // Skip friendly entities (same owner)
+ if (!isEnemy(this, m as { ownerId: string | null })) {
+ continue;
+ }
+
const mc = m.getBodyCircle();
// 使用扫掠碰撞检测
@@ -364,8 +372,8 @@ export class Bully extends CircleObject {
if (m.teleportingAble && m.teleporting) {
m.teleporting();
}
- // Direct hit damage
- m.hpChange(-this.damage);
+ // Direct hit damage (pass ownerId for kill reward tracking)
+ m.hpChange(-this.damage, this.ownerId);
// Direct hit slow effect
if (m.speedFreezeNumb !== undefined) {
m.speedFreezeNumb *= this.freezeCutDown; // slow stacks
@@ -455,6 +463,7 @@ export class Bully extends CircleObject {
if (!b) continue;
b.isSliptedBully = true;
b.world = this.world;
+ b.ownerId = this.ownerId;
b.pos = this.pos.copy();
b.originalPos = this.pos.copy();
b.speed = Vector.randCircle().mul(this.splitRandomV);
@@ -489,12 +498,17 @@ export class Bully extends CircleObject {
arr = this.world.getMonstersInRange(this.pos.x, this.pos.y, this.bombRange + 50);
}
for (const m of arr) {
+ // Skip friendly entities (same owner)
+ if (!isEnemy(this, m as { ownerId: string | null })) {
+ continue;
+ }
+
if (m.getBodyCircle().impact(bC as any)) {
// Use disSq for distance calculation, only sqrt when needed for damage
const disSq = this.pos.disSq(m.pos as Vector);
const dis = Math.sqrt(disSq);
const damage = (1 - (dis / this.bombRange)) * this.bombDamage;
- m.hpChange(-Math.abs(damage));
+ m.hpChange(-Math.abs(damage), this.ownerId);
}
}
// Add explosion effect circle
@@ -524,9 +538,14 @@ export class Bully extends CircleObject {
arr = this.world.getMonstersInRange(this.pos.x, this.pos.y, this.bombRange + 50);
}
for (const m of arr) {
+ // Skip friendly entities (same owner)
+ if (!isEnemy(this, m as { ownerId: string | null })) {
+ continue;
+ }
+
if (m.getBodyCircle().impact(bC as any)) {
- // Spread damage
- m.hpChange(-this.bombDamage);
+ // Spread damage (pass ownerId for kill reward tracking)
+ m.hpChange(-this.bombDamage, this.ownerId);
if (m.speedFreezeNumb !== undefined) {
m.speedFreezeNumb *= this.freezeCutDown; // slow stacks
}
diff --git a/src/bullets/variants/explosive.ts b/src/bullets/variants/explosive.ts
index 7152def..e3b48e4 100644
--- a/src/bullets/variants/explosive.ts
+++ b/src/bullets/variants/explosive.ts
@@ -93,8 +93,37 @@ export function H_Target_S(): Bully {
return b;
}
+/**
+ * Manual Cannon Shell - Explosive shell for ManualCannon tower
+ * Can hit buildings (unique property)
+ */
+export function ManualCannon_Shell(): Bully {
+ const b = new Bully(Vector.zero(), Vector.zero(), null, 5, 3);
+ b.r = 5;
+ b.damage = 50; // Direct hit damage
+
+ b.haveBomb = true;
+ b.bombDamage = 100; // Explosion damage (will be overridden by tower)
+ b.bombRange = 50; // Explosion radius (will be overridden by tower)
+ b.bombFunc = b.bombFire;
+ b.accelerationV = 0.03;
+
+ // Can hit buildings flag (handled by collision system)
+ (b as any).canHitBuildings = true;
+ // Store target position for guided behavior
+ (b as any).targetPos = null;
+
+ // Dark metallic color
+ b.bodyColor = MyColor.arrTo([80, 80, 100, 1]);
+ b.bodyStrokeColor = MyColor.arrTo([120, 120, 150, 1]);
+ b.bodyStrokeWidth = 3;
+ b.collideSound = "/sound/子弹音效/火炮爆炸.ogg";
+ return b;
+}
+
// Register all explosive bullets
BulletRegistry.register('H_S', H_S);
BulletRegistry.register('H_L', H_L);
BulletRegistry.register('H_LL', H_LL);
BulletRegistry.register('H_Target_S', H_Target_S);
+BulletRegistry.register('ManualCannon_Shell', ManualCannon_Shell);
diff --git a/src/bullets/variants/special.ts b/src/bullets/variants/special.ts
index aefa7de..fc2486c 100644
--- a/src/bullets/variants/special.ts
+++ b/src/bullets/variants/special.ts
@@ -12,7 +12,7 @@ import { BulletRegistry } from '../bulletRegistry';
*/
export function S(): Bully {
const b = new Bully(Vector.zero(), Vector.zero(), null, 5, 2.5);
- b.r = 1.6;
+ b.r = 2;
b.damage = 40;
b.bodyColor = MyColor.arrTo([0, 0, 255, 1]);
return b;
diff --git a/src/core/functions.ts b/src/core/functions.ts
index 45f04b8..40ad219 100644
--- a/src/core/functions.ts
+++ b/src/core/functions.ts
@@ -1,184 +1,50 @@
/**
* Game balance functions
* by littlefean
+ *
+ * This class wraps the shared pure functions for backward compatibility.
+ * New code should import directly from @shared/formulas/gameBalance.
*/
-export class Functions {
- /**
- * Monster HP increase based on world tick
- */
- static timeMonsterHp(t: number): number {
- return t / 3;
- }
- static timeMonsterAtt(t: number): number {
- return t / 5;
- }
+import * as GameBalance from '@shared/formulas/gameBalance';
- /**
- * Monster spawn count based on time
- */
- static timeMonsterAddedNum(t: number): number {
- const res = Math.floor(Math.pow(t / 20, 0.2));
- return res < 0 ? 1 : res;
- }
+// Re-export all pure functions for direct imports
+export * from '@shared/formulas/gameBalance';
- /**
- * Total monsters in wave n
- */
- static levelMonsterFlowNum(level: number): number {
- let res = Math.floor(Math.pow(level / 2, 1.1)) + 2;
- res += Math.log(level + 1) * 5;
- res = Math.floor(res);
- return res <= 0 ? 1 : res;
- }
-
- static levelMonsterFlowNumHard(level: number): number {
- let res = Math.floor(Math.pow(level / 2, 1.35)) + 2;
- res += Math.log(level + 1) * 10;
- res = Math.floor(res);
- return res <= 0 ? 1 : res;
- }
-
- /**
- * Effect alpha based on progress
- * @param tr effect progress 0~1
- */
- static timeRateAlpha(tr: number): number {
- return (1 - tr) * 0.25;
- }
-
- static timeRateAlphaDownFast(tr: number): number {
- return Math.pow((1 - tr) * 0.25, 2);
- }
+// Keep the Functions class as a namespace wrapper for backward compatibility
+export class Functions {
+ static timeMonsterHp = GameBalance.timeMonsterHp;
+ static timeMonsterAtt = GameBalance.timeMonsterAtt;
+ static timeMonsterAddedNum = GameBalance.timeMonsterAddedNum;
+ static levelMonsterFlowNum = GameBalance.levelMonsterFlowNum;
+ static levelMonsterFlowNumHard = GameBalance.levelMonsterFlowNumHard;
+ static timeRateAlpha = GameBalance.timeRateAlpha;
+ static timeRateAlphaDownFast = GameBalance.timeRateAlphaDownFast;
+ static timeAddPrise = GameBalance.timeAddPrise;
+ static levelMonsterHpAddedHard = GameBalance.levelMonsterHpAddedHard;
+ static levelMonsterHpAddedNormal = GameBalance.levelMonsterHpAddedNormal;
+ static levelMonsterHpAddedEasy = GameBalance.levelMonsterHpAddedEasy;
+ static tickMonsterHpAddedEasy = GameBalance.tickMonsterHpAddedEasy;
+ static tickMonsterHpAddedHard = GameBalance.tickMonsterHpAddedHard;
+ static tickAddMonsterNumEasy = GameBalance.tickAddMonsterNumEasy;
+ static tickAddMonsterNumHard = GameBalance.tickAddMonsterNumHard;
+ static levelT800Count = GameBalance.levelT800Count;
+ static levelT800CountHard = GameBalance.levelT800CountHard;
+ static levelAddPrice = GameBalance.levelAddPrice;
+ static levelAddPriceNormal = GameBalance.levelAddPriceNormal;
+ static levelAddPriceHard = GameBalance.levelAddPriceHard;
+ static timeHellTowerDamage_E = GameBalance.timeHellTowerDamage_E;
+ static timeHellTowerDamage = GameBalance.timeHellTowerDamage;
+ static levelCollideAdded = GameBalance.levelCollideAdded;
+ static levelCollideAddedHard = GameBalance.levelCollideAddedHard;
+ static TowerNumPriceAdded = GameBalance.TowerNumPriceAdded;
+ static TowerNumPriceAdded2 = GameBalance.TowerNumPriceAdded2;
/**
* Explosion damage falloff with distance
+ * @deprecated Not implemented
*/
- static disBoomDamage(dis: number): void {
+ static disBoomDamage(_dis: number): void {
// Not implemented
}
-
- /**
- * Monster kill reward based on time
- */
- static timeAddPrise(tick: number): number {
- let res = Math.floor(Math.log10(tick));
- if (res <= 0) {
- res = 1;
- }
- return res;
- }
-
- /**
- * Monster HP cap based on wave
- */
- static levelMonsterHpAddedHard(level: number): number {
- return Math.floor(Math.pow(level, 2.7) + Math.pow(level, 0.5) * 60);
- }
-
- static levelMonsterHpAddedNormal(level: number): number {
- let res = Math.floor(Math.pow(level, 2.5) + Math.pow(level, 0.5) * 60);
- if (res > 100000) {
- res = 100000;
- }
- return res;
- }
-
- static levelMonsterHpAddedEasy(level: number): number {
- let res = Math.floor(Math.pow(level, 2) + Math.pow(level, 0.5) * 60);
- if (res > 5000) {
- res = 5000;
- }
- return res;
- }
-
- /**
- * Monster HP increase based on tick
- */
- static tickMonsterHpAddedEasy(t: number): number {
- return Math.floor(Math.pow(t / 500, 2.5) + Math.pow(t / 500, 0.5) * 60);
- }
-
- static tickMonsterHpAddedHard(t: number): number {
- return Math.floor(Math.pow(t / 500, 2.52) + Math.pow(t / 500, 0.5) * 60);
- }
-
- /**
- * Monster spawn count per tick (endless mode)
- */
- static tickAddMonsterNumEasy(t: number): number {
- return Math.floor(t / 200);
- }
-
- static tickAddMonsterNumHard(t: number): number {
- return Math.floor(t / 180);
- }
-
- /**
- * T800 count based on wave
- */
- static levelT800Count(level: number): number {
- const res = Math.floor(Math.pow(level, 1.2) / 20); // 数量减半
- const count = res < 1 ? 1 : res;
- return count > 10 ? 10 : count; // 最多10个
- }
-
- static levelT800CountHard(level: number): number {
- const res = Math.floor(Math.pow(level, 1.5) / 10);
- return res < 1 ? 1 : res;
- }
-
- /**
- * Monster kill reward based on wave
- */
- static levelAddPrice(level: number): number {
- return Math.floor(Math.log(level) * (level / 10)) + 10;
- }
-
- static levelAddPriceNormal(level: number): number {
- return Math.floor(Math.log(level) * (level / 10));
- }
-
- static levelAddPriceHard(level: number): number {
- return Math.floor(Math.log(level) * (level / 20));
- }
-
- /**
- * Hell tower damage based on lock time
- */
- static timeHellTowerDamage_E(tick: number): number {
- return Math.exp(tick) / 100000_0000;
- }
-
- static timeHellTowerDamage(tick: number): number {
- return Math.pow(tick, 2) / 1000;
- }
-
- /**
- * Monster collision damage based on wave
- */
- static levelCollideAdded(level: number): number {
- return Math.floor(Math.pow(level, 1.55));
- }
-
- static levelCollideAddedHard(level: number): number {
- return Math.floor(Math.pow(level, 2));
- }
-
- /**
- * Tower price increase based on count
- */
- static TowerNumPriceAdded(num: number): number {
- if (num < 8) return 0;
- const n = num - 7;
- return Math.floor(n * n * n * n / 3);
- }
-
- static TowerNumPriceAdded2(num: number): number {
- let x = num - 6;
- if (x < 0) {
- x = 0;
- }
- return Math.floor(Math.pow(x, 1.7));
- }
}
diff --git a/src/core/math/circle.ts b/src/core/math/circle.ts
index 13b68e7..899f862 100644
--- a/src/core/math/circle.ts
+++ b/src/core/math/circle.ts
@@ -4,6 +4,11 @@
*/
import { Vector } from './vector';
import { MyColor, ReadonlyColor } from '../../entities/myColor';
+import {
+ collides as _collides,
+ sweepCollides as _sweepCollides,
+ sweepCollidesRelative as _sweepCollidesRelative,
+} from '@shared/math/circleCollision';
export class Circle {
// Precomputed constant
@@ -35,7 +40,7 @@ export class Circle {
/**
* Check if two circles collide
*/
- impact(otherC: Circle): boolean {
+ impact(otherC: { x: number; y: number; r: number }): boolean {
const dx = otherC.x - this.x;
const dy = otherC.y - this.y;
const distSq = dx * dx + dy * dy;
@@ -52,24 +57,12 @@ export class Circle {
x1: number, y1: number, r1: number,
x2: number, y2: number, r2: number
): boolean {
- const dx = x2 - x1;
- const dy = y2 - y1;
- const distSq = dx * dx + dy * dy;
- const radiusSum = r1 + r2;
- return distSq <= radiusSum * radiusSum;
+ return _collides(x1, y1, r1, x2, y2, r2);
}
/**
* 基础扫掠检测:移动圆 vs 静态圆
* 检测一个从 (x1,y1) 移动到 (x2,y2) 的圆是否与静态圆碰撞
- * @param x1 移动圆起点 x
- * @param y1 移动圆起点 y
- * @param x2 移动圆终点 x
- * @param y2 移动圆终点 y
- * @param movingR 移动圆半径
- * @param cx 静态圆圆心 x
- * @param cy 静态圆圆心 y
- * @param targetR 静态圆半径
*/
static sweepCollides(
x1: number, y1: number,
@@ -78,68 +71,18 @@ export class Circle {
cx: number, cy: number,
targetR: number
): boolean {
- const combinedR = movingR + targetR;
- const combinedRSq = combinedR * combinedR;
-
- // 快速检查:起点已在碰撞范围内
- const dx1 = x1 - cx, dy1 = y1 - cy;
- if (dx1 * dx1 + dy1 * dy1 <= combinedRSq) return true;
-
- // 快速检查:终点已在碰撞范围内
- const dx2 = x2 - cx, dy2 = y2 - cy;
- if (dx2 * dx2 + dy2 * dy2 <= combinedRSq) return true;
-
- // 线段方向向量
- const dx = x2 - x1, dy = y2 - y1;
- const lenSq = dx * dx + dy * dy;
- if (lenSq === 0) return false;
-
- // 解二次方程: at² + bt + c = 0
- // 找到线段上最近点,检查是否在 t∈[0,1] 范围内与圆相交
- const fx = x1 - cx, fy = y1 - cy;
- const a = lenSq;
- const b = 2 * (fx * dx + fy * dy);
- const c = fx * fx + fy * fy - combinedRSq;
-
- const discriminant = b * b - 4 * a * c;
- if (discriminant < 0) return false;
-
- const sqrtD = Math.sqrt(discriminant);
- const t1 = (-b - sqrtD) / (2 * a);
- const t2 = (-b + sqrtD) / (2 * a);
-
- // 检查是否有解在 [0,1] 范围内
- return (t1 >= 0 && t1 <= 1) || (t2 >= 0 && t2 <= 1);
+ return _sweepCollides(x1, y1, x2, y2, movingR, cx, cy, targetR);
}
/**
* 相对速度扫掠检测:两个移动圆
* 将问题转换到目标参考系,目标视为静止
- * @param ax1 物体 A 起点 x
- * @param ay1 物体 A 起点 y
- * @param ax2 物体 A 终点 x
- * @param ay2 物体 A 终点 y
- * @param aR 物体 A 半径
- * @param bx1 物体 B 起点 x
- * @param by1 物体 B 起点 y
- * @param bx2 物体 B 终点 x
- * @param by2 物体 B 终点 y
- * @param bR 物体 B 半径
*/
static sweepCollidesRelative(
ax1: number, ay1: number, ax2: number, ay2: number, aR: number,
bx1: number, by1: number, bx2: number, by2: number, bR: number
): boolean {
- // 转换到 B 的参考系:B 视为静止在原点
- // A 的相对起点(相对于 B 起点)
- const relX1 = ax1 - bx1;
- const relY1 = ay1 - by1;
- // A 的相对终点 = 相对起点 + (A位移 - B位移)
- const relX2 = relX1 + (ax2 - ax1) - (bx2 - bx1);
- const relY2 = relY1 + (ay2 - ay1) - (by2 - by1);
-
- // 检测相对路径与半径和圆的碰撞(圆心在原点)
- return Circle.sweepCollides(relX1, relY1, relX2, relY2, aR, 0, 0, bR);
+ return _sweepCollidesRelative(ax1, ay1, ax2, ay2, aR, bx1, by1, bx2, by2, bR);
}
setStrokeWidth(n: number): void {
diff --git a/src/core/physics/obstacle.ts b/src/core/physics/obstacle.ts
index c059c9b..a4cb4ad 100644
--- a/src/core/physics/obstacle.ts
+++ b/src/core/physics/obstacle.ts
@@ -13,7 +13,7 @@ interface ObstacleSaveData {
interface WorldLike {
width: number;
height: number;
- rootBuilding: { pos: Vector };
+ getBaseBuilding(playerId?: string): { pos: Vector };
}
export class Obstacle {
@@ -88,7 +88,7 @@ export class Obstacle {
const obstacles: Obstacle[] = [];
const count = Math.floor(Math.random() * (maxCount - minCount + 1)) + minCount;
- const rootPos = world.rootBuilding.pos;
+ const rootPos = world.getBaseBuilding().pos;
const minDistance = Math.min(world.width, world.height) * 0.15;
let attempts = 0;
@@ -133,4 +133,105 @@ export class Obstacle {
return obstacles;
}
+
+ /**
+ * Generate pseudo-symmetric obstacles for multiplayer maps
+ * Each side of the map gets independent random placement but same count and density
+ * @param world The world reference for dimensions
+ * @param basePositions Array of base positions (one per player)
+ * @param countPerSide Number of obstacles per side (default 35)
+ */
+ static generatePseudoSymmetric(
+ world: WorldLike,
+ basePositions: Vector[],
+ countPerSide: number = 35
+ ): Obstacle[] {
+ const obstacles: Obstacle[] = [];
+ const centerX = world.width / 2;
+ const minDistFromBase = Math.min(world.width, world.height) * 0.15;
+ const gapHalf = 50; // No obstacles in the center gap
+
+ // Generate for left side
+ const leftObs = Obstacle._generateForRegion(
+ world, 0, centerX - gapHalf, basePositions, minDistFromBase, countPerSide, obstacles
+ );
+ obstacles.push(...leftObs);
+
+ // Generate for right side (independent random, same count)
+ const rightObs = Obstacle._generateForRegion(
+ world, centerX + gapHalf, world.width, basePositions, minDistFromBase, countPerSide, obstacles
+ );
+ obstacles.push(...rightObs);
+
+ return obstacles;
+ }
+
+ /**
+ * Generate obstacles within a specific x-region
+ */
+ private static _generateForRegion(
+ world: WorldLike,
+ minX: number,
+ maxX: number,
+ basePositions: Vector[],
+ minDistFromBase: number,
+ count: number,
+ existingObstacles: Obstacle[]
+ ): Obstacle[] {
+ const result: Obstacle[] = [];
+ let attempts = 0;
+ const maxAttempts = count * 100;
+
+ while (result.length < count && attempts < maxAttempts) {
+ attempts++;
+
+ const x = minX + Math.random() * (maxX - minX);
+ const y = Math.random() * world.height;
+ const radius = Math.random() * 15 + 10;
+
+ // Check edge margin
+ const margin = radius;
+ if (x < margin || x > world.width - margin ||
+ y < margin || y > world.height - margin) {
+ continue;
+ }
+
+ // Check distance from all bases
+ let tooCloseToBase = false;
+ for (const basePos of basePositions) {
+ const dx = x - basePos.x;
+ const dy = y - basePos.y;
+ if (Math.sqrt(dx * dx + dy * dy) < minDistFromBase) {
+ tooCloseToBase = true;
+ break;
+ }
+ }
+ if (tooCloseToBase) continue;
+
+ // Check overlap with existing obstacles
+ let overlaps = false;
+ for (const obs of existingObstacles) {
+ const distObs = Math.sqrt((x - obs.pos.x) ** 2 + (y - obs.pos.y) ** 2);
+ if (distObs < radius + obs.radius + 5) {
+ overlaps = true;
+ break;
+ }
+ }
+ if (overlaps) continue;
+
+ // Check overlap with new obstacles in this region
+ for (const obs of result) {
+ const distObs = Math.sqrt((x - obs.pos.x) ** 2 + (y - obs.pos.y) ** 2);
+ if (distObs < radius + obs.radius + 5) {
+ overlaps = true;
+ break;
+ }
+ }
+ if (overlaps) continue;
+
+ result.push(new Obstacle(new Vector(x, y), radius));
+ }
+
+ return result;
+ }
}
diff --git a/src/core/speedScale.ts b/src/core/speedScale.ts
index c7defba..33d9359 100644
--- a/src/core/speedScale.ts
+++ b/src/core/speedScale.ts
@@ -1,7 +1,2 @@
-// 速度缩放因子 - 用于减少 ticks 计算
-// 设置为 3 表示:速度 x3,周期 /3,实现 1x 速度 = 原 3x 效果
-export const SPEED_SCALE_FACTOR = 3;
-
-// 辅助函数
-export const scaleSpeed = (v: number) => v * SPEED_SCALE_FACTOR;
-export const scalePeriod = (v: number) => Math.max(1, Math.round(v / SPEED_SCALE_FACTOR));
+// Re-export from shared module
+export { SPEED_SCALE_FACTOR, scaleSpeed, scalePeriod } from '@shared/constants/speedScale';
diff --git a/src/effects/effectLine.ts b/src/effects/effectLine.ts
index 0eceda2..e1227cb 100644
--- a/src/effects/effectLine.ts
+++ b/src/effects/effectLine.ts
@@ -12,7 +12,7 @@ import type { Circle } from '../core/math/circle';
interface MonsterLike {
getBodyCircle(): Circle;
- hpChange(dh: number): void;
+ hpChange(dh: number, sourceOwnerId?: string | null): void;
}
interface WorldLike {
@@ -35,6 +35,7 @@ export class EffectLine extends Effect {
animationFunc: () => void;
world: WorldLike | null;
damage: number;
+ ownerId: string | null = null;
/**
* Acquire from pool or create new EffectLine
@@ -85,6 +86,7 @@ export class EffectLine extends Effect {
this.animationFunc = this.flashAnimation;
this.world = null;
this.damage = 0;
+ this.ownerId = null;
}
/**
@@ -99,9 +101,10 @@ export class EffectLine extends Effect {
/**
* Initialize damage properties for continuous damage dealing
*/
- initDamage(world: WorldLike, damage: number): void {
+ initDamage(world: WorldLike, damage: number, ownerId?: string | null): void {
this.world = world;
this.damage = damage;
+ this.ownerId = ownerId ?? null;
}
/**
@@ -125,7 +128,7 @@ export class EffectLine extends Effect {
const nearbyMonsters = this.world.getMonstersInRange(centerX, centerY, queryRadius);
for (let m of nearbyMonsters) {
if (line.intersectWithCircle(m.getBodyCircle())) {
- m.hpChange(-actualDamage);
+ m.hpChange(-actualDamage, this.ownerId);
}
}
}
diff --git a/src/entities/base/circleObject.ts b/src/entities/base/circleObject.ts
index b922043..a5dfb87 100644
--- a/src/entities/base/circleObject.ts
+++ b/src/entities/base/circleObject.ts
@@ -11,16 +11,13 @@ import {
BAR_OFFSET,
type StatusBarCache
} from '../statusBar';
-
-interface CheatMode {
- enabled: boolean;
- infiniteHp: boolean;
-}
+import type { CheatModeLike } from '@/types/worldLike';
interface WorldLike {
width: number;
height: number;
- cheatMode?: CheatMode;
+ cheatMode?: CheatModeLike;
+ markSpatialDirty?(entity: unknown): void;
}
export class CircleObject {
@@ -67,6 +64,12 @@ export class CircleObject {
prevX: number = 0;
prevY: number = 0;
+ // Owner ID for multiplayer support (null = neutral/single-player default)
+ ownerId: string | null = null;
+
+ // Unique ID for multiplayer entity tracking (null = single-player)
+ id: string | null = null;
+
constructor(pos: Vector, world: WorldLike) {
this.pos = pos;
this.world = world;
@@ -120,7 +123,7 @@ export class CircleObject {
protected _markMovement(prevX: number, prevY: number): void {
if (prevX !== this.pos.x || prevY !== this.pos.y) {
- (this.world as any)?.markSpatialDirty?.call(this.world, this);
+ this.world.markSpatialDirty?.(this);
}
}
@@ -184,8 +187,7 @@ export class CircleObject {
} else {
this.r += dr;
}
- // 使用 call 保持 world 作为 this 上下文
- (this.world as any)?.markSpatialDirty?.call(this.world, this);
+ this.world.markSpatialDirty?.(this);
this._markBodyDirty();
}
diff --git a/src/game/entities/entityManager.ts b/src/game/entities/entityManager.ts
index 44a995f..d25e70b 100644
--- a/src/game/entities/entityManager.ts
+++ b/src/game/entities/entityManager.ts
@@ -21,17 +21,24 @@ export interface TowerLike {
// 分离的移动和碰撞方法
goStepMove: () => void;
goStepCollide: () => void;
+ // Rendering methods
+ renderBody?: (ctx: CanvasRenderingContext2D) => void;
+ renderBars?: (ctx: CanvasRenderingContext2D) => void;
}
// 建筑实体接口
export interface BuildingLike {
pos: Vector;
+ r?: number;
gameType?: string;
isDead: () => boolean;
goStep: () => void;
render: (ctx: CanvasRenderingContext2D) => void;
destroy?: (skipRemoveFromBuildings?: boolean) => void;
getBodyCircle: () => any; // Returns Circle or compatible object
+ // Rendering methods
+ renderStatic?: (ctx: CanvasRenderingContext2D) => void;
+ renderDynamic?: (ctx: CanvasRenderingContext2D) => void;
}
// 怪物实体接口
@@ -53,6 +60,9 @@ export interface MonsterLike extends SpatialEntity {
// 上一帧位置(用于扫掠碰撞检测)
prevX: number;
prevY: number;
+ // Multiplayer support
+ ownerId?: string | null;
+ destination?: Vector;
}
// 子弹实体接口
@@ -83,6 +93,10 @@ export interface EntityManagerContext {
export interface EntityRemovalCallbacks {
onTowerRemoved?: () => void;
onBuildingRemoved?: () => void;
+ /** Called when a building dies, with the building as parameter */
+ onBuildingDied?: (building: BuildingLike) => void;
+ /** Called when a tower dies, with the tower as parameter */
+ onTowerDied?: (tower: TowerLike) => void;
}
/**
@@ -277,6 +291,8 @@ export class EntityManager {
const e = EffectCircle.acquire(t.pos);
e.animationFunc = e.destroyAnimation;
this.addEffect(e as unknown as IEffect);
+ // Notify about tower death
+ callbacks?.onTowerDied?.(t);
}
}
this.batterys.length = writeIdx;
@@ -294,6 +310,8 @@ export class EntityManager {
if (b.gameType === "Mine" && b.destroy) {
b.destroy(true);
}
+ // Notify about building death (for base building check)
+ callbacks?.onBuildingDied?.(b);
}
}
this.buildings.length = writeIdx;
diff --git a/src/game/index.ts b/src/game/index.ts
index f60e4e7..5f2f7d2 100644
--- a/src/game/index.ts
+++ b/src/game/index.ts
@@ -30,3 +30,7 @@ export type {
export { WorldRenderer } from './rendering';
export type { WorldRendererContext } from './rendering';
+
+// Player module (multiplayer support)
+export { PlayerManager, isEnemy, isFriendly, belongsTo, isNeutral, filterEnemies, filterFriendlies } from './player';
+export type { GameResult } from './player';
diff --git a/src/game/player/index.ts b/src/game/player/index.ts
new file mode 100644
index 0000000..9f6a08e
--- /dev/null
+++ b/src/game/player/index.ts
@@ -0,0 +1,13 @@
+/**
+ * Player module - Multiplayer support
+ */
+
+export { PlayerManager, type GameResult } from './playerManager';
+export {
+ isEnemy,
+ isFriendly,
+ belongsTo,
+ isNeutral,
+ filterEnemies,
+ filterFriendlies,
+} from './ownership';
diff --git a/src/game/player/ownership.ts b/src/game/player/ownership.ts
new file mode 100644
index 0000000..6df8ae1
--- /dev/null
+++ b/src/game/player/ownership.ts
@@ -0,0 +1,15 @@
+/**
+ * Ownership utility functions for multiplayer support
+ *
+ * Re-exports from shared module.
+ * OwnedEntity type is defined in @/types/player.ts to maintain existing import structure.
+ */
+
+export {
+ isEnemy,
+ isFriendly,
+ belongsTo,
+ isNeutral,
+ filterEnemies,
+ filterFriendlies,
+} from '@shared/types/ownership';
diff --git a/src/game/player/playerManager.ts b/src/game/player/playerManager.ts
new file mode 100644
index 0000000..b75951c
--- /dev/null
+++ b/src/game/player/playerManager.ts
@@ -0,0 +1,290 @@
+/**
+ * PlayerManager - Manages player state for multiplayer support
+ *
+ * In single-player mode, there's one player (DEFAULT_PLAYER_ID).
+ * In multiplayer mode, manages multiple players with their own
+ * bases, money, and alive status.
+ */
+
+import type { Player, PlayerConfig } from '@/types/player';
+import type { BuildingLike } from '@/types/worldLike';
+import { DEFAULT_PLAYER_ID, PLAYER_COLORS, PVP_CONFIG } from '@/types/player';
+
+/**
+ * Game result after checking win conditions
+ */
+export interface GameResult {
+ ended: boolean;
+ winner: Player | null;
+ reason?: 'last_standing' | 'draw' | 'timeout';
+}
+
+export class PlayerManager {
+ /** Map of player ID to player */
+ private _players: Map = new Map();
+
+ /** Current local player ID (for rendering perspective) */
+ private _localPlayerId: string = DEFAULT_PLAYER_ID;
+
+ /** Whether this is a multiplayer game */
+ private _isMultiplayer: boolean = false;
+
+ constructor() {
+ // Initialize with default single-player
+ this.initSinglePlayer();
+ }
+
+ /**
+ * Initialize for single-player mode
+ */
+ initSinglePlayer(initialMoney: number = 200): void {
+ this._players.clear();
+ this._isMultiplayer = false;
+ this._localPlayerId = DEFAULT_PLAYER_ID;
+
+ const player: Player = {
+ id: DEFAULT_PLAYER_ID,
+ name: 'Player',
+ color: PLAYER_COLORS[0],
+ baseBuilding: null,
+ money: initialMoney,
+ isAlive: true,
+ };
+
+ this._players.set(player.id, player);
+ }
+
+ /**
+ * Initialize for multiplayer mode
+ */
+ initMultiplayer(configs: PlayerConfig[]): void {
+ this._players.clear();
+ this._isMultiplayer = true;
+
+ for (let i = 0; i < configs.length; i++) {
+ const config = configs[i];
+ const player: Player = {
+ id: config.id,
+ name: config.name,
+ color: config.color || PLAYER_COLORS[i] || '#888888',
+ baseBuilding: null,
+ money: config.initialMoney ?? PVP_CONFIG.initialMoney,
+ isAlive: true,
+ };
+ this._players.set(player.id, player);
+ }
+
+ // Default local player to first player
+ if (configs.length > 0) {
+ this._localPlayerId = configs[0].id;
+ }
+ }
+
+ /**
+ * Set local player ID (for multiplayer client)
+ */
+ setLocalPlayer(playerId: string): void {
+ if (this._players.has(playerId)) {
+ this._localPlayerId = playerId;
+ }
+ }
+
+ /**
+ * Get a player by ID
+ */
+ getPlayer(id: string): Player | undefined {
+ return this._players.get(id);
+ }
+
+ /**
+ * Get the current local player
+ */
+ getLocalPlayer(): Player | undefined {
+ return this._players.get(this._localPlayerId);
+ }
+
+ /**
+ * Get all players
+ */
+ getAllPlayers(): Player[] {
+ return Array.from(this._players.values());
+ }
+
+ /**
+ * Get all alive players
+ */
+ getAlivePlayers(): Player[] {
+ return this.getAllPlayers().filter(p => p.isAlive);
+ }
+
+ /**
+ * Get player count
+ */
+ get playerCount(): number {
+ return this._players.size;
+ }
+
+ /**
+ * Check if multiplayer mode
+ */
+ get isMultiplayer(): boolean {
+ return this._isMultiplayer;
+ }
+
+ /**
+ * Get local player ID
+ */
+ get localPlayerId(): string {
+ return this._localPlayerId;
+ }
+
+ // =========================================================================
+ // Money management
+ // =========================================================================
+
+ /**
+ * Get money for a player
+ */
+ getMoney(playerId?: string): number {
+ const id = playerId ?? this._localPlayerId;
+ const player = this._players.get(id);
+ return player?.money ?? 0;
+ }
+
+ /**
+ * Add money to a player
+ */
+ addMoney(amount: number, playerId?: string): void {
+ // Guard against NaN
+ if (Number.isNaN(amount)) return;
+ const id = playerId ?? this._localPlayerId;
+ const player = this._players.get(id);
+ if (player) {
+ player.money += amount;
+ }
+ }
+
+ /**
+ * Spend money from a player
+ * @returns true if successful, false if not enough money
+ */
+ spendMoney(amount: number, playerId?: string): boolean {
+ // Guard against NaN
+ if (Number.isNaN(amount)) return false;
+ const id = playerId ?? this._localPlayerId;
+ const player = this._players.get(id);
+ if (!player) return false;
+
+ if (player.money >= amount) {
+ player.money -= amount;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Set money for a player
+ */
+ setMoney(amount: number, playerId?: string): void {
+ // Guard against NaN
+ if (Number.isNaN(amount)) return;
+ const id = playerId ?? this._localPlayerId;
+ const player = this._players.get(id);
+ if (player) {
+ player.money = amount;
+ }
+ }
+
+ // =========================================================================
+ // Base building management
+ // =========================================================================
+
+ /**
+ * Set base building for a player
+ */
+ setBaseBuilding(building: BuildingLike, playerId?: string): void {
+ const id = playerId ?? this._localPlayerId;
+ const player = this._players.get(id);
+ if (player) {
+ player.baseBuilding = building;
+ }
+ }
+
+ /**
+ * Get base building for a player
+ */
+ getBaseBuilding(playerId?: string): BuildingLike | null {
+ const id = playerId ?? this._localPlayerId;
+ const player = this._players.get(id);
+ return player?.baseBuilding ?? null;
+ }
+
+ /**
+ * Get all base buildings (for multi-base scenarios)
+ */
+ getAllBaseBuildings(): BuildingLike[] {
+ const bases: BuildingLike[] = [];
+ for (const player of this._players.values()) {
+ if (player.baseBuilding && player.isAlive) {
+ bases.push(player.baseBuilding);
+ }
+ }
+ return bases;
+ }
+
+ // =========================================================================
+ // Game state management
+ // =========================================================================
+
+ /**
+ * Mark a player as eliminated
+ */
+ eliminatePlayer(playerId: string): void {
+ const player = this._players.get(playerId);
+ if (player) {
+ player.isAlive = false;
+ }
+ }
+
+ /**
+ * Check win conditions
+ */
+ checkGameEnd(): GameResult {
+ const alivePlayers = this.getAlivePlayers();
+
+ // Only one player alive -> winner
+ if (alivePlayers.length === 1) {
+ return {
+ ended: true,
+ winner: alivePlayers[0],
+ reason: 'last_standing',
+ };
+ }
+
+ // No players alive -> draw
+ if (alivePlayers.length === 0) {
+ return {
+ ended: true,
+ winner: null,
+ reason: 'draw',
+ };
+ }
+
+ // Game continues
+ return { ended: false, winner: null };
+ }
+
+ /**
+ * Handle building destroyed event
+ */
+ onBuildingDestroyed(building: BuildingLike): GameResult | null {
+ // Check if this was a base building
+ for (const player of this._players.values()) {
+ if (player.baseBuilding === building) {
+ player.isAlive = false;
+ return this.checkGameEnd();
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/game/rendering/worldRenderer.ts b/src/game/rendering/worldRenderer.ts
index 8146758..2569c45 100644
--- a/src/game/rendering/worldRenderer.ts
+++ b/src/game/rendering/worldRenderer.ts
@@ -62,6 +62,7 @@ export interface WorldRendererContext {
// Systems
territory?: { renderer: { render: (ctx: CanvasRenderingContext2D) => void } };
+ allTerritories?: Array<{ renderer: { render: (ctx: CanvasRenderingContext2D) => void } }>;
fog?: {
enabled: boolean;
renderer: { render: (ctx: CanvasRenderingContext2D, width: number, height: number) => void };
@@ -220,17 +221,21 @@ export class WorldRenderer {
);
}
- // Render territory
- if (this._context.territory) {
+ // Render territory (all players in multiplayer, or single player)
+ if (this._context.allTerritories) {
+ for (const territory of this._context.allTerritories) {
+ territory.renderer.render(ctx);
+ }
+ } else if (this._context.territory) {
this._context.territory.renderer.render(ctx);
}
// Render dynamic parts of buildings (HP bars)
for (const b of this._context.buildings) {
- if ((b as any).gameType === "Mine") continue;
+ if (b.gameType === "Mine") continue;
if (this._isObjectVisible(b, this._visibleBounds)) {
- if (typeof (b as any).renderDynamic === 'function') {
- (b as any).renderDynamic(ctx);
+ if (typeof b.renderDynamic === 'function') {
+ b.renderDynamic(ctx);
}
}
}
@@ -239,14 +244,14 @@ export class WorldRenderer {
// 阶段1: 先渲染所有塔的主体
for (const b of this._context.batterys) {
if (this._isObjectVisible(b, this._visibleBounds)) {
- (b as any).renderBody(ctx);
+ b.renderBody?.(ctx);
}
}
// 阶段2: 再渲染所有塔的状态条(血条、蓄力条等)
// 这样状态条都在同一层级,避免塔A的蓄力条覆盖塔B的血条
for (const b of this._context.batterys) {
if (this._isObjectVisible(b, this._visibleBounds)) {
- (b as any).renderBars(ctx);
+ b.renderBars?.(ctx);
}
}
@@ -537,16 +542,16 @@ export class WorldRenderer {
// Only render static parts of buildings (body, not HP bar) within buffer
for (const b of buildings) {
- if ((b as any).gameType === "Mine") continue;
- const pos = (b as any).pos;
- const r = (b as any).r || 50;
+ if (b.gameType === "Mine") continue;
+ const pos = b.pos;
+ const r = b.r || 50;
// Skip buildings outside buffer
if (pos.x + r < this._bufferLeft || pos.x - r > bufferRight ||
pos.y + r < this._bufferTop || pos.y - r > bufferBottom) {
continue;
}
- if (typeof (b as any).renderStatic === 'function') {
- (b as any).renderStatic(ctx);
+ if (typeof b.renderStatic === 'function') {
+ b.renderStatic(ctx);
} else {
b.render(ctx);
}
@@ -639,6 +644,12 @@ export class WorldRenderer {
cache: cache
});
}
+
+ // Render monster name below the body
+ ctx.fillStyle = "black";
+ ctx.font = "12px Microsoft YaHei";
+ ctx.textAlign = "center";
+ ctx.fillText(monster.name, monster.pos.x, monster.pos.y + monster.r * 1.5);
}
private _renderPlacementPreview(ctx: CanvasRenderingContext2D): void {
diff --git a/src/game/waves/waveManager.ts b/src/game/waves/waveManager.ts
index c15b1d1..8f36878 100644
--- a/src/game/waves/waveManager.ts
+++ b/src/game/waves/waveManager.ts
@@ -1,6 +1,7 @@
/**
* WaveManager - 波次管理器
* 负责管理怪物波次生成和预计算
+ * 支持多人模式下的怪物分散攻击
*/
import { Vector } from '../../core/math/vector';
@@ -130,6 +131,9 @@ export class WaveManager {
if (this.monsterFlow.couldBegin()) {
this.monsterFlow.addToWorld(this._context.mode);
+ // Distribute monsters among alive players in multiplayer mode
+ this.distributeNeutralWave();
+
this.monsterFlow = this.monsterFlowNext.copySelf();
this.monsterFlowNext = MonsterGroup.getMonsterFlow(
this._world,
@@ -188,6 +192,9 @@ export class WaveManager {
this._callbacks.addMonster(m);
}
}
+
+ // Distribute monsters among alive players in multiplayer mode
+ this.distributeNeutralWave();
}
/**
@@ -314,4 +321,141 @@ export class WaveManager {
}
}
}
+
+ // =========================================================================
+ // Multiplayer Wave Distribution
+ // =========================================================================
+
+ /**
+ * Distribute neutral monsters among alive players (multiplayer mode)
+ * Called after a wave is added to redistribute targets
+ */
+ distributeNeutralWave(): void {
+ const playerManager = this._world.getPlayerManager?.();
+ if (!playerManager || !playerManager.isMultiplayer) {
+ return; // Single-player mode, no distribution needed
+ }
+
+ const alivePlayers = playerManager.getAlivePlayers();
+ if (alivePlayers.length === 0) {
+ return; // No alive players
+ }
+
+ // Collect neutral monsters that need redistribution
+ const neutralMonsters: MonsterLike[] = [];
+ for (const monster of this._world.monsters) {
+ if (monster.ownerId === null) {
+ neutralMonsters.push(monster);
+ }
+ }
+
+ if (neutralMonsters.length === 0) {
+ return; // No neutral monsters to distribute
+ }
+
+ // Distribute evenly among alive players
+ let playerIndex = 0;
+ for (const monster of neutralMonsters) {
+ const targetPlayer = alivePlayers[playerIndex % alivePlayers.length];
+ const targetBase = playerManager.getBaseBuilding(targetPlayer.id);
+
+ if (targetBase) {
+ // Set monster's destination to target player's base
+ monster.destination = new Vector(targetBase.pos.x, targetBase.pos.y);
+
+ // Reposition monster to spawn from map edge towards target
+ this._repositionMonsterToEdge(monster, targetBase.pos);
+ }
+
+ playerIndex++;
+ }
+ }
+
+ /**
+ * Reposition a monster to spawn from map edge towards target
+ */
+ private _repositionMonsterToEdge(monster: MonsterLike, targetPos: { x: number; y: number }): void {
+ const world = this._world;
+ const margin = 30;
+
+ // Calculate spawn position from edge towards target
+ // Choose random edge based on target position
+ const centerX = world.width / 2;
+ const centerY = world.height / 2;
+
+ // Determine which edges to spawn from (opposite side of target)
+ const dx = targetPos.x - centerX;
+ const dy = targetPos.y - centerY;
+
+ let spawnX: number;
+ let spawnY: number;
+
+ // Spawn from the opposite side of where the target is
+ if (Math.abs(dx) > Math.abs(dy)) {
+ // Target is more to left/right, spawn from opposite horizontal edge
+ spawnX = dx > 0 ? margin : world.width - margin;
+ spawnY = margin + Math.random() * (world.height - 2 * margin);
+ } else {
+ // Target is more to top/bottom, spawn from opposite vertical edge
+ spawnX = margin + Math.random() * (world.width - 2 * margin);
+ spawnY = dy > 0 ? margin : world.height - margin;
+ }
+
+ // Add some randomness to avoid stacking
+ spawnX += (Math.random() - 0.5) * 60;
+ spawnY += (Math.random() - 0.5) * 60;
+
+ // Clamp to world bounds
+ spawnX = Math.max(margin, Math.min(world.width - margin, spawnX));
+ spawnY = Math.max(margin, Math.min(world.height - margin, spawnY));
+
+ monster.pos.x = spawnX;
+ monster.pos.y = spawnY;
+ }
+
+ /**
+ * Handle player elimination - redistribute monsters targeting the eliminated player
+ */
+ redistributeOnPlayerElimination(eliminatedPlayerId: string): void {
+ const playerManager = this._world.getPlayerManager?.();
+ if (!playerManager || !playerManager.isMultiplayer) {
+ return;
+ }
+
+ const alivePlayers = playerManager.getAlivePlayers();
+ if (alivePlayers.length === 0) {
+ return; // No alive players left
+ }
+
+ // Find monsters targeting the eliminated player and redistribute them
+ let playerIndex = 0;
+ for (const monster of this._world.monsters) {
+ // Check if monster's destination matches eliminated player's former base
+ // We need to check by comparing the destination with the base positions
+ const mDest = monster.destination;
+ if (!mDest) continue;
+
+ // Get former base position of eliminated player
+ const elimBase = playerManager.getBaseBuilding(eliminatedPlayerId);
+ if (elimBase) {
+ const dist = Math.sqrt(
+ Math.pow(mDest.x - elimBase.pos.x, 2) +
+ Math.pow(mDest.y - elimBase.pos.y, 2)
+ );
+
+ // If monster was targeting eliminated player (within 50 units of base)
+ if (dist < 50) {
+ // Reassign to another alive player
+ const newTarget = alivePlayers[playerIndex % alivePlayers.length];
+ const newBase = playerManager.getBaseBuilding(newTarget.id);
+
+ if (newBase) {
+ monster.destination = new Vector(newBase.pos.x, newBase.pos.y);
+ }
+
+ playerIndex++;
+ }
+ }
+ }
+ }
}
diff --git a/src/game/world.ts b/src/game/world.ts
index 83700f8..c05124a 100644
--- a/src/game/world.ts
+++ b/src/game/world.ts
@@ -29,10 +29,17 @@ import { BuildingFinallyCompat } from '../buildings/index';
// Systems imports
import { Territory } from '../systems/territory/territory';
+import { MultiPlayerTerritory } from '../systems/territory/multiPlayerTerritory';
import { Energy } from '../systems/energy/energy';
+import { MultiPlayerEnergy } from '../systems/energy/multiPlayerEnergy';
import { EnergyRenderer } from '../systems/energy/energyRenderer';
import { Mine } from '../systems/energy/mine';
import { FogOfWar } from '../systems/fog';
+import { MultiPlayerFogOfWar } from '../systems/fog/multiPlayerFogOfWar';
+
+// Player imports
+import { PlayerManager } from './player/playerManager';
+import type { PlayerConfig } from '../types/player';
// Type definitions (local interfaces used by World)
interface UserState {
@@ -54,6 +61,14 @@ interface CheatModeState {
disableEnergy: boolean;
}
+/**
+ * World initialization options for multiplayer support
+ */
+interface WorldOptions {
+ isMultiplayer?: boolean;
+ playerConfigs?: PlayerConfig[];
+}
+
// Import entity interfaces from EntityManager
import type {
TowerLike,
@@ -97,15 +112,22 @@ export class World {
obstacles: Obstacle[];
// Root building
- rootBuilding: BuildingLike;
+ rootBuilding!: BuildingLike;
// Territory and energy systems
- territory: Territory;
- energy: Energy;
- energyRenderer: EnergyRenderer;
+ territory!: Territory;
+ allTerritories?: Territory[];
+ energy!: Energy;
+ energyRenderer!: EnergyRenderer;
// Fog of war system
- fog: FogOfWar;
+ fog!: FogOfWar;
+
+ // === Multiplayer Systems (optional) ===
+ private _playerManager: PlayerManager | null = null;
+ private _multiTerritory: MultiPlayerTerritory | null = null;
+ private _multiEnergy: MultiPlayerEnergy | null = null;
+ private _multiFog: MultiPlayerFogOfWar | null = null;
// User state
user: UserState;
@@ -162,7 +184,7 @@ export class World {
readonly worldCenterX: number = 0;
readonly worldCenterY: number = 0;
- constructor(worldWidth: number, worldHeight: number, viewWidth?: number, viewHeight?: number) {
+ constructor(worldWidth: number, worldHeight: number, viewWidth?: number, viewHeight?: number, options?: WorldOptions) {
// World size (game logic space)
this.width = worldWidth;
this.height = worldHeight;
@@ -196,32 +218,19 @@ export class World {
// Obstacles (initialized after rootBuilding)
this.obstacles = [];
- // Place root building
- let RootBuilding = BuildingFinallyCompat.Root!(this as any) as any;
- //console.log('[DEBUG] Before setting pos - RootBuilding.pos:', RootBuilding.pos?.x, RootBuilding.pos?.y);
- RootBuilding.pos = new Vector(this.width / 2, this.height / 2);
- //console.log('[DEBUG] After setting pos - RootBuilding.pos:', RootBuilding.pos?.x, RootBuilding.pos?.y);
- this.rootBuilding = RootBuilding as BuildingLike;
- this.addBuilding(this.rootBuilding);
+ // === Initialize Player Manager ===
+ this._playerManager = new PlayerManager();
+
+ // Check if multiplayer mode
+ if (options?.isMultiplayer && options.playerConfigs && options.playerConfigs.length > 0) {
+ this._initMultiplayer(options.playerConfigs);
+ } else {
+ this._initSinglePlayer();
+ }
- // Territory system (must be created after rootBuilding)
- this.territory = new Territory(this as any);
// Update EntityManager context with territory
(this._entityManager as any)._context = { territory: this.territory };
- // Fog of war system (must be created after rootBuilding)
- this.fog = new FogOfWar(this as any);
-
- // Generate obstacles (must be after rootBuilding)
- this.obstacles = Obstacle.generateRandom(this as any);
-
- // Generate mines (must be after obstacles)
- this.generateMines();
-
- // Energy system (must be created after mines)
- this.energy = new Energy(this as any);
- this.energyRenderer = new EnergyRenderer(this as any);
-
// Center camera on root building
this.camera.centerOn(this.rootBuilding.pos);
@@ -270,6 +279,104 @@ export class World {
this._renderer.markStaticLayerDirty();
}
+ /**
+ * Initialize single-player mode (default)
+ */
+ private _initSinglePlayer(): void {
+ this._playerManager!.initSinglePlayer();
+
+ // Place root building at center
+ let RootBuilding = BuildingFinallyCompat.Root!(this as any) as any;
+ RootBuilding.pos = new Vector(this.width / 2, this.height / 2);
+ this.rootBuilding = RootBuilding as BuildingLike;
+ this.addBuilding(this.rootBuilding);
+
+ // Territory system (must be created after rootBuilding)
+ this.territory = new Territory(this as any);
+
+ // Fog of war system (must be created after rootBuilding)
+ this.fog = new FogOfWar(this as any);
+
+ // Generate obstacles (must be after rootBuilding)
+ this.obstacles = Obstacle.generateRandom(this as any);
+
+ // Generate mines (must be after obstacles)
+ this.generateMines();
+
+ // Energy system (must be created after mines)
+ this.energy = new Energy(this as any);
+ this.energyRenderer = new EnergyRenderer(this as any);
+ }
+
+ /**
+ * Initialize multiplayer mode
+ */
+ private _initMultiplayer(configs: PlayerConfig[]): void {
+ this._playerManager!.initMultiplayer(configs);
+ const playerIds = configs.map(c => c.id);
+ const localPlayerId = this._playerManager!.localPlayerId;
+
+ // Create base buildings for each player
+ for (const config of configs) {
+ this._createBaseForPlayer(config);
+ }
+
+ // Generate obstacles with pseudo-symmetric layout (before mines)
+ this._generatePseudoSymmetricObstacles(configs);
+
+ // Generate mines with pseudo-symmetric layout
+ this._generatePseudoSymmetricMines(configs);
+
+ // Create multiplayer system managers
+ this._multiTerritory = new MultiPlayerTerritory(this as any, playerIds);
+ this._multiFog = new MultiPlayerFogOfWar(this as any, playerIds, localPlayerId);
+ this._multiEnergy = new MultiPlayerEnergy(this as any, playerIds);
+
+ // Facade compatibility: point to local player's instances
+ this.territory = this._multiTerritory.getTerritory(localPlayerId)!;
+ this.allTerritories = this._multiTerritory.getAllTerritories();
+ this.fog = this._multiFog.getLocalFog()!;
+ this.energy = this._multiEnergy.getEnergy(localPlayerId)!;
+
+ // Energy renderer uses local player's energy
+ this.energyRenderer = new EnergyRenderer(this as any);
+ }
+
+ /**
+ * Create base building for a player (multiplayer)
+ */
+ private _createBaseForPlayer(config: PlayerConfig): void {
+ const RootBuilding = BuildingFinallyCompat.Root!(this as any) as any;
+ RootBuilding.pos = new Vector(config.basePosition.x, config.basePosition.y);
+ RootBuilding.ownerId = config.id;
+
+ if (!this.rootBuilding) {
+ // First player's base becomes rootBuilding for backward compatibility
+ this.rootBuilding = RootBuilding as BuildingLike;
+ }
+
+ this.addBuilding(RootBuilding as BuildingLike);
+ this._playerManager!.setBaseBuilding(RootBuilding, config.id);
+ }
+
+ /**
+ * Generate pseudo-symmetric obstacles for multiplayer
+ * Each side has independent random placement but same count and density
+ */
+ private _generatePseudoSymmetricObstacles(configs: PlayerConfig[]): void {
+ const basePositions = configs.map(c => new Vector(c.basePosition.x, c.basePosition.y));
+ this.obstacles = Obstacle.generatePseudoSymmetric(this as any, basePositions);
+ }
+
+ /**
+ * Generate pseudo-symmetric mines for multiplayer
+ * Each side has independent random placement but same count
+ */
+ private _generatePseudoSymmetricMines(configs: PlayerConfig[]): void {
+ const basePositions = configs.map(c => new Vector(c.basePosition.x, c.basePosition.y));
+ this.generateMinesPseudoSymmetric(basePositions);
+ }
+
/**
* Create renderer context object that provides access to World's properties
*/
@@ -362,6 +469,230 @@ export class World {
this._entityManager.addEffect(effect);
}
+ /**
+ * Add money to the owner (multiplayer compatible)
+ * In single-player mode, ownerId is ignored and money goes to world.user
+ * In multiplayer mode, money is dispatched to the correct player via PlayerManager
+ */
+ addMoneyToOwner(ownerId: string | null, amount: number): void {
+ if (Number.isNaN(amount)) {
+ console.error('[World.addMoneyToOwner] NaN detected! ownerId:', ownerId, new Error().stack);
+ return;
+ }
+ if (this._playerManager?.isMultiplayer) {
+ // Multiplayer mode: dispatch to the correct player
+ if (ownerId) {
+ this._playerManager.addMoney(amount, ownerId);
+ }
+ // null ownerId (neutral kills) - no reward in multiplayer
+ } else {
+ // Single-player mode: always add to the main user
+ this.user.money += amount;
+ }
+ }
+
+ /**
+ * Spend money from the owner (multiplayer compatible)
+ * In single-player mode, ownerId is ignored and money is spent from world.user
+ * In multiplayer mode, money is spent from the correct player via PlayerManager
+ * If force is true, money will be set to 0 when insufficient
+ */
+ spendMoneyFromOwner(ownerId: string | null, amount: number, force: boolean = false): boolean {
+ if (Number.isNaN(amount)) {
+ console.error('[World.spendMoneyFromOwner] NaN detected! ownerId:', ownerId, new Error().stack);
+ return false;
+ }
+ if (this._playerManager?.isMultiplayer) {
+ // Multiplayer mode: dispatch to the correct player
+ if (ownerId) {
+ const success = this._playerManager.spendMoney(amount, ownerId);
+ if (!success && force) {
+ // Force spend: set player money to 0
+ const player = this._playerManager.getPlayer(ownerId);
+ if (player) {
+ player.money = 0;
+ }
+ return true;
+ }
+ return success;
+ }
+ // null ownerId - no penalty in multiplayer
+ return false;
+ } else {
+ // Single-player mode: always spend from the main user
+ return this.spendMoney(amount, force);
+ }
+ }
+
+ /**
+ * Get the current player's money
+ * In multiplayer, this will return the local player's money
+ */
+ getMoney(playerId?: string): number {
+ if (this._playerManager?.isMultiplayer) {
+ return this._playerManager.getMoney(playerId);
+ }
+ return this.user.money;
+ }
+
+ /**
+ * Set the current player's money (for save/load and initialization)
+ * In multiplayer, this will set the local player's money
+ */
+ setMoney(amount: number, playerId?: string): void {
+ if (Number.isNaN(amount)) {
+ console.error('[World.setMoney] NaN detected!', new Error().stack);
+ return;
+ }
+ if (this._playerManager?.isMultiplayer) {
+ this._playerManager.setMoney(amount, playerId);
+ return;
+ }
+ this.user.money = amount;
+ }
+
+ /**
+ * Add money to the current player
+ * In multiplayer, this will add to the local player's money
+ */
+ addMoney(amount: number, playerId?: string): void {
+ if (Number.isNaN(amount)) {
+ console.error('[World.addMoney] NaN detected!', new Error().stack);
+ return;
+ }
+ if (this._playerManager?.isMultiplayer) {
+ this._playerManager.addMoney(amount, playerId);
+ return;
+ }
+ this.user.money += amount;
+ }
+
+ /**
+ * Spend money from the current player
+ * Returns true if the player had enough money, false otherwise
+ * If force is true, money will be set to 0 when insufficient
+ */
+ spendMoney(amount: number, force: boolean = false): boolean {
+ if (Number.isNaN(amount)) {
+ console.error('[World.spendMoney] NaN detected!', new Error().stack);
+ return false;
+ }
+ if (this._playerManager?.isMultiplayer) {
+ const success = this._playerManager.spendMoney(amount);
+ if (!success && force) {
+ const player = this._playerManager.getPlayer(
+ this._playerManager.localPlayerId
+ );
+ if (player) player.money = 0;
+ return true;
+ }
+ return success;
+ }
+ if (this.user.money >= amount) {
+ this.user.money -= amount;
+ return true;
+ }
+ if (force) {
+ this.user.money = 0;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Get the base building for a player
+ * In single-player, always returns rootBuilding
+ * In multiplayer, returns the specified player's base
+ */
+ getBaseBuilding(playerId?: string): BuildingLike {
+ if (this._playerManager?.isMultiplayer && playerId) {
+ const base = this._playerManager.getBaseBuilding(playerId);
+ if (base) return base as unknown as BuildingLike;
+ }
+ return this.rootBuilding;
+ }
+
+ /**
+ * Get the Energy instance for a specific owner
+ * Used by towers to calculate damage multiplier based on owner's energy satisfaction
+ * In multiplayer, returns the energy system for the owner's player
+ * In single-player, always returns the global energy instance
+ */
+ getEnergyForOwner(ownerId: string | null): Energy {
+ if (this._multiEnergy && ownerId) {
+ return this._multiEnergy.getEnergy(ownerId) ?? this.energy;
+ }
+ return this.energy;
+ }
+
+ /**
+ * Get the MultiPlayerTerritory manager (for build cost calculation)
+ * Returns null in single-player mode
+ */
+ getMultiTerritory(): MultiPlayerTerritory | null {
+ return this._multiTerritory;
+ }
+
+ /**
+ * Get the PlayerManager (for accessing player data)
+ */
+ getPlayerManager(): PlayerManager | null {
+ return this._playerManager;
+ }
+
+ // =========================================================================
+ // Building Death Handling (for multiplayer win/lose conditions)
+ // =========================================================================
+
+ /** Game result after a building death (for UI to display) */
+ private _lastGameResult: import('./player/playerManager').GameResult | null = null;
+
+ /**
+ * Get the last game result (for UI to check)
+ */
+ getLastGameResult(): import('./player/playerManager').GameResult | null {
+ return this._lastGameResult;
+ }
+
+ /**
+ * Clear the game result (after UI has processed it)
+ */
+ clearGameResult(): void {
+ this._lastGameResult = null;
+ }
+
+ /**
+ * Handle building death - check if base building was destroyed
+ */
+ private _handleBuildingDied(building: any): void {
+ if (!this._playerManager) return;
+
+ // Check if this was a base building
+ const result = this._playerManager.onBuildingDestroyed(building);
+ if (result) {
+ this._lastGameResult = result;
+
+ // If a player was eliminated, redistribute monsters
+ if (!result.ended) {
+ // Game not over yet, redistribute wave targets
+ for (const player of this._playerManager.getAllPlayers()) {
+ if (!player.isAlive && player.baseBuilding === building) {
+ this._waveManager.redistributeOnPlayerElimination(player.id);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Handle tower death - check if base building was destroyed (tower can be base)
+ */
+ private _handleTowerDied(tower: any): void {
+ // Towers can also be base buildings in some configurations
+ this._handleBuildingDied(tower);
+ }
+
/**
* Add bullet to global cache
* @delegate EntityManager
@@ -509,6 +840,139 @@ export class World {
}
}
+ /**
+ * Generate mines with pseudo-symmetric layout for multiplayer
+ * Each half has independent random placement but same count
+ * @param basePositions Array of base positions (typically 2 for 2-player)
+ */
+ generateMinesPseudoSymmetric(basePositions: Vector[]): void {
+ if (basePositions.length < 2) {
+ // Fallback to normal generation for single player
+ this.generateMines();
+ return;
+ }
+
+ const centerX = this.width / 2;
+ const minDistFromEdge = 100;
+ const minDistFromBase = 200;
+ const maxAttempts = 100;
+
+ // Total mines to generate on each side
+ const minesPerSide = 80;
+
+ // Generate guaranteed mines near each base
+ const guaranteedNearBase = 3;
+ const guaranteedMinDist = 100;
+ const guaranteedMaxDist = 300;
+
+ for (const basePos of basePositions) {
+ let generated = 0;
+ let attempts = 0;
+
+ while (generated < guaranteedNearBase && attempts < maxAttempts * guaranteedNearBase) {
+ const angle = Math.random() * Math.PI * 2;
+ const dist = guaranteedMinDist + Math.random() * (guaranteedMaxDist - guaranteedMinDist);
+ const x = basePos.x + Math.cos(angle) * dist;
+ const y = basePos.y + Math.sin(angle) * dist;
+ const pos = new Vector(x, y);
+ attempts++;
+
+ if (x < minDistFromEdge || x > this.width - minDistFromEdge ||
+ y < minDistFromEdge || y > this.height - minDistFromEdge) {
+ continue;
+ }
+ if (this.isPositionOnObstacle(pos, 25) || this.isPositionOnBuilding(pos, 25)) {
+ continue;
+ }
+
+ this.mines.add(new Mine(pos, this as any));
+ generated++;
+ }
+ }
+
+ // Generate remaining mines on left side (x < centerX - 100)
+ this._generateMinesInRegion(minDistFromEdge, centerX - 100, minDistFromEdge, this.height - minDistFromEdge,
+ minesPerSide - guaranteedNearBase, basePositions, minDistFromBase);
+
+ // Generate remaining mines on right side (x > centerX + 100)
+ this._generateMinesInRegion(centerX + 100, this.width - minDistFromEdge, minDistFromEdge, this.height - minDistFromEdge,
+ minesPerSide - guaranteedNearBase, basePositions, minDistFromBase);
+
+ // Generate mines in middle contested zone
+ this._generateMinesInRegion(centerX - 100, centerX + 100, minDistFromEdge, this.height - minDistFromEdge,
+ 10, basePositions, minDistFromBase);
+ }
+
+ /**
+ * Generate mines in a specific region with grid distribution
+ */
+ private _generateMinesInRegion(
+ minX: number, maxX: number, minY: number, maxY: number,
+ count: number, basePositions: Vector[], minDistFromBase: number
+ ): void {
+ const effectiveWidth = maxX - minX;
+ const effectiveHeight = maxY - minY;
+
+ if (effectiveWidth <= 0 || effectiveHeight <= 0 || count <= 0) return;
+
+ const gridCols = Math.max(1, Math.ceil(Math.sqrt(count * effectiveWidth / effectiveHeight)));
+ const gridRows = Math.max(1, Math.ceil(count / gridCols));
+ const cellWidth = effectiveWidth / gridCols;
+ const cellHeight = effectiveHeight / gridRows;
+
+ // Shuffle cell indices
+ let cellIndices: number[] = [];
+ for (let i = 0; i < gridCols * gridRows; i++) {
+ cellIndices.push(i);
+ }
+ for (let i = cellIndices.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [cellIndices[i], cellIndices[j]] = [cellIndices[j], cellIndices[i]];
+ }
+
+ let generated = 0;
+ const maxAttempts = 100;
+
+ for (const idx of cellIndices) {
+ if (generated >= count) break;
+
+ const col = idx % gridCols;
+ const row = Math.floor(idx / gridCols);
+
+ const margin = 20;
+ const baseX = minX + col * cellWidth;
+ const baseY = minY + row * cellHeight;
+
+ let attempts = 0;
+ let placed = false;
+
+ while (!placed && attempts < maxAttempts) {
+ const x = baseX + margin + Math.random() * Math.max(0, cellWidth - 2 * margin);
+ const y = baseY + margin + Math.random() * Math.max(0, cellHeight - 2 * margin);
+ const pos = new Vector(x, y);
+ attempts++;
+
+ // Check distance from all bases
+ let tooCloseToBase = false;
+ for (const basePos of basePositions) {
+ if (pos.disSq(basePos) < minDistFromBase * minDistFromBase) {
+ tooCloseToBase = true;
+ break;
+ }
+ }
+ if (tooCloseToBase) continue;
+
+ if (this.isPositionOnObstacle(pos, 25) || this.isPositionOnBuilding(pos, 25)) {
+ continue;
+ }
+
+ this.mines.add(new Mine(pos, this as any));
+ generated++;
+ placed = true;
+ }
+ }
+ }
+
/**
* Add a building to the world
* @delegate EntityManager
@@ -564,7 +1028,9 @@ export class World {
// Clean up dead/removed entities (delegated to EntityManager)
const { towerRemoved, buildingRemoved } = this._entityManager.cleanupEntities({
onTowerRemoved: () => { this._renderer.markStaticLayerDirty(); },
- onBuildingRemoved: () => { this._renderer.markStaticLayerDirty(); }
+ onBuildingRemoved: () => { this._renderer.markStaticLayerDirty(); },
+ onBuildingDied: (building) => { this._handleBuildingDied(building as any); },
+ onTowerDied: (tower) => { this._handleTowerDied(tower as any); }
});
// Add monster flow (delegated to WaveManager)
@@ -573,11 +1039,20 @@ export class World {
// Update all entities (delegated to EntityManager)
this._entityManager.updateEntities();
- // Energy system tick
- this.energy.goTick();
+ // Energy system tick (multiplayer-aware)
+ if (this._multiEnergy) {
+ this._multiEnergy.goTick();
+ } else {
+ this.energy.goTick();
+ }
+
+ // Fog of war update (multiplayer: only update local player's fog)
+ if (this._multiFog) {
+ this._multiFog.update();
+ } else {
+ this.fog.update();
+ }
- // Fog of war update
- this.fog.update();
this.time++;
}
@@ -605,6 +1080,10 @@ export class World {
this._renderer.markStaticLayerDirty();
}
+ markBuildingQuadTreeDirty(): void {
+ this._spatialSystem.markBuildingQuadTreeDirty();
+ }
+
/**
* Mark UI layer as dirty
* @delegate WorldRenderer
diff --git a/src/monsters/ai/targetSelection.ts b/src/monsters/ai/targetSelection.ts
index d7ed31b..96d16f2 100644
--- a/src/monsters/ai/targetSelection.ts
+++ b/src/monsters/ai/targetSelection.ts
@@ -6,6 +6,8 @@
import { Vector } from '@/core/math';
import { scalePeriod } from '@/core/speedScale';
+import { isEnemy } from '@/game/player/ownership';
+import type { OwnedEntity } from '@/types/player';
// 目标选择策略
export type TargetStrategy = 'nearest' | 'weakest' | 'threat' | 'balanced';
@@ -33,7 +35,7 @@ export const DEFAULT_TARGET_CONFIG: TargetConfig = {
};
/** 建筑接口 */
-interface BuildingLike {
+interface BuildingLike extends OwnedEntity {
pos: { x: number; y: number };
hp: number;
maxHp: number;
@@ -44,7 +46,7 @@ interface BuildingLike {
}
/** 怪物接口 */
-interface MonsterLike {
+interface MonsterLike extends OwnedEntity {
pos: Vector;
liveTime: number;
world: {
@@ -115,6 +117,10 @@ export function selectTarget(
case 'nearest': {
let minDist = Infinity;
for (const b of buildings) {
+ // Filter friendly buildings (same owner)
+ if (!isEnemy(monster, b)) {
+ continue;
+ }
const dx = b.pos.x - monster.pos.x;
const dy = b.pos.y - monster.pos.y;
const dist = dx * dx + dy * dy; // 不需要开方,只比较大小
@@ -129,6 +135,10 @@ export function selectTarget(
case 'weakest': {
let minHp = Infinity;
for (const b of buildings) {
+ // Filter friendly buildings (same owner)
+ if (!isEnemy(monster, b)) {
+ continue;
+ }
if (b.hp < minHp) {
minHp = b.hp;
bestTarget = b;
@@ -140,6 +150,10 @@ export function selectTarget(
case 'threat': {
let maxThreat = -Infinity;
for (const b of buildings) {
+ // Filter friendly buildings (same owner)
+ if (!isEnemy(monster, b)) {
+ continue;
+ }
const threat = calcThreatScore(b);
if (threat > maxThreat) {
maxThreat = threat;
@@ -153,6 +167,10 @@ export function selectTarget(
default: {
let bestScore = -Infinity;
for (const b of buildings) {
+ // Filter friendly buildings (same owner)
+ if (!isEnemy(monster, b)) {
+ continue;
+ }
const score = calcBuildingScore(
b,
monster.pos.x,
diff --git a/src/monsters/base/monster.ts b/src/monsters/base/monster.ts
index b9d230c..be5324c 100644
--- a/src/monsters/base/monster.ts
+++ b/src/monsters/base/monster.ts
@@ -15,8 +15,10 @@ import {
} from '../../entities/statusBar';
import { MonsterRegistry } from '../monsterRegistry';
import { MONSTER_IMG_PRE_WIDTH, MONSTER_IMG_PRE_HEIGHT, getMonstersImg } from '../monsterConstants';
+import { renderMonster } from '../rendering/monsterRenderer';
import { World } from '../../game/world';
import { scaleSpeed, scalePeriod } from '../../core/speedScale';
+import { isEnemy } from '@/game/player/ownership';
import {
calcBulletDodge,
selectTarget,
@@ -25,6 +27,17 @@ import {
type DodgeConfig,
type TargetConfig
} from '../ai';
+import type {
+ VectorLike as BaseVectorLike,
+ CircleLike as BaseCircleLike,
+ BulletLike as BaseBulletLike,
+ BuildingLike as BaseBuildingLike,
+ TerritoryLike,
+ UserLike,
+ RootBuildingLike as BaseRootBuildingLike,
+ FogOfWarLike,
+ CheatModeLike,
+} from '@/types/worldLike';
// Declare globals for non-migrated modules
declare const EffectLine: {
@@ -41,20 +54,17 @@ declare const Functions: {
levelAddPrice(level: number): number;
} | undefined;
-interface VectorLike {
- x: number;
- y: number;
+// Extended VectorLike with additional methods for monster movement
+interface VectorLike extends BaseVectorLike {
copy(): VectorLike;
- sub(other: VectorLike): VectorLike;
+ sub(other: BaseVectorLike): VectorLike;
abs(): number;
- add(other: VectorLike): void;
+ add(other: BaseVectorLike): void;
}
-interface CircleLike {
- x: number;
- y: number;
- r: number;
- impact(other: CircleLike): boolean;
+// Extended CircleLike with additional methods
+interface CircleLike extends BaseCircleLike {
+ impact(other: BaseCircleLike): boolean;
pointIn?(x: number, y: number): boolean;
}
@@ -68,46 +78,27 @@ interface EffectCircleLike {
flashRedAnimation: () => void;
}
-interface BulletLike {
+// Extended BulletLike for monster interaction
+interface BulletLike extends BaseBulletLike {
pos: VectorLike;
- speed: VectorLike; // Required by bulletDodge AI
- r: number;
- laserDestoryAble?: boolean;
- bodyRadiusChange(dr: number): void;
- acceleration: VectorLike;
- damageChange(delta: number): void;
- remove(): void;
}
-interface BuildingLike {
- pos: VectorLike;
- hp: number; // Required by targetSelection AI
- maxHp: number; // Required by targetSelection AI
+// Extended BuildingLike for monster targeting
+interface BuildingLike extends BaseBuildingLike {
getBodyCircle(): CircleLike;
- hpChange(delta: number): void;
- isDead(): boolean;
- // Tower-specific (optional, for threat calculation)
- damage?: number;
- clock?: number;
-}
-
-interface TerritoryLike {
- markDirty(): void;
}
-interface UserLike {
- money: number;
-}
-
-interface RootBuildingLike {
+// Extended RootBuildingLike with Vector pos
+interface RootBuildingLike extends BaseRootBuildingLike {
pos: Vector;
}
-interface FogLike {
- enabled: boolean;
+// Extended FogLike
+interface FogLike extends FogOfWarLike {
isCircleVisible(x: number, y: number, radius: number): boolean;
}
+// WorldLike interface for Monster
interface WorldLike {
width: number;
height: number;
@@ -118,11 +109,11 @@ interface WorldLike {
monsterRadiusRange: number;
monsters: Set;
allBullys: Iterable;
- rootBuilding: RootBuildingLike;
+ getBaseBuilding(): RootBuildingLike;
user: UserLike;
territory?: TerritoryLike;
fog?: FogLike;
- cheatMode?: { enabled: boolean; infiniteHp: boolean };
+ cheatMode?: CheatModeLike;
getMonstersInRange(x: number, y: number, range: number): Monster[];
getBullysInRange(x: number, y: number, range: number): BulletLike[];
getBuildingsInRange(x: number, y: number, range: number): BuildingLike[];
@@ -130,6 +121,7 @@ interface WorldLike {
addMonster(monster: Monster): void;
removeMonster(monster: Monster): void;
addEffect(effect: unknown): void;
+ addMoneyToOwner(ownerId: string | null, amount: number): void;
}
interface GainDetails {
@@ -266,6 +258,9 @@ export class Monster extends CircleObject {
imgIndex: number;
+ // Multiplayer: track who dealt the last damage (for kill reward)
+ lastDamageOwnerId: string | null = null;
+
declare world: WorldLike;
constructor(pos: Vector, world: WorldLike) {
@@ -360,13 +355,14 @@ export class Monster extends CircleObject {
this.imgIndex = 0;
}
- static randInit(world: WorldLike): Monster {
- let rootPos = world.rootBuilding.pos;
+ static randInit(world: WorldLike, targetBuilding?: { pos: { x: number; y: number } }): Monster {
+ // Use specified target building or default to rootBuilding
+ let targetPos = targetBuilding?.pos ?? world.getBaseBuilding().pos;
let minRadius = Math.min(world.width, world.height) * 0.5;
let maxRadius = Math.max(world.width, world.height) * 0.6;
let pos = Vector.randCircleOutside(
- rootPos.x, rootPos.y,
+ targetPos.x, targetPos.y,
minRadius, maxRadius,
world.width, world.height
);
@@ -386,7 +382,19 @@ export class Monster extends CircleObject {
teleporting(): void {
if (this.teleportingAble) {
+ // Save old position for spatial grid update
+ const oldX = this.pos.x;
+ const oldY = this.pos.y;
+
this.pos.add(Vector.randCircle().mul(this.teleportingRange));
+
+ // Update prevX/prevY to new position to avoid sweep detection treating teleport as a line
+ this.prevX = this.pos.x;
+ this.prevY = this.pos.y;
+
+ // Notify spatial grid of position change
+ this._markMovement(oldX, oldY);
+
this.teleportingCount--;
if (this.teleportingCount < 0) {
this.teleportingCount = 0;
@@ -513,6 +521,9 @@ export class Monster extends CircleObject {
// Direct position update (skip CircleObject.move's acceleration logic)
this.pos.add(this.speed);
+
+ // Notify spatial grid of position change for accurate range queries
+ this._markMovement(this.prevX, this.prevY);
}
laserDefend(): void {
@@ -630,6 +641,10 @@ export class Monster extends CircleObject {
let nearbyBuildings = this.world.getBuildingsInRange(this.pos.x, this.pos.y, this.gAreaR + 50);
const gAreaRSq = this.gAreaR * this.gAreaR;
for (let b of nearbyBuildings) {
+ // Filter friendly buildings (same owner)
+ if (!isEnemy(this, b)) {
+ continue;
+ }
if (this.pos.disSq(b.pos as Vector) < gAreaRSq) {
// Power plants (Mine in buildings array) cannot be moved, take damage instead
if ((b as any).gameType === "Mine") {
@@ -655,6 +670,10 @@ export class Monster extends CircleObject {
let nearbyBuildings = this.world.getBuildingsInRange(this.pos.x, this.pos.y, this.bombSelfRange + 50);
for (let b of nearbyBuildings) {
+ // Filter friendly buildings (same owner)
+ if (!isEnemy(this, b)) {
+ continue;
+ }
if (b.getBodyCircle().impact(bC as any)) {
// Use disSq for distance calculation, only sqrt when needed for damage
let disSq = this.pos.disSq(b.pos as Vector);
@@ -696,7 +715,11 @@ export class Monster extends CircleObject {
}
}
- hpChange(dh: number): void {
+ hpChange(dh: number, sourceOwnerId?: string | null): void {
+ // Track who dealt damage (for kill reward in multiplayer)
+ if (dh < 0 && sourceOwnerId !== undefined) {
+ this.lastDamageOwnerId = sourceOwnerId;
+ }
return super.hpChange(dh);
}
@@ -705,13 +728,18 @@ export class Monster extends CircleObject {
super.remove();
this.hpSet(0);
this.world.removeMonster(this);
- this.world.user.money += this.addPrice;
+ // Kill reward: dispatch to killer (multiplayer compatible)
+ this.world.addMoneyToOwner(this.lastDamageOwnerId, this.addPrice);
}
clash(): void {
let nearbyBuildings = this.world.getBuildingsInRange(this.pos.x, this.pos.y, this.r + 50);
const myCircle = this.getBodyCircle();
for (let b of nearbyBuildings) {
+ // Filter friendly buildings (same owner)
+ if (!isEnemy(this, b)) {
+ continue;
+ }
const bc = b.getBodyCircle();
if (Circle.collides(myCircle.x, myCircle.y, myCircle.r, bc.x, bc.y, bc.r)) {
this.bombSelf();
@@ -772,6 +800,10 @@ export class Monster extends CircleObject {
clashOnly(): void {
let nearbyBuildings = this.world.getBuildingsInRange(this.pos.x, this.pos.y, this.r + 100);
for (let b of nearbyBuildings) {
+ // Skip friendly buildings (multiplayer support)
+ if (!isEnemy(this, b)) {
+ continue;
+ }
const bc = b.getBodyCircle();
// 使用扫掠检测(建筑不移动,所以用基础扫掠检测)
if (Circle.sweepCollides(
@@ -789,61 +821,7 @@ export class Monster extends CircleObject {
}
render(ctx: CanvasRenderingContext2D): void {
- super.render(ctx);
- const MONSTERS_IMG = getMonstersImg();
- if (MONSTERS_IMG && MONSTERS_IMG.complete && MONSTERS_IMG.naturalWidth > 0) {
- let imgStartPos = this.getImgStartPosByIndex(this.imgIndex);
- ctx.drawImage(
- MONSTERS_IMG,
- imgStartPos.x,
- imgStartPos.y,
- MONSTER_IMG_PRE_WIDTH,
- MONSTER_IMG_PRE_HEIGHT,
- this.pos.x - this.r,
- this.pos.y - this.r,
- this.r * 2,
- this.r * 2
- );
- }
-
- ctx.fillStyle = "black";
- ctx.font = Monster.FONT_12;
- ctx.textAlign = "center";
- ctx.fillText(this.name, this.pos.x, this.pos.y + this.r * 1.5);
-
- if (this.bombSelfAble) {
- Monster.getRenderCircle(this.pos.x, this.pos.y, this.bombSelfRange).renderView(ctx);
- }
- if (this.haveGArea) {
- Monster.getRenderCircle(this.pos.x, this.pos.y, this.gAreaR).renderView(ctx);
- }
- if (this.haveBullyChangeArea) {
- Monster.getRenderCircle(this.pos.x, this.pos.y, this.bullyChangeDetails.r).renderView(ctx);
- }
- if (this.haveGain) {
- Monster.getRenderCircle(this.pos.x, this.pos.y, this.gainDetails.gainRadius).renderView(ctx);
- }
- if (this.haveLaserDefence) {
- Monster.getRenderCircle(this.pos.x, this.pos.y, this.laserRadius).renderView(ctx);
-
- const barH = this.hpBarHeight;
- const barX = this.pos.x - this.r;
- const barY = this.pos.y - this.r + BAR_OFFSET.LASER_DEFENSE * barH;
- const barW = this.r * 2;
- const laserRate = this.laserDefendNum / this.maxLaserNum;
-
- renderStatusBar(ctx, {
- x: barX,
- y: barY,
- width: barW,
- height: barH,
- fillRate: laserRate,
- fillColor: BAR_COLORS.LASER_DEFENSE,
- showText: true,
- textValue: this.laserDefendNum,
- cache: this._laserBarCache
- });
- }
+ renderMonster(this as any, ctx);
}
getImgStartPosByIndex(n: number): Vector {
diff --git a/src/monsters/base/monsterMortis.ts b/src/monsters/base/monsterMortis.ts
index fbab1ee..bb0fe24 100644
--- a/src/monsters/base/monsterMortis.ts
+++ b/src/monsters/base/monsterMortis.ts
@@ -7,24 +7,30 @@ import { Circle } from '../../core/math/circle';
import { MyColor, ReadonlyColor } from '../../entities/myColor';
import { Monster } from './monster';
import { MonsterRegistry } from '../monsterRegistry';
+import { renderMonsterMortis } from '../rendering/monsterRenderer';
import { scaleSpeed } from '../../core/speedScale';
+import { isEnemy } from '@/game/player/ownership';
+import type {
+ VectorLike as BaseVectorLike,
+ CircleLike as BaseCircleLike,
+ BuildingLike as BaseBuildingLike,
+ UserLike,
+ RootBuildingLike,
+} from '@/types/worldLike';
// Declare globals for non-migrated modules
declare const EffectCircle: {
acquire(pos: VectorLike): EffectCircleLike;
} | undefined;
-interface VectorLike {
- x: number;
- y: number;
+// Extended VectorLike with copy method
+interface VectorLike extends BaseVectorLike {
copy(): VectorLike;
}
-interface CircleLike {
- x: number;
- y: number;
- r: number;
- impact(other: CircleLike): boolean;
+// Extended CircleLike with additional methods
+interface CircleLike extends BaseCircleLike {
+ impact(other: BaseCircleLike): boolean;
pointIn?(x: number, y: number): boolean;
}
@@ -35,20 +41,19 @@ interface EffectCircleLike {
initCircleStyle(fillColor: ReadonlyColor, strokeColor: ReadonlyColor, strokeWidth: number): void;
}
-interface BuildingLike {
- pos: VectorLike;
+// Extended BuildingLike for mortis targeting
+interface BuildingLike extends BaseBuildingLike {
getBodyCircle(): CircleLike;
- hpChange(delta: number): void;
- isDead(): boolean;
}
+// WorldLike interface for MonsterMortis
interface WorldLike {
width: number;
height: number;
monsters: Set;
allBullys: Iterable;
- rootBuilding: { pos: Vector };
- user: { money: number };
+ rootBuilding: RootBuildingLike & { pos: Vector };
+ user: UserLike;
getMonstersInRange(x: number, y: number, range: number): Monster[];
getBullysInRange(x: number, y: number, range: number): unknown[];
getBuildingsInRange(x: number, y: number, range: number): BuildingLike[];
@@ -97,6 +102,10 @@ export class MonsterMortis extends Monster {
refreshTarget(): void {
const nearbyBuildings = this.world.getBuildingsInRange(this.pos.x, this.pos.y, this.viewRadius);
for (let building of nearbyBuildings) {
+ // Filter friendly buildings (same owner)
+ if (!isEnemy(this, building)) {
+ continue;
+ }
if (building.getBodyCircle().impact(new Circle(this.pos.x, this.pos.y, this.viewRadius) as any)) {
this.target = building;
return;
@@ -185,6 +194,10 @@ export class MonsterMortis extends Monster {
clashOnly(): void {
const nearbyBuildings = this.world.getBuildingsInRange(this.pos.x, this.pos.y, this.r + 100);
for (let b of nearbyBuildings) {
+ // Skip friendly buildings (multiplayer support)
+ if (!isEnemy(this, b)) {
+ continue;
+ }
const bc = b.getBodyCircle();
// 使用扫掠检测(建筑不移动)
if (Circle.sweepCollides(
@@ -208,8 +221,7 @@ export class MonsterMortis extends Monster {
}
render(ctx: CanvasRenderingContext2D): void {
- super.render(ctx);
- new Circle(this.pos.x, this.pos.y, this.viewRadius).renderView(ctx);
+ renderMonsterMortis(this as any, ctx);
}
}
diff --git a/src/monsters/base/monsterShooter.ts b/src/monsters/base/monsterShooter.ts
index 21a561e..80d488c 100644
--- a/src/monsters/base/monsterShooter.ts
+++ b/src/monsters/base/monsterShooter.ts
@@ -6,23 +6,26 @@ import { Vector } from '../../core/math/vector';
import { Circle } from '../../core/math/circle';
import { Monster } from './monster';
import { MonsterRegistry } from '../monsterRegistry';
+import { renderMonsterShooter } from '../rendering/monsterRenderer';
import { scaleSpeed, scalePeriod } from '../../core/speedScale';
+import { isEnemy } from '../../game/player/ownership';
+import type {
+ VectorLike,
+ CircleLike as BaseCircleLike,
+ BuildingLike as BaseBuildingLike,
+ UserLike,
+ RootBuildingLike,
+} from '@/types/worldLike';
// Declare globals for non-migrated modules
declare const BullyFinally: { S: () => BulletLike } | undefined;
-interface VectorLike {
- x: number;
- y: number;
-}
-
-interface CircleLike {
- x: number;
- y: number;
- r: number;
- impact(other: CircleLike): boolean;
+// Extended CircleLike with impact method
+interface CircleLike extends BaseCircleLike {
+ impact(other: BaseCircleLike): boolean;
}
+// Shooter-specific bullet interface
interface BulletLike {
targetTower: boolean;
father: MonsterShooter;
@@ -31,6 +34,7 @@ interface BulletLike {
pos: Vector;
speed: Vector;
slideRate: number;
+ ownerId: string | null;
goStep(): void;
render(ctx: CanvasRenderingContext2D): void;
remove(): void;
@@ -43,19 +47,19 @@ interface BulletLike {
outTowerViewRange(): boolean;
}
-interface BuildingLike {
- pos: VectorLike;
+// Extended BuildingLike for shooter targeting
+interface BuildingLike extends BaseBuildingLike {
getBodyCircle(): CircleLike;
- isDead(): boolean;
}
+// WorldLike interface for MonsterShooter
interface WorldLike {
width: number;
height: number;
monsters: Set;
allBullys: Iterable;
- rootBuilding: { pos: Vector };
- user: { money: number };
+ getBaseBuilding(): RootBuildingLike & { pos: Vector };
+ user: UserLike;
getMonstersInRange(x: number, y: number, range: number): Monster[];
getBullysInRange(x: number, y: number, range: number): unknown[];
getBuildingsInRange(x: number, y: number, range: number): BuildingLike[];
@@ -88,7 +92,7 @@ export class MonsterShooter extends Monster {
this.getmMainBullyFunc = typeof BullyFinally !== 'undefined' ? BullyFinally.S : null;
this.bullySpeed = scaleSpeed(8);
- this.clock = scalePeriod(5);
+ this.clock = scalePeriod(30);
this.attackBullyNum = 1;
this.bullyDeviationRotate = 0;
this.bullySpeedAddMax = 0;
@@ -166,13 +170,33 @@ export class MonsterShooter extends Monster {
}
getTarget(): void {
- const nearbyBuildings = this.world.getBuildingsInRange(this.pos.x, this.pos.y, this.rangeR);
- const viewCircle = this.getViewCircle();
- for (let building of nearbyBuildings) {
- const bc = building.getBodyCircle();
- if (Circle.collides(viewCircle.x, viewCircle.y, viewCircle.r, bc.x, bc.y, bc.r)) {
- this.target = building;
- return;
+ // First, check if current target is still valid (alive and in range)
+ if (this.target !== null) {
+ if (this.target.isDead()) {
+ this.target = null;
+ } else {
+ const tc = this.target.getBodyCircle();
+ const viewCircle = this.getViewCircle();
+ if (!Circle.collides(viewCircle.x, viewCircle.y, viewCircle.r, tc.x, tc.y, tc.r)) {
+ this.target = null;
+ }
+ }
+ }
+
+ // If no valid target, search for a new one
+ if (this.target === null) {
+ const nearbyBuildings = this.world.getBuildingsInRange(this.pos.x, this.pos.y, this.rangeR);
+ const viewCircle = this.getViewCircle();
+ for (const building of nearbyBuildings) {
+ // Skip friendly buildings
+ if (!isEnemy(this, building)) {
+ continue;
+ }
+ const bc = building.getBodyCircle();
+ if (Circle.collides(viewCircle.x, viewCircle.y, viewCircle.r, bc.x, bc.y, bc.r)) {
+ this.target = building;
+ return;
+ }
}
}
}
@@ -209,6 +233,7 @@ export class MonsterShooter extends Monster {
res.originalPos = new Vector(this.pos.x, this.pos.y);
res.world = this.world;
res.pos = new Vector(this.pos.x, this.pos.y).deviation(this.bullyDeviation);
+ res.ownerId = this.ownerId;
let bDir = this.dirction.mul(Math.random() * this.bullySpeedAddMax + this.bullySpeed);
bDir = bDir.deviation(this.bullyDeviationRotate);
res.speed = bDir;
@@ -217,11 +242,7 @@ export class MonsterShooter extends Monster {
}
render(ctx: CanvasRenderingContext2D): void {
- super.render(ctx);
- new Circle(this.pos.x, this.pos.y, this.rangeR).renderView(ctx);
- for (let b of this.bullys) {
- b.render(ctx);
- }
+ renderMonsterShooter(this as any, ctx);
}
getViewCircle(): Circle {
diff --git a/src/monsters/base/monsterTerminator.ts b/src/monsters/base/monsterTerminator.ts
index f69cc14..a1837fd 100644
--- a/src/monsters/base/monsterTerminator.ts
+++ b/src/monsters/base/monsterTerminator.ts
@@ -8,28 +8,35 @@ import { Circle } from '../../core/math/circle';
import { MyColor } from '../../entities/myColor';
import { Monster } from './monster';
import { MonsterRegistry } from '../monsterRegistry';
+import { renderMonsterTerminator } from '../rendering/monsterRenderer';
import { scaleSpeed } from '../../core/speedScale';
+import { isEnemy } from '@/game/player/ownership';
+import type {
+ CircleLike as BaseCircleLike,
+ BuildingLike as BaseBuildingLike,
+ UserLike,
+ RootBuildingLike,
+} from '@/types/worldLike';
-interface CircleLike {
- x: number;
- y: number;
- r: number;
- impact(other: CircleLike): boolean;
+// Extended CircleLike with impact method
+interface CircleLike extends BaseCircleLike {
+ impact(other: BaseCircleLike): boolean;
}
-interface BuildingLike {
+// Extended BuildingLike for terminator targeting (uses Vector pos)
+interface BuildingLike extends BaseBuildingLike {
pos: Vector;
getBodyCircle(): CircleLike;
- hpChange(delta: number): void;
}
+// WorldLike interface for MonsterTerminator
interface WorldLike {
width: number;
height: number;
monsters: Set;
allBullys: Iterable;
- rootBuilding: { pos: Vector };
- user: { money: number };
+ rootBuilding: RootBuildingLike & { pos: Vector };
+ user: UserLike;
getMonstersInRange(x: number, y: number, range: number): Monster[];
getBullysInRange(x: number, y: number, range: number): unknown[];
getBuildingsInRange(x: number, y: number, range: number): BuildingLike[];
@@ -53,23 +60,23 @@ export class MonsterTerminator extends Monster {
this.scar = new Set();
}
- hpChange(dh: number): void {
+ hpChange(dh: number, sourceOwnerId?: string | null): void {
let damage = -dh;
if (damage < 10) {
return;
}
if (damage < 100) {
- super.hpChange(-1);
+ super.hpChange(-1, sourceOwnerId);
} else if (damage < 300) {
- super.hpChange(-5);
+ super.hpChange(-5, sourceOwnerId);
} else if (damage < 500) {
- super.hpChange(-100);
+ super.hpChange(-100, sourceOwnerId);
} else if (damage < 1500) {
- super.hpChange(-300);
+ super.hpChange(-300, sourceOwnerId);
} else if (damage < 3000) {
- super.hpChange(-500);
+ super.hpChange(-500, sourceOwnerId);
} else {
- super.hpChange(-damage * 0.75);
+ super.hpChange(-damage * 0.75, sourceOwnerId);
}
}
@@ -113,6 +120,10 @@ export class MonsterTerminator extends Monster {
this.meeleAttacking = false;
const nearbyBuildings = this.world.getBuildingsInRange(this.pos.x, this.pos.y, this.r + 100);
for (let b of nearbyBuildings) {
+ // Skip friendly buildings (multiplayer support)
+ if (!isEnemy(this, b)) {
+ continue;
+ }
const bc = b.getBodyCircle();
// 使用扫掠检测(建筑不移动)
if (Circle.sweepCollides(
@@ -134,10 +145,7 @@ export class MonsterTerminator extends Monster {
}
render(ctx: CanvasRenderingContext2D): void {
- super.render(ctx);
- for (let s of this.scar) {
- s.render(ctx);
- }
+ renderMonsterTerminator(this as any, ctx);
}
}
diff --git a/src/monsters/rendering/index.ts b/src/monsters/rendering/index.ts
new file mode 100644
index 0000000..bfd5aee
--- /dev/null
+++ b/src/monsters/rendering/index.ts
@@ -0,0 +1,4 @@
+/**
+ * Monster rendering module
+ */
+export * from './monsterRenderer';
diff --git a/src/monsters/rendering/monsterRenderer.ts b/src/monsters/rendering/monsterRenderer.ts
new file mode 100644
index 0000000..a2edf87
--- /dev/null
+++ b/src/monsters/rendering/monsterRenderer.ts
@@ -0,0 +1,283 @@
+/**
+ * MonsterRenderer - Rendering functions for Monster and its subclasses
+ * Extracted from Monster, MonsterMortis, MonsterShooter, MonsterTerminator
+ */
+import { Circle } from '../../core/math/circle';
+import { Vector } from '../../core/math/vector';
+import {
+ renderStatusBar,
+ BAR_OFFSET,
+ BAR_COLORS,
+ type StatusBarCache
+} from '../../entities/statusBar';
+import { getMonstersImg, MONSTER_IMG_PRE_WIDTH, MONSTER_IMG_PRE_HEIGHT } from '../monsterConstants';
+
+// ============================================================================
+// Type interfaces for loose coupling (avoid circular dependencies)
+// ============================================================================
+
+interface MonsterLike {
+ pos: Vector;
+ r: number;
+ hp: number;
+ maxHp: number;
+ name: string;
+ imgIndex: number;
+ hpBarHeight: number;
+ hpColor: { r: number; g: number; b: number; a: number };
+ _hpBarCache: StatusBarCache;
+
+ // Ability flags and properties
+ bombSelfAble: boolean;
+ bombSelfRange: number;
+ haveGArea: boolean;
+ gAreaR: number;
+ haveBullyChangeArea: boolean;
+ bullyChangeDetails: { r: number };
+ haveGain: boolean;
+ gainDetails: { gainRadius: number };
+ haveLaserDefence: boolean;
+ laserRadius: number;
+ laserDefendNum: number;
+ maxLaserNum: number;
+ _laserBarCache: StatusBarCache;
+
+ // Methods
+ isInScreen(): boolean;
+ isDead(): boolean;
+ getBodyCircle(): Circle;
+ getImgStartPosByIndex(n: number): Vector;
+}
+
+interface MonsterMortisLike extends MonsterLike {
+ viewRadius: number;
+}
+
+interface MonsterShooterLike extends MonsterLike {
+ rangeR: number;
+ bullys: Set<{ render(ctx: CanvasRenderingContext2D): void }>;
+}
+
+interface MonsterTerminatorLike extends MonsterLike {
+ scar: Set<{ render(ctx: CanvasRenderingContext2D): void }>;
+}
+
+// ============================================================================
+// Shared rendering resources
+// ============================================================================
+
+// Reusable Circle for range rendering (avoid GC pressure)
+let _renderCircle: Circle | null = null;
+
+function getRenderCircle(x: number, y: number, r: number): Circle {
+ if (!_renderCircle) {
+ _renderCircle = new Circle(x, y, r);
+ } else {
+ _renderCircle.x = x;
+ _renderCircle.y = y;
+ _renderCircle.r = r;
+ }
+ return _renderCircle;
+}
+
+// Static grid dimensions (cached after first calculation)
+let _gridWidth = 0;
+let _gridHeight = 0;
+
+function ensureGridDimensions(): void {
+ if (_gridWidth === 0 || _gridHeight === 0) {
+ const img = getMonstersImg();
+ if (img && img.complete && img.naturalWidth > 0) {
+ _gridWidth = Math.floor(img.naturalWidth / MONSTER_IMG_PRE_WIDTH);
+ _gridHeight = Math.floor(img.naturalHeight / MONSTER_IMG_PRE_HEIGHT);
+ }
+ }
+}
+
+// Cached font string
+const FONT_12 = "12px Microsoft YaHei";
+
+// ============================================================================
+// Core rendering functions
+// ============================================================================
+
+/**
+ * Render base monster body (circle)
+ */
+export function renderMonsterBody(monster: MonsterLike, ctx: CanvasRenderingContext2D): void {
+ if (!monster.isInScreen()) return;
+ const c = monster.getBodyCircle();
+ c.render(ctx);
+}
+
+/**
+ * Render monster HP bar
+ */
+export function renderMonsterHpBar(monster: MonsterLike, ctx: CanvasRenderingContext2D): void {
+ if (!monster.isInScreen()) return;
+ if (monster.maxHp > 0 && !monster.isDead()) {
+ const barH = monster.hpBarHeight;
+ const barX = monster.pos.x - monster.r;
+ const barY = monster.pos.y - monster.r + BAR_OFFSET.HP_TOP * barH;
+ const barW = monster.r * 2;
+ const hpRate = monster.hp / monster.maxHp;
+
+ renderStatusBar(ctx, {
+ x: barX,
+ y: barY,
+ width: barW,
+ height: barH,
+ fillRate: hpRate,
+ fillColor: monster.hpColor,
+ showText: true,
+ textValue: monster.hp,
+ cache: monster._hpBarCache
+ });
+ }
+}
+
+/**
+ * Render monster sprite image
+ */
+export function renderMonsterSprite(monster: MonsterLike, ctx: CanvasRenderingContext2D): void {
+ const MONSTERS_IMG = getMonstersImg();
+ if (MONSTERS_IMG && MONSTERS_IMG.complete && MONSTERS_IMG.naturalWidth > 0) {
+ const imgStartPos = monster.getImgStartPosByIndex(monster.imgIndex);
+ ctx.drawImage(
+ MONSTERS_IMG,
+ imgStartPos.x,
+ imgStartPos.y,
+ MONSTER_IMG_PRE_WIDTH,
+ MONSTER_IMG_PRE_HEIGHT,
+ monster.pos.x - monster.r,
+ monster.pos.y - monster.r,
+ monster.r * 2,
+ monster.r * 2
+ );
+ }
+}
+
+/**
+ * Render monster name text
+ */
+export function renderMonsterName(monster: MonsterLike, ctx: CanvasRenderingContext2D): void {
+ ctx.fillStyle = "black";
+ ctx.font = FONT_12;
+ ctx.textAlign = "center";
+ ctx.fillText(monster.name, monster.pos.x, monster.pos.y + monster.r * 1.5);
+}
+
+/**
+ * Render monster ability range circles
+ */
+export function renderMonsterAbilities(monster: MonsterLike, ctx: CanvasRenderingContext2D): void {
+ // Bomb self range
+ if (monster.bombSelfAble) {
+ getRenderCircle(monster.pos.x, monster.pos.y, monster.bombSelfRange).renderView(ctx);
+ }
+ // Gravity area
+ if (monster.haveGArea) {
+ getRenderCircle(monster.pos.x, monster.pos.y, monster.gAreaR).renderView(ctx);
+ }
+ // Bullet manipulation area
+ if (monster.haveBullyChangeArea) {
+ getRenderCircle(monster.pos.x, monster.pos.y, monster.bullyChangeDetails.r).renderView(ctx);
+ }
+ // Ally buff area
+ if (monster.haveGain) {
+ getRenderCircle(monster.pos.x, monster.pos.y, monster.gainDetails.gainRadius).renderView(ctx);
+ }
+}
+
+/**
+ * Render monster laser defense bar and range
+ */
+export function renderMonsterLaserDefense(monster: MonsterLike, ctx: CanvasRenderingContext2D): void {
+ if (!monster.haveLaserDefence) return;
+
+ // Laser defense range
+ getRenderCircle(monster.pos.x, monster.pos.y, monster.laserRadius).renderView(ctx);
+
+ // Laser defense bar
+ const barH = monster.hpBarHeight;
+ const barX = monster.pos.x - monster.r;
+ const barY = monster.pos.y - monster.r + BAR_OFFSET.LASER_DEFENSE * barH;
+ const barW = monster.r * 2;
+ const laserRate = monster.laserDefendNum / monster.maxLaserNum;
+
+ renderStatusBar(ctx, {
+ x: barX,
+ y: barY,
+ width: barW,
+ height: barH,
+ fillRate: laserRate,
+ fillColor: BAR_COLORS.LASER_DEFENSE,
+ showText: true,
+ textValue: monster.laserDefendNum,
+ cache: monster._laserBarCache
+ });
+}
+
+// ============================================================================
+// Composite rendering functions
+// ============================================================================
+
+/**
+ * Render complete Monster (base class)
+ * Equivalent to Monster.render()
+ */
+export function renderMonster(monster: MonsterLike, ctx: CanvasRenderingContext2D): void {
+ // CircleObject base rendering (body + HP bar)
+ renderMonsterBody(monster, ctx);
+ renderMonsterHpBar(monster, ctx);
+
+ // Monster-specific rendering
+ renderMonsterSprite(monster, ctx);
+ renderMonsterName(monster, ctx);
+ renderMonsterAbilities(monster, ctx);
+ renderMonsterLaserDefense(monster, ctx);
+}
+
+/**
+ * Render MonsterMortis (adds viewRadius circle)
+ */
+export function renderMonsterMortis(monster: MonsterMortisLike, ctx: CanvasRenderingContext2D): void {
+ renderMonster(monster, ctx);
+ // MonsterMortis-specific: view radius circle
+ new Circle(monster.pos.x, monster.pos.y, monster.viewRadius).renderView(ctx);
+}
+
+/**
+ * Render MonsterShooter (adds attack range circle + bullets)
+ */
+export function renderMonsterShooter(monster: MonsterShooterLike, ctx: CanvasRenderingContext2D): void {
+ renderMonster(monster, ctx);
+ // MonsterShooter-specific: attack range circle
+ new Circle(monster.pos.x, monster.pos.y, monster.rangeR).renderView(ctx);
+ // Render bullets
+ for (const b of monster.bullys) {
+ b.render(ctx);
+ }
+}
+
+/**
+ * Render MonsterTerminator (adds scars)
+ */
+export function renderMonsterTerminator(monster: MonsterTerminatorLike, ctx: CanvasRenderingContext2D): void {
+ renderMonster(monster, ctx);
+ // MonsterTerminator-specific: scars
+ for (const s of monster.scar) {
+ s.render(ctx);
+ }
+}
+
+// ============================================================================
+// Export types for external use
+// ============================================================================
+
+export type {
+ MonsterLike,
+ MonsterMortisLike,
+ MonsterShooterLike,
+ MonsterTerminatorLike
+};
diff --git a/src/network/config.ts b/src/network/config.ts
new file mode 100644
index 0000000..a48fc5c
--- /dev/null
+++ b/src/network/config.ts
@@ -0,0 +1,15 @@
+/**
+ * Network Client Configuration
+ */
+export const NETWORK_CONFIG = {
+ // Server URL
+ serverUrl: import.meta.env.VITE_SERVER_URL || 'ws://localhost:2567',
+
+ // Reconnection settings
+ maxReconnectAttempts: 5,
+ reconnectInterval: 2000, // 2 seconds between reconnect attempts
+
+ // Timeouts
+ connectionTimeout: 10000, // 10 seconds to connect
+ roomJoinTimeout: 15000, // 15 seconds to join room
+};
diff --git a/src/network/eventEmitter.ts b/src/network/eventEmitter.ts
new file mode 100644
index 0000000..566d434
--- /dev/null
+++ b/src/network/eventEmitter.ts
@@ -0,0 +1,77 @@
+/**
+ * Network Event Emitter
+ * Simple typed event system for network events
+ */
+
+type EventCallback = (...args: unknown[]) => void;
+
+export class NetworkEventEmitter {
+ private listeners: Map = new Map();
+
+ /**
+ * Register an event listener
+ */
+ on(event: string, callback: EventCallback): () => void {
+ if (!this.listeners.has(event)) {
+ this.listeners.set(event, []);
+ }
+ this.listeners.get(event)!.push(callback);
+
+ // Return unsubscribe function
+ return () => this.off(event, callback);
+ }
+
+ /**
+ * Register a one-time event listener
+ */
+ once(event: string, callback: EventCallback): () => void {
+ const wrapper: EventCallback = (...args) => {
+ this.off(event, wrapper);
+ callback(...args);
+ };
+ return this.on(event, wrapper);
+ }
+
+ /**
+ * Remove an event listener
+ */
+ off(event: string, callback: EventCallback): void {
+ const callbacks = this.listeners.get(event);
+ if (callbacks) {
+ const index = callbacks.indexOf(callback);
+ if (index !== -1) {
+ callbacks.splice(index, 1);
+ }
+ }
+ }
+
+ /**
+ * Emit an event
+ */
+ emit(event: string, ...args: unknown[]): void {
+ const callbacks = this.listeners.get(event);
+ if (callbacks) {
+ for (const callback of [...callbacks]) {
+ try {
+ callback(...args);
+ } catch (error) {
+ console.error(`[NetworkEvent] Error in handler for "${event}":`, error);
+ }
+ }
+ }
+ }
+
+ /**
+ * Remove all listeners
+ */
+ removeAll(): void {
+ this.listeners.clear();
+ }
+
+ /**
+ * Remove all listeners for a specific event
+ */
+ removeAllFor(event: string): void {
+ this.listeners.delete(event);
+ }
+}
diff --git a/src/network/index.ts b/src/network/index.ts
new file mode 100644
index 0000000..da68484
--- /dev/null
+++ b/src/network/index.ts
@@ -0,0 +1,57 @@
+/**
+ * Network Module
+ * Client-side networking for Cannon War multiplayer
+ */
+
+export { NetworkClient, getNetworkClient, ConnectionState, NetworkEvent } from './networkClient';
+export {
+ ClientMessage,
+ ServerMessage,
+ LobbyMessage,
+ type ClientMessageType,
+ type ServerMessageType,
+ type LobbyMessageType,
+ type BuildTowerPayload,
+ type UpgradeTowerPayload,
+ type SellTowerPayload,
+ type SpawnMonsterPayload,
+ type CannonAimPayload,
+ type CannonFirePayload,
+ type CannonSetAutoTargetPayload,
+ type ChatMessagePayload,
+ type GameEndedPayload,
+ type WaveStartingPayload,
+ type MonsterDamagedPayload,
+ type MonsterKilledPayload,
+ type BuildingDamagedPayload,
+ type BuildingDestroyedPayload,
+ type PlayerEliminatedPayload,
+ type ErrorPayload,
+ type ActionRejectedPayload,
+ type BulletFiredPayload,
+ type BulletHitPayload,
+ type BulletExplosionPayload,
+ type RoomInfo,
+ type MatchFoundPayload,
+} from './messages';
+export { NETWORK_CONFIG } from './config';
+export { NetworkEventEmitter } from './eventEmitter';
+export {
+ ReconnectionManager,
+ getReconnectionManager,
+ type ReconnectionState,
+} from './reconnectionManager';
+export {
+ NetworkWorldAdapter,
+ TowerRenderProxy,
+ MonsterRenderProxy,
+ BuildingRenderProxy,
+ BulletRenderProxy,
+ InterpolationSystem,
+ getInterpolationSystem,
+ LocalEffectsManager,
+ getLocalEffectsManager,
+ ClientPrediction,
+ getClientPrediction,
+} from './rendering';
+export { ClientValidator } from './validation';
diff --git a/src/network/messages.ts b/src/network/messages.ts
new file mode 100644
index 0000000..808352e
--- /dev/null
+++ b/src/network/messages.ts
@@ -0,0 +1,59 @@
+/**
+ * Network Message Types
+ * Re-exported from shared/types/messages.ts (single source of truth)
+ */
+
+// Message enums
+export {
+ ClientMessage,
+ ServerMessage,
+ LobbyMessage,
+ type ClientMessageType,
+ type ServerMessageType,
+ type LobbyMessageType,
+} from '@shared/types/messages';
+
+// Client message payloads
+export type {
+ BuildTowerPayload,
+ UpgradeTowerPayload,
+ SellTowerPayload,
+ BuildBuildingPayload,
+ SpawnMonsterPayload,
+ CannonAimPayload,
+ CannonFirePayload,
+ CannonSetAutoTargetPayload,
+ ChatMessagePayload,
+} from '@shared/types/messages';
+
+// Server message payloads
+export type {
+ GameEndedPayload,
+ WaveStartingPayload,
+ MonsterDamagedPayload,
+ MonsterKilledPayload,
+ BuildingDamagedPayload,
+ BuildingDestroyedPayload,
+ PlayerEliminatedPayload,
+ ErrorPayload,
+ ActionRejectedPayload,
+ BulletFiredPayload,
+ BulletHitPayload,
+ BulletExplosionPayload,
+ MineDestroyedPayload,
+ TerritorySyncPayload,
+} from '@shared/types/messages';
+
+// Mine message payloads
+export type {
+ UpgradeMinePayload,
+ RepairMinePayload,
+ DowngradeMinePayload,
+ SellMinePayload,
+} from '@shared/types/messages';
+
+// Vision message payloads
+export type { UpgradeVisionPayload } from '@shared/types/messages';
+
+// Lobby / Room payloads
+export type { RoomInfo, MatchFoundPayload } from '@shared/types/messages';
diff --git a/src/network/networkClient.ts b/src/network/networkClient.ts
new file mode 100644
index 0000000..4dfe248
--- /dev/null
+++ b/src/network/networkClient.ts
@@ -0,0 +1,666 @@
+/**
+ * Network Client
+ * Manages connection to Colyseus server, lobby, and game rooms
+ */
+import * as Colyseus from 'colyseus.js';
+import { NETWORK_CONFIG } from './config';
+import { NetworkEventEmitter } from './eventEmitter';
+import { getReconnectionManager } from './reconnectionManager';
+import {
+ ClientMessage,
+ ServerMessage,
+ LobbyMessage,
+ type BuildTowerPayload,
+ type BuildBuildingPayload,
+ type UpgradeTowerPayload,
+ type SellTowerPayload,
+ type SpawnMonsterPayload,
+ type CannonFirePayload,
+ type CannonSetAutoTargetPayload,
+ type GameEndedPayload,
+ type WaveStartingPayload,
+ type ErrorPayload,
+ type RoomInfo,
+ type MatchFoundPayload,
+ type UpgradeMinePayload,
+ type RepairMinePayload,
+ type DowngradeMinePayload,
+ type SellMinePayload,
+ type UpgradeVisionPayload,
+} from './messages';
+
+/**
+ * Connection state
+ */
+export enum ConnectionState {
+ DISCONNECTED = 'disconnected',
+ CONNECTING = 'connecting',
+ CONNECTED_LOBBY = 'connected_lobby',
+ JOINING_GAME = 'joining_game',
+ IN_GAME = 'in_game',
+ RECONNECTING = 'reconnecting',
+ ERROR = 'error',
+}
+
+/**
+ * Network events
+ */
+export const NetworkEvent = {
+ // Connection events
+ STATE_CHANGED: 'state_changed',
+ CONNECTED: 'connected',
+ DISCONNECTED: 'disconnected',
+ RECONNECTING: 'reconnecting',
+ RECONNECTED: 'reconnected',
+ ERROR: 'error',
+
+ // Lobby events
+ ROOMS_UPDATED: 'rooms_updated',
+ MATCH_FOUND: 'match_found',
+
+ // Game events
+ GAME_STATE_CHANGED: 'game_state_changed',
+ GAME_STARTING: 'game_starting',
+ GAME_STARTED: 'game_started',
+ GAME_PAUSED: 'game_paused',
+ GAME_RESUMED: 'game_resumed',
+ GAME_ENDED: 'game_ended',
+
+ // Wave events
+ WAVE_STARTING: 'wave_starting',
+ WAVE_COMPLETED: 'wave_completed',
+
+ // Combat events
+ BULLET_FIRED: 'bullet_fired',
+ MONSTER_DAMAGED: 'monster_damaged',
+ MONSTER_KILLED: 'monster_killed',
+ BUILDING_DAMAGED: 'building_damaged',
+ BUILDING_DESTROYED: 'building_destroyed',
+
+ // Player events
+ PLAYER_JOINED: 'player_joined',
+ PLAYER_LEFT: 'player_left',
+ PLAYER_ELIMINATED: 'player_eliminated',
+ PLAYER_DISCONNECTED: 'player_disconnected',
+ PLAYER_RECONNECTED: 'player_reconnected',
+
+ // Action feedback
+ ACTION_REJECTED: 'action_rejected',
+} as const;
+
+/**
+ * Main Network Client
+ */
+export class NetworkClient {
+ private colyseusClient: Colyseus.Client;
+ private lobbyRoom: Colyseus.Room | null = null;
+ private gameRoom: Colyseus.Room | null = null;
+
+ private _connectionState: ConnectionState = ConnectionState.DISCONNECTED;
+ private _playerName: string = '';
+ private _playerId: string = '';
+
+ private reconnectAttempts: number = 0;
+
+ public readonly events = new NetworkEventEmitter();
+
+ constructor() {
+ this.colyseusClient = new Colyseus.Client(NETWORK_CONFIG.serverUrl);
+ }
+
+ // ==================== Properties ====================
+
+ get connectionState(): ConnectionState {
+ return this._connectionState;
+ }
+
+ get playerName(): string {
+ return this._playerName;
+ }
+
+ get playerId(): string {
+ return this._playerId;
+ }
+
+ get isConnected(): boolean {
+ return (
+ this._connectionState === ConnectionState.CONNECTED_LOBBY ||
+ this._connectionState === ConnectionState.IN_GAME
+ );
+ }
+
+ get isInGame(): boolean {
+ return this._connectionState === ConnectionState.IN_GAME;
+ }
+
+ get gameState(): unknown {
+ return this.gameRoom?.state;
+ }
+
+ // ==================== Connection ====================
+
+ /**
+ * Connect to lobby
+ */
+ async connectToLobby(playerName: string): Promise {
+ this._playerName = playerName;
+ this.setConnectionState(ConnectionState.CONNECTING);
+
+ try {
+ this.lobbyRoom = await this.colyseusClient.joinOrCreate('lobby', {
+ playerName,
+ });
+
+ this._playerId = this.lobbyRoom.sessionId;
+
+ // Register lobby message handlers
+ this.setupLobbyHandlers(this.lobbyRoom);
+
+ this.setConnectionState(ConnectionState.CONNECTED_LOBBY);
+ this.reconnectAttempts = 0;
+ this.events.emit(NetworkEvent.CONNECTED);
+
+ console.log(`[NetworkClient] Connected to lobby as "${playerName}"`);
+ } catch (error) {
+ console.error('[NetworkClient] Failed to connect to lobby:', error);
+ this.setConnectionState(ConnectionState.ERROR);
+ this.events.emit(NetworkEvent.ERROR, error);
+ throw error;
+ }
+ }
+
+ /**
+ * Disconnect from all rooms
+ */
+ async disconnect(): Promise {
+ if (this.gameRoom) {
+ await this.gameRoom.leave(true);
+ this.gameRoom = null;
+ }
+
+ if (this.lobbyRoom) {
+ await this.lobbyRoom.leave(true);
+ this.lobbyRoom = null;
+ }
+
+ this.setConnectionState(ConnectionState.DISCONNECTED);
+ this.events.emit(NetworkEvent.DISCONNECTED);
+ this.events.removeAll();
+
+ console.log('[NetworkClient] Disconnected');
+ }
+
+ /**
+ * Set server URL and recreate Colyseus client
+ */
+ setServerUrl(url: string): void {
+ if (this.isConnected()) {
+ throw new Error('Cannot change server URL while connected');
+ }
+ this.colyseusClient = new Colyseus.Client(url);
+ console.log(`[NetworkClient] Server URL updated to: ${url}`);
+ }
+
+ // ==================== Lobby Actions ====================
+
+ /**
+ * Create a game room
+ */
+ async createRoom(options: {
+ roomName?: string;
+ mapSize?: string;
+ isPrivate?: boolean;
+ }): Promise {
+ if (!this.lobbyRoom) {
+ throw new Error('Not connected to lobby');
+ }
+
+ this.lobbyRoom.send(LobbyMessage.CREATE_ROOM, options);
+ }
+
+ /**
+ * Join a specific room
+ */
+ async joinRoom(roomId: string): Promise {
+ if (!this.lobbyRoom) {
+ throw new Error('Not connected to lobby');
+ }
+
+ this.lobbyRoom.send(LobbyMessage.JOIN_ROOM, { roomId });
+ }
+
+ /**
+ * Start quick match
+ */
+ quickMatch(): void {
+ if (!this.lobbyRoom) {
+ throw new Error('Not connected to lobby');
+ }
+
+ this.lobbyRoom.send(LobbyMessage.QUICK_MATCH);
+ }
+
+ /**
+ * Cancel quick match search
+ */
+ cancelSearch(): void {
+ if (!this.lobbyRoom) return;
+
+ this.lobbyRoom.send(LobbyMessage.CANCEL_SEARCH);
+ }
+
+ /**
+ * Refresh available rooms list
+ */
+ refreshRooms(): void {
+ if (!this.lobbyRoom) return;
+
+ this.lobbyRoom.send(LobbyMessage.REFRESH_ROOMS);
+ }
+
+ // ==================== Game Actions ====================
+
+ /**
+ * Set player ready state
+ */
+ setReady(ready: boolean): void {
+ if (!this.gameRoom) return;
+
+ this.gameRoom.send(ready ? ClientMessage.PLAYER_READY : ClientMessage.PLAYER_NOT_READY);
+ }
+
+ /**
+ * Build a tower
+ */
+ buildTower(payload: BuildTowerPayload): void {
+ if (!this.gameRoom) return;
+
+ this.gameRoom.send(ClientMessage.BUILD_TOWER, payload);
+ }
+
+ buildBuilding(payload: BuildBuildingPayload): void {
+ if (!this.gameRoom) return;
+
+ this.gameRoom.send(ClientMessage.BUILD_BUILDING, payload);
+ }
+
+ /**
+ * Upgrade a tower
+ */
+ upgradeTower(payload: UpgradeTowerPayload): void {
+ if (!this.gameRoom) return;
+
+ this.gameRoom.send(ClientMessage.UPGRADE_TOWER, payload);
+ }
+
+ /**
+ * Sell a tower
+ */
+ sellTower(payload: SellTowerPayload): void {
+ if (!this.gameRoom) return;
+
+ this.gameRoom.send(ClientMessage.SELL_TOWER, payload);
+ }
+
+ // === Mine Operations ===
+
+ upgradeMine(payload: UpgradeMinePayload): void {
+ if (!this.gameRoom) return;
+ this.gameRoom.send(ClientMessage.UPGRADE_MINE, payload);
+ }
+
+ repairMine(payload: RepairMinePayload): void {
+ if (!this.gameRoom) return;
+ this.gameRoom.send(ClientMessage.REPAIR_MINE, payload);
+ }
+
+ downgradeMine(payload: DowngradeMinePayload): void {
+ if (!this.gameRoom) return;
+ this.gameRoom.send(ClientMessage.DOWNGRADE_MINE, payload);
+ }
+
+ sellMine(payload: SellMinePayload): void {
+ if (!this.gameRoom) return;
+ this.gameRoom.send(ClientMessage.SELL_MINE, payload);
+ }
+
+ /**
+ * Upgrade tower vision (observer/radar)
+ */
+ sendUpgradeVision(payload: UpgradeVisionPayload): void {
+ if (!this.gameRoom) return;
+ this.gameRoom.send(ClientMessage.UPGRADE_VISION, payload);
+ }
+
+ /**
+ * Spawn a monster
+ */
+ spawnMonster(payload: SpawnMonsterPayload): void {
+ if (!this.gameRoom) return;
+
+ this.gameRoom.send(ClientMessage.SPAWN_MONSTER, payload);
+ }
+
+ /**
+ * Fire manual cannon
+ */
+ cannonFire(payload: CannonFirePayload): void {
+ if (!this.gameRoom) return;
+
+ this.gameRoom.send(ClientMessage.CANNON_FIRE, payload);
+ }
+
+ /**
+ * Set cannon auto-target
+ */
+ cannonSetAutoTarget(payload: CannonSetAutoTargetPayload): void {
+ if (!this.gameRoom) return;
+
+ this.gameRoom.send(ClientMessage.CANNON_SET_AUTO_TARGET, payload);
+ }
+
+ cannonClearAutoTarget(towerId: string): void {
+ if (!this.gameRoom) return;
+ this.gameRoom.send(ClientMessage.CANNON_SET_AUTO_TARGET, {
+ towerId,
+ targetX: 0,
+ targetY: 0,
+ radius: 0,
+ clear: true,
+ });
+ }
+
+ /**
+ * Surrender
+ */
+ surrender(): void {
+ if (!this.gameRoom) return;
+
+ this.gameRoom.send(ClientMessage.SURRENDER);
+ }
+
+ /**
+ * Leave current game
+ */
+ async leaveGame(): Promise {
+ if (this.gameRoom) {
+ getReconnectionManager().clearSession();
+ await this.gameRoom.leave(true);
+ this.gameRoom = null;
+
+ if (this.lobbyRoom) {
+ this.setConnectionState(ConnectionState.CONNECTED_LOBBY);
+ } else {
+ this.setConnectionState(ConnectionState.DISCONNECTED);
+ }
+ }
+ }
+
+ /**
+ * Reconnect to a game room using a previously saved reconnection token.
+ * Uses Colyseus client.reconnect() which accepts the format "roomId:token".
+ */
+ async reconnectToGame(token: string): Promise {
+ try {
+ this.gameRoom = await this.colyseusClient.reconnect(token);
+ this.setupGameHandlers(this.gameRoom);
+ this.setConnectionState(ConnectionState.IN_GAME);
+
+ // Save updated token (may change after reconnection)
+ getReconnectionManager().saveSession(this.gameRoom.reconnectionToken);
+ this.reconnectAttempts = 0;
+
+ console.log('[NetworkClient] Game room reconnected successfully');
+ return true;
+ } catch (error) {
+ console.error('[NetworkClient] Game reconnect failed:', error);
+ return false;
+ }
+ }
+
+ // ==================== Private Methods ====================
+
+ /**
+ * Set up lobby message handlers
+ */
+ private setupLobbyHandlers(room: Colyseus.Room): void {
+ room.onMessage(LobbyMessage.ROOM_CREATED, async (data: MatchFoundPayload) => {
+ await this.joinGameRoom(data);
+ });
+
+ room.onMessage(LobbyMessage.MATCH_FOUND, async (data: MatchFoundPayload) => {
+ this.events.emit(NetworkEvent.MATCH_FOUND, data);
+ await this.joinGameRoom(data);
+ });
+
+ room.onMessage(LobbyMessage.ROOM_LIST_UPDATED, (data: { rooms: RoomInfo[] }) => {
+ this.events.emit(NetworkEvent.ROOMS_UPDATED, data.rooms);
+ });
+
+ room.onMessage(LobbyMessage.ERROR, (data: ErrorPayload) => {
+ console.error('[NetworkClient] Lobby error:', data);
+ this.events.emit(NetworkEvent.ERROR, data);
+ });
+
+ room.onLeave((code: number) => {
+ console.log(`[NetworkClient] Left lobby (code: ${code})`);
+ if (code !== 1000) {
+ // Abnormal disconnection
+ this.handleLobbyDisconnect();
+ }
+ });
+
+ room.onError((code: number, message?: string) => {
+ console.error(`[NetworkClient] Lobby error: ${code} - ${message}`);
+ this.events.emit(NetworkEvent.ERROR, { code, message });
+ });
+ }
+
+ /**
+ * Join a game room using reservation
+ */
+ private async joinGameRoom(data: MatchFoundPayload): Promise {
+ this.setConnectionState(ConnectionState.JOINING_GAME);
+
+ try {
+ this.gameRoom = await this.colyseusClient.consumeSeatReservation(
+ data.reservation as Colyseus.SeatReservation
+ );
+
+ // Setup game handlers
+ this.setupGameHandlers(this.gameRoom);
+
+ this.setConnectionState(ConnectionState.IN_GAME);
+
+ // Save reconnection token for potential reconnection
+ getReconnectionManager().saveSession(this.gameRoom.reconnectionToken);
+
+ console.log(`[NetworkClient] Joined game room: ${data.roomId}`);
+ } catch (error) {
+ console.error('[NetworkClient] Failed to join game room:', error);
+ this.setConnectionState(ConnectionState.CONNECTED_LOBBY);
+ this.events.emit(NetworkEvent.ERROR, error);
+ }
+ }
+
+ /**
+ * Set up game room message handlers
+ */
+ private setupGameHandlers(room: Colyseus.Room): void {
+ // State change listener
+ room.onStateChange((state) => {
+ this.events.emit(NetworkEvent.GAME_STATE_CHANGED, state);
+ });
+
+ // Game lifecycle events
+ room.onMessage(ServerMessage.GAME_STARTING, (data) => {
+ this.events.emit(NetworkEvent.GAME_STARTING, data);
+ });
+
+ room.onMessage(ServerMessage.GAME_STARTED, (data) => {
+ this.events.emit(NetworkEvent.GAME_STARTED, data);
+ });
+
+ room.onMessage(ServerMessage.GAME_PAUSED, (data) => {
+ this.events.emit(NetworkEvent.GAME_PAUSED, data);
+ });
+
+ room.onMessage(ServerMessage.GAME_RESUMED, (data) => {
+ this.events.emit(NetworkEvent.GAME_RESUMED, data);
+ });
+
+ room.onMessage(ServerMessage.GAME_ENDED, (data: GameEndedPayload) => {
+ this.events.emit(NetworkEvent.GAME_ENDED, data);
+ });
+
+ // Wave events
+ room.onMessage(ServerMessage.WAVE_STARTING, (data: WaveStartingPayload) => {
+ this.events.emit(NetworkEvent.WAVE_STARTING, data);
+ });
+
+ room.onMessage(ServerMessage.WAVE_COMPLETED, (data) => {
+ this.events.emit(NetworkEvent.WAVE_COMPLETED, data);
+ });
+
+ // Combat events
+ room.onMessage(ServerMessage.BULLET_FIRED, (data) => {
+ this.events.emit(NetworkEvent.BULLET_FIRED, data);
+ });
+
+ room.onMessage(ServerMessage.MONSTER_DAMAGED, (data) => {
+ this.events.emit(NetworkEvent.MONSTER_DAMAGED, data);
+ });
+
+ room.onMessage(ServerMessage.MONSTER_KILLED, (data) => {
+ this.events.emit(NetworkEvent.MONSTER_KILLED, data);
+ });
+
+ room.onMessage(ServerMessage.BUILDING_DAMAGED, (data) => {
+ this.events.emit(NetworkEvent.BUILDING_DAMAGED, data);
+ });
+
+ room.onMessage(ServerMessage.BUILDING_DESTROYED, (data) => {
+ this.events.emit(NetworkEvent.BUILDING_DESTROYED, data);
+ });
+
+ // Player events
+ room.onMessage(ServerMessage.PLAYER_JOINED, (data) => {
+ this.events.emit(NetworkEvent.PLAYER_JOINED, data);
+ });
+
+ room.onMessage(ServerMessage.PLAYER_LEFT, (data) => {
+ this.events.emit(NetworkEvent.PLAYER_LEFT, data);
+ });
+
+ room.onMessage(ServerMessage.PLAYER_ELIMINATED, (data) => {
+ this.events.emit(NetworkEvent.PLAYER_ELIMINATED, data);
+ });
+
+ room.onMessage(ServerMessage.PLAYER_DISCONNECTED, (data) => {
+ this.events.emit(NetworkEvent.PLAYER_DISCONNECTED, data);
+ });
+
+ room.onMessage(ServerMessage.PLAYER_RECONNECTED, (data) => {
+ this.events.emit(NetworkEvent.PLAYER_RECONNECTED, data);
+ });
+
+ // Error handling
+ room.onMessage(ServerMessage.ERROR, (data: ErrorPayload) => {
+ console.error('[NetworkClient] Game error:', data);
+ this.events.emit(NetworkEvent.ERROR, data);
+ });
+
+ // Action rejection feedback (e.g. insufficient funds, invalid position)
+ room.onMessage(ServerMessage.ACTION_REJECTED, (data: { action: string; reason: string; errorCode?: string }) => {
+ this.events.emit(NetworkEvent.ACTION_REJECTED, data);
+ });
+
+ // Room lifecycle
+ room.onLeave((code: number) => {
+ console.log(`[NetworkClient] Left game room (code: ${code})`);
+ this.gameRoom = null;
+
+ if (code !== 1000) {
+ // Abnormal disconnection - attempt reconnect
+ this.handleGameDisconnect();
+ } else {
+ // Normal leave
+ if (this.lobbyRoom) {
+ this.setConnectionState(ConnectionState.CONNECTED_LOBBY);
+ } else {
+ this.setConnectionState(ConnectionState.DISCONNECTED);
+ }
+ }
+ });
+
+ room.onError((code: number, message?: string) => {
+ console.error(`[NetworkClient] Game room error: ${code} - ${message}`);
+ this.events.emit(NetworkEvent.ERROR, { code, message });
+ });
+ }
+
+ /**
+ * Handle lobby disconnection
+ */
+ private async handleLobbyDisconnect(): Promise {
+ this.lobbyRoom = null;
+
+ if (this.reconnectAttempts < NETWORK_CONFIG.maxReconnectAttempts) {
+ this.setConnectionState(ConnectionState.RECONNECTING);
+ this.events.emit(NetworkEvent.RECONNECTING, {
+ attempt: this.reconnectAttempts + 1,
+ maxAttempts: NETWORK_CONFIG.maxReconnectAttempts,
+ });
+
+ this.reconnectAttempts++;
+
+ try {
+ await new Promise((resolve) =>
+ setTimeout(resolve, NETWORK_CONFIG.reconnectInterval)
+ );
+ await this.connectToLobby(this._playerName);
+ this.events.emit(NetworkEvent.RECONNECTED);
+ } catch {
+ this.handleLobbyDisconnect();
+ }
+ } else {
+ this.setConnectionState(ConnectionState.DISCONNECTED);
+ this.events.emit(NetworkEvent.DISCONNECTED);
+ }
+ }
+
+ /**
+ * Handle game disconnection
+ */
+ private handleGameDisconnect(): void {
+ // The server handles reconnection via allowReconnection()
+ // The client just needs to attempt reconnection
+ this.setConnectionState(ConnectionState.RECONNECTING);
+ this.events.emit(NetworkEvent.RECONNECTING);
+
+ console.log('[NetworkClient] Game disconnected, server will wait for reconnection');
+ }
+
+ /**
+ * Set connection state and emit event
+ */
+ private setConnectionState(state: ConnectionState): void {
+ if (this._connectionState !== state) {
+ const oldState = this._connectionState;
+ this._connectionState = state;
+ this.events.emit(NetworkEvent.STATE_CHANGED, { oldState, newState: state });
+ }
+ }
+}
+
+/**
+ * Singleton network client instance
+ */
+let networkClientInstance: NetworkClient | null = null;
+
+export function getNetworkClient(): NetworkClient {
+ if (!networkClientInstance) {
+ networkClientInstance = new NetworkClient();
+ }
+ return networkClientInstance;
+}
diff --git a/src/network/reconnectionManager.ts b/src/network/reconnectionManager.ts
new file mode 100644
index 0000000..eeb6990
--- /dev/null
+++ b/src/network/reconnectionManager.ts
@@ -0,0 +1,258 @@
+/**
+ * Reconnection Manager
+ * Handles automatic reconnection logic for the client
+ */
+import { getNetworkClient, ConnectionState, NetworkEvent } from './networkClient';
+import { NETWORK_CONFIG } from './config';
+
+/**
+ * Reconnection state
+ */
+export interface ReconnectionState {
+ isReconnecting: boolean;
+ attempt: number;
+ maxAttempts: number;
+ nextRetryMs: number;
+ lastDisconnectTime: number;
+}
+
+/**
+ * Reconnection status callback
+ */
+type ReconnectionCallback = (state: ReconnectionState) => void;
+
+/**
+ * Manages reconnection logic
+ */
+export class ReconnectionManager {
+ private state: ReconnectionState = {
+ isReconnecting: false,
+ attempt: 0,
+ maxAttempts: NETWORK_CONFIG.maxReconnectAttempts,
+ nextRetryMs: 0,
+ lastDisconnectTime: 0,
+ };
+
+ private retryTimer: ReturnType | null = null;
+ private statusCallbacks: ReconnectionCallback[] = [];
+ private reconnectionToken: string | null = null;
+
+ constructor() {
+ this.setupListeners();
+ }
+
+ /**
+ * Register for reconnection status updates
+ */
+ onStatusChange(callback: ReconnectionCallback): () => void {
+ this.statusCallbacks.push(callback);
+ return () => {
+ const index = this.statusCallbacks.indexOf(callback);
+ if (index !== -1) this.statusCallbacks.splice(index, 1);
+ };
+ }
+
+ /**
+ * Save reconnection token for game room reconnection
+ */
+ saveSession(reconnectionToken: string): void {
+ this.reconnectionToken = reconnectionToken;
+
+ // Persist to sessionStorage for page refresh recovery
+ try {
+ sessionStorage.setItem(
+ 'cannonwar_session',
+ JSON.stringify({ reconnectionToken, timestamp: Date.now() })
+ );
+ } catch {
+ // sessionStorage may not be available
+ }
+ }
+
+ /**
+ * Clear saved session
+ */
+ clearSession(): void {
+ this.reconnectionToken = null;
+
+ try {
+ sessionStorage.removeItem('cannonwar_session');
+ } catch {
+ // sessionStorage may not be available
+ }
+ }
+
+ /**
+ * Check if there's a saved session to reconnect to
+ */
+ hasSavedSession(): boolean {
+ if (this.reconnectionToken) return true;
+
+ try {
+ const saved = sessionStorage.getItem('cannonwar_session');
+ if (saved) {
+ const data = JSON.parse(saved);
+ // Only valid if less than 3 minutes old
+ if (Date.now() - data.timestamp < 180000 && data.reconnectionToken) {
+ this.reconnectionToken = data.reconnectionToken;
+ return true;
+ }
+ sessionStorage.removeItem('cannonwar_session');
+ }
+ } catch {
+ // ignore
+ }
+
+ return false;
+ }
+
+ /**
+ * Cancel any ongoing reconnection
+ */
+ cancel(): void {
+ if (this.retryTimer) {
+ clearTimeout(this.retryTimer);
+ this.retryTimer = null;
+ }
+
+ this.state.isReconnecting = false;
+ this.state.attempt = 0;
+ this.notifyStatus();
+ }
+
+ /**
+ * Dispose the manager
+ */
+ dispose(): void {
+ this.cancel();
+ this.statusCallbacks = [];
+ this.clearSession();
+ }
+
+ // ==================== Private ====================
+
+ /**
+ * Setup network event listeners
+ */
+ private setupListeners(): void {
+ const client = getNetworkClient();
+
+ client.events.on(NetworkEvent.STATE_CHANGED, (...args: unknown[]) => {
+ const data = args[0] as { oldState: ConnectionState; newState: ConnectionState };
+
+ if (data.newState === ConnectionState.RECONNECTING) {
+ this.startReconnection();
+ } else if (
+ data.newState === ConnectionState.IN_GAME ||
+ data.newState === ConnectionState.CONNECTED_LOBBY
+ ) {
+ if (this.state.isReconnecting) {
+ this.onReconnected();
+ }
+ }
+ });
+
+ client.events.on(NetworkEvent.GAME_ENDED, () => {
+ this.clearSession();
+ });
+ }
+
+ /**
+ * Start reconnection process
+ */
+ private startReconnection(): void {
+ this.state.isReconnecting = true;
+ this.state.attempt = 0;
+ this.state.lastDisconnectTime = Date.now();
+
+ this.attemptReconnect();
+ }
+
+ /**
+ * Attempt a single reconnection
+ */
+ private attemptReconnect(): void {
+ this.state.attempt++;
+
+ if (this.state.attempt > this.state.maxAttempts) {
+ this.onReconnectionFailed();
+ return;
+ }
+
+ // Exponential backoff: 2s, 4s, 8s, 16s, 32s
+ const delay = NETWORK_CONFIG.reconnectInterval * Math.pow(2, this.state.attempt - 1);
+ this.state.nextRetryMs = delay;
+
+ this.notifyStatus();
+
+ console.log(
+ `[ReconnectionManager] Attempt ${this.state.attempt}/${this.state.maxAttempts} in ${delay}ms`
+ );
+
+ this.retryTimer = setTimeout(async () => {
+ const client = getNetworkClient();
+
+ if (this.reconnectionToken) {
+ // Game room reconnection using Colyseus reconnectionToken
+ const success = await client.reconnectToGame(this.reconnectionToken);
+ if (!success) {
+ this.attemptReconnect();
+ }
+ } else {
+ // Lobby-only reconnection (no game session to restore)
+ try {
+ await client.connectToLobby(client.playerName);
+ } catch {
+ this.attemptReconnect();
+ }
+ }
+ }, delay);
+ }
+
+ /**
+ * Reconnection succeeded
+ */
+ private onReconnected(): void {
+ console.log('[ReconnectionManager] Reconnected successfully');
+
+ this.state.isReconnecting = false;
+ this.state.attempt = 0;
+ this.notifyStatus();
+ }
+
+ /**
+ * Reconnection failed after all attempts
+ */
+ private onReconnectionFailed(): void {
+ console.log('[ReconnectionManager] Reconnection failed after all attempts');
+
+ this.state.isReconnecting = false;
+ this.clearSession();
+ this.notifyStatus();
+ }
+
+ /**
+ * Notify all status callbacks
+ */
+ private notifyStatus(): void {
+ for (const callback of this.statusCallbacks) {
+ try {
+ callback({ ...this.state });
+ } catch (error) {
+ console.error('[ReconnectionManager] Status callback error:', error);
+ }
+ }
+ }
+}
+
+/**
+ * Singleton reconnection manager
+ */
+let reconnectionManagerInstance: ReconnectionManager | null = null;
+
+export function getReconnectionManager(): ReconnectionManager {
+ if (!reconnectionManagerInstance) {
+ reconnectionManagerInstance = new ReconnectionManager();
+ }
+ return reconnectionManagerInstance;
+}
diff --git a/src/network/rendering/clientPrediction.ts b/src/network/rendering/clientPrediction.ts
new file mode 100644
index 0000000..1c06996
--- /dev/null
+++ b/src/network/rendering/clientPrediction.ts
@@ -0,0 +1,435 @@
+/**
+ * Client Prediction System
+ * Provides immediate visual feedback for player actions before server confirmation
+ *
+ * Currently supports:
+ * - Build tower prediction (ghost tower display)
+ * - Sell tower prediction (fade-out effect)
+ */
+
+import { Vector } from '../../core/math/vector';
+import { Circle } from '../../core/math/circle';
+import { MyColor } from '../../entities/myColor';
+import { createStatusBarCache, type StatusBarCache } from '../../entities/statusBar';
+
+/**
+ * Prediction state for a pending action
+ */
+export type PredictionState = 'pending' | 'confirmed' | 'rejected';
+
+/**
+ * Predicted tower (ghost tower before server confirmation)
+ */
+export interface PredictedTower {
+ // Unique prediction ID (for matching server response)
+ predictionId: string;
+
+ // Tower properties
+ towerType: string;
+ x: number;
+ y: number;
+ radius: number;
+ rangeR: number;
+
+ // State
+ state: PredictionState;
+ createdAt: number;
+
+ // Render properties
+ alpha: number;
+}
+
+/**
+ * Predicted sell action
+ */
+export interface PredictedSell {
+ predictionId: string;
+ towerId: string;
+ state: PredictionState;
+ createdAt: number;
+}
+
+/**
+ * Ghost tower render proxy for prediction visualization
+ */
+export class GhostTowerProxy {
+ private _prediction: PredictedTower;
+ private _pos: Vector;
+ private _bodyCircle: Circle;
+ private _rangeCircle: Circle;
+
+ readonly _hpBarCache: StatusBarCache;
+ readonly hpBarHeight: number = 6;
+ readonly hpColor = { r: 0, g: 255, b: 0, a: 0.8 };
+ readonly imgIndex: number = 0;
+ readonly selected: boolean = false;
+ readonly bullys: Set = new Set();
+ _upIconOffset: Vector | null = null;
+ readonly liveTime: number = 0;
+
+ constructor(prediction: PredictedTower) {
+ this._prediction = prediction;
+ this._pos = new Vector(prediction.x, prediction.y);
+
+ this._bodyCircle = new Circle(prediction.x, prediction.y, prediction.radius);
+ this._bodyCircle.fillColor = new MyColor(100, 200, 255, 0.4);
+ this._bodyCircle.strokeColor = new MyColor(50, 150, 255, 0.8);
+ this._bodyCircle.strokeWidth = 2;
+
+ this._rangeCircle = new Circle(prediction.x, prediction.y, prediction.rangeR);
+ this._rangeCircle.fillColor.setRGBA(0, 0, 0, 0);
+ this._rangeCircle.strokeColor.setRGBA(100, 200, 255, 0.3);
+ this._rangeCircle.strokeWidth = 1;
+
+ this._hpBarCache = createStatusBarCache();
+ }
+
+ get predictionId(): string {
+ return this._prediction.predictionId;
+ }
+
+ get state(): PredictionState {
+ return this._prediction.state;
+ }
+
+ get pos(): Vector {
+ return this._pos;
+ }
+
+ get r(): number {
+ return this._prediction.radius;
+ }
+
+ get rangeR(): number {
+ return this._prediction.rangeR;
+ }
+
+ // Ghost towers show as full HP
+ get hp(): number {
+ return 1000;
+ }
+
+ get maxHp(): number {
+ return 1000;
+ }
+
+ get ownerId(): string | null {
+ return null; // Local prediction
+ }
+
+ get inValidTerritory(): boolean {
+ return true;
+ }
+
+ isDead(): boolean {
+ return this._prediction.state === 'rejected';
+ }
+
+ isInScreen(): boolean {
+ return true;
+ }
+
+ getBodyCircle(): Circle {
+ // Update alpha based on state
+ const alpha = this._prediction.state === 'rejected' ? 0.1 : 0.4;
+ this._bodyCircle.fillColor.changeAlpha(alpha);
+ return this._bodyCircle;
+ }
+
+ getViewCircle(): Circle {
+ return this._rangeCircle;
+ }
+
+ getImgStartPosByIndex(_n: number): Vector {
+ return new Vector(0, 0);
+ }
+
+ isUpLevelAble(): boolean {
+ return false;
+ }
+
+ getTowerLevel(): number {
+ return 1;
+ }
+
+ hpChange(_delta: number): void {
+ // Ghost tower - no HP changes
+ }
+
+ updateState(state: PredictionState): void {
+ this._prediction.state = state;
+ }
+}
+
+/**
+ * Client Prediction Manager
+ * Manages predicted actions and their visual representation
+ */
+export class ClientPrediction {
+ // Predicted towers (pending build actions)
+ private _predictedTowers: Map = new Map();
+ private _ghostProxies: Map = new Map();
+
+ // Predicted sells (pending sell actions)
+ private _predictedSells: Map = new Map();
+
+ // Counter for generating prediction IDs
+ private _predictionCounter: number = 0;
+
+ // Timeout for pending predictions (ms)
+ private readonly PREDICTION_TIMEOUT = 5000;
+
+ /**
+ * Generate unique prediction ID
+ */
+ private generatePredictionId(): string {
+ return `pred_${Date.now()}_${this._predictionCounter++}`;
+ }
+
+ /**
+ * Predict tower build
+ * Returns prediction ID to match with server response
+ */
+ predictBuild(
+ towerType: string,
+ x: number,
+ y: number,
+ radius: number = 15,
+ rangeR: number = 200
+ ): string {
+ const predictionId = this.generatePredictionId();
+
+ const prediction: PredictedTower = {
+ predictionId,
+ towerType,
+ x,
+ y,
+ radius,
+ rangeR,
+ state: 'pending',
+ createdAt: Date.now(),
+ alpha: 0.5,
+ };
+
+ this._predictedTowers.set(predictionId, prediction);
+ this._ghostProxies.set(predictionId, new GhostTowerProxy(prediction));
+
+ return predictionId;
+ }
+
+ /**
+ * Confirm a predicted build (server accepted)
+ * The ghost tower will be replaced by real tower from server state
+ */
+ confirmBuild(predictionId: string): void {
+ const prediction = this._predictedTowers.get(predictionId);
+ if (prediction) {
+ prediction.state = 'confirmed';
+ // Remove immediately - real tower will appear from server state
+ this._predictedTowers.delete(predictionId);
+ this._ghostProxies.delete(predictionId);
+ }
+ }
+
+ /**
+ * Reject a predicted build (server rejected)
+ * Ghost tower will fade out
+ */
+ rejectBuild(predictionId: string): void {
+ const prediction = this._predictedTowers.get(predictionId);
+ if (prediction) {
+ prediction.state = 'rejected';
+ const ghost = this._ghostProxies.get(predictionId);
+ if (ghost) {
+ ghost.updateState('rejected');
+ }
+ // Schedule removal after brief display
+ setTimeout(() => {
+ this._predictedTowers.delete(predictionId);
+ this._ghostProxies.delete(predictionId);
+ }, 500);
+ }
+ }
+
+ /**
+ * Predict tower sell
+ * Returns prediction ID
+ */
+ predictSell(towerId: string): string {
+ const predictionId = this.generatePredictionId();
+
+ this._predictedSells.set(predictionId, {
+ predictionId,
+ towerId,
+ state: 'pending',
+ createdAt: Date.now(),
+ });
+
+ return predictionId;
+ }
+
+ /**
+ * Confirm a predicted sell
+ */
+ confirmSell(predictionId: string): void {
+ this._predictedSells.delete(predictionId);
+ }
+
+ /**
+ * Reject a predicted sell
+ */
+ rejectSell(predictionId: string): void {
+ const sell = this._predictedSells.get(predictionId);
+ if (sell) {
+ sell.state = 'rejected';
+ // Remove after brief delay
+ setTimeout(() => {
+ this._predictedSells.delete(predictionId);
+ }, 500);
+ }
+ }
+
+ /**
+ * Check if a tower is pending sell
+ */
+ isTowerPendingSell(towerId: string): boolean {
+ for (const sell of this._predictedSells.values()) {
+ if (sell.towerId === towerId && sell.state === 'pending') {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Get ghost tower proxies for rendering
+ */
+ getGhostTowers(): GhostTowerProxy[] {
+ return Array.from(this._ghostProxies.values());
+ }
+
+ /**
+ * Update predictions (cleanup timeouts)
+ */
+ update(): void {
+ const now = Date.now();
+
+ // Cleanup timed out predictions
+ for (const [id, prediction] of this._predictedTowers) {
+ if (now - prediction.createdAt > this.PREDICTION_TIMEOUT) {
+ this._predictedTowers.delete(id);
+ this._ghostProxies.delete(id);
+ }
+ }
+
+ for (const [id, sell] of this._predictedSells) {
+ if (now - sell.createdAt > this.PREDICTION_TIMEOUT) {
+ this._predictedSells.delete(id);
+ }
+ }
+ }
+
+ /**
+ * Clear all predictions
+ */
+ clear(): void {
+ this._predictedTowers.clear();
+ this._ghostProxies.clear();
+ this._predictedSells.clear();
+ }
+
+ /**
+ * Get prediction count (for debugging)
+ */
+
+ // ==================== Matching Methods ====================
+ // Used by NetworkWorldAdapter to match server state changes to predictions
+
+ /**
+ * Find and confirm a build prediction by (towerType, x, y).
+ * Position-based matching ensures correct pairing even with concurrent builds.
+ */
+ findAndConfirmBuild(towerType: string, x: number, y: number, tolerance = 1): boolean {
+ for (const [id, prediction] of this._predictedTowers) {
+ if (
+ prediction.state === 'pending' &&
+ prediction.towerType === towerType &&
+ Math.abs(prediction.x - x) <= tolerance &&
+ Math.abs(prediction.y - y) <= tolerance
+ ) {
+ this.confirmBuild(id);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Reject the oldest pending build prediction (FIFO).
+ * Used when ACTION_REJECTED arrives without coordinates.
+ */
+ rejectOldestPendingBuild(): boolean {
+ for (const [id, prediction] of this._predictedTowers) {
+ if (prediction.state === 'pending') {
+ this.rejectBuild(id);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Find and confirm a sell prediction by towerId.
+ * Called when a tower disappears from server state.
+ */
+ findAndConfirmSellByTowerId(towerId: string): boolean {
+ for (const [id, sell] of this._predictedSells) {
+ if (sell.state === 'pending' && sell.towerId === towerId) {
+ this.confirmSell(id);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Reject the oldest pending sell prediction (FIFO).
+ * Used when sell ACTION_REJECTED arrives.
+ */
+ rejectOldestPendingSell(): boolean {
+ for (const [id, sell] of this._predictedSells) {
+ if (sell.state === 'pending') {
+ this.rejectSell(id);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ getPredictionCount(): number {
+ return this._predictedTowers.size + this._predictedSells.size;
+ }
+}
+
+// Singleton instance
+let _clientPrediction: ClientPrediction | null = null;
+
+/**
+ * Get the singleton client prediction manager
+ */
+export function getClientPrediction(): ClientPrediction {
+ if (!_clientPrediction) {
+ _clientPrediction = new ClientPrediction();
+ }
+ return _clientPrediction;
+}
+
+/**
+ * Reset the client prediction manager
+ */
+export function resetClientPrediction(): void {
+ if (_clientPrediction) {
+ _clientPrediction.clear();
+ }
+ _clientPrediction = null;
+}
diff --git a/src/network/rendering/index.ts b/src/network/rendering/index.ts
new file mode 100644
index 0000000..32b2233
--- /dev/null
+++ b/src/network/rendering/index.ts
@@ -0,0 +1,40 @@
+/**
+ * Network Rendering Module
+ * Client-side rendering adapter for multiplayer mode
+ *
+ * Provides WorldRendererContext from Colyseus GameState,
+ * enabling the existing WorldRenderer to render network game state.
+ */
+
+export { NetworkWorldAdapter } from './networkWorldAdapter';
+
+export {
+ TowerRenderProxy,
+ MonsterRenderProxy,
+ BuildingRenderProxy,
+ BulletRenderProxy,
+ resetCirclePool,
+} from './renderProxy';
+
+export {
+ InterpolationSystem,
+ getInterpolationSystem,
+ resetInterpolationSystem,
+} from './interpolation';
+
+export {
+ LocalEffectsManager,
+ getLocalEffectsManager,
+ resetLocalEffectsManager,
+ type LocalEffectType,
+} from './localEffects';
+
+export {
+ ClientPrediction,
+ GhostTowerProxy,
+ getClientPrediction,
+ resetClientPrediction,
+ type PredictionState,
+ type PredictedTower,
+ type PredictedSell,
+} from './clientPrediction';
diff --git a/src/network/rendering/interpolation.ts b/src/network/rendering/interpolation.ts
new file mode 100644
index 0000000..df30bb7
--- /dev/null
+++ b/src/network/rendering/interpolation.ts
@@ -0,0 +1,211 @@
+/**
+ * Interpolation System for Network Entities
+ * Provides smooth position interpolation between server state updates
+ *
+ * Server sends patches at ~20Hz (50ms interval), client renders at 60fps
+ * This system interpolates positions to create smooth movement
+ */
+
+/**
+ * Position snapshot from server
+ */
+interface PositionSnapshot {
+ x: number;
+ y: number;
+ serverTick: number;
+ timestamp: number;
+}
+
+/**
+ * Interpolation data for an entity
+ */
+interface EntityInterpolation {
+ prevSnapshot: PositionSnapshot | null;
+ currSnapshot: PositionSnapshot | null;
+}
+
+/**
+ * Interpolation configuration
+ */
+const INTERPOLATION_CONFIG = {
+ // Time delay for interpolation buffer (ms)
+ // We render positions from ~50ms ago to ensure we always have 2 snapshots
+ BUFFER_DELAY_MS: 50,
+
+ // Maximum distance (units) before we teleport instead of interpolate
+ TELEPORT_THRESHOLD: 200,
+
+ // Maximum age (ms) for a snapshot before it's considered stale
+ SNAPSHOT_MAX_AGE_MS: 500,
+};
+
+/**
+ * Interpolation System
+ * Manages position interpolation for all network entities
+ */
+export class InterpolationSystem {
+ private entities: Map = new Map();
+
+ // Render time offset from real time (for interpolation delay)
+ private renderTimeOffset: number = 0;
+
+ // Current render time (real time - buffer delay)
+ private renderTime: number = 0;
+
+ /**
+ * Initialize render time from server
+ */
+ initRenderTime(serverTime: number): void {
+ this.renderTimeOffset = Date.now() - serverTime + INTERPOLATION_CONFIG.BUFFER_DELAY_MS;
+ this.renderTime = serverTime - INTERPOLATION_CONFIG.BUFFER_DELAY_MS;
+ }
+
+ /**
+ * Update render time each frame
+ */
+ updateRenderTime(dt: number): void {
+ this.renderTime += dt;
+ }
+
+ /**
+ * Get current render time
+ */
+ getRenderTime(): number {
+ return this.renderTime;
+ }
+
+ /**
+ * Push a new position snapshot for an entity
+ */
+ pushSnapshot(entityId: string, x: number, y: number, serverTick: number): void {
+ let entity = this.entities.get(entityId);
+ if (!entity) {
+ entity = { prevSnapshot: null, currSnapshot: null };
+ this.entities.set(entityId, entity);
+ }
+
+ const timestamp = Date.now();
+
+ // Shift current to previous
+ entity.prevSnapshot = entity.currSnapshot;
+
+ // Set new current
+ entity.currSnapshot = {
+ x,
+ y,
+ serverTick,
+ timestamp,
+ };
+ }
+
+ /**
+ * Get interpolated position for an entity
+ * Returns the raw current position if interpolation isn't possible
+ */
+ getPosition(entityId: string): { x: number; y: number } | null {
+ const entity = this.entities.get(entityId);
+ if (!entity) return null;
+
+ // If we don't have a current snapshot, nothing to return
+ if (!entity.currSnapshot) return null;
+
+ // If we don't have a previous snapshot, return current position (no interpolation)
+ if (!entity.prevSnapshot) {
+ return { x: entity.currSnapshot.x, y: entity.currSnapshot.y };
+ }
+
+ // Check if snapshots are too old (stale)
+ const now = Date.now();
+ if (now - entity.currSnapshot.timestamp > INTERPOLATION_CONFIG.SNAPSHOT_MAX_AGE_MS) {
+ return { x: entity.currSnapshot.x, y: entity.currSnapshot.y };
+ }
+
+ // Calculate interpolation factor based on timestamps
+ const prev = entity.prevSnapshot;
+ const curr = entity.currSnapshot;
+
+ const snapshotDuration = curr.timestamp - prev.timestamp;
+ if (snapshotDuration <= 0) {
+ return { x: curr.x, y: curr.y };
+ }
+
+ // How far into the interpolation are we?
+ const timeSinceCurr = now - curr.timestamp - INTERPOLATION_CONFIG.BUFFER_DELAY_MS;
+ const t = Math.max(0, Math.min(1, (timeSinceCurr + snapshotDuration) / snapshotDuration));
+
+ // Check teleport threshold
+ const dx = curr.x - prev.x;
+ const dy = curr.y - prev.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+
+ if (dist > INTERPOLATION_CONFIG.TELEPORT_THRESHOLD) {
+ // Teleport: jump to current position
+ return { x: curr.x, y: curr.y };
+ }
+
+ // Linear interpolation
+ return {
+ x: prev.x + dx * t,
+ y: prev.y + dy * t,
+ };
+ }
+
+ /**
+ * Get interpolated position, or fallback to provided default
+ */
+ getPositionOrDefault(entityId: string, defaultX: number, defaultY: number): { x: number; y: number } {
+ const pos = this.getPosition(entityId);
+ return pos || { x: defaultX, y: defaultY };
+ }
+
+ /**
+ * Check if entity has interpolation data
+ */
+ hasEntity(entityId: string): boolean {
+ return this.entities.has(entityId);
+ }
+
+ /**
+ * Remove entity interpolation data
+ */
+ removeEntity(entityId: string): void {
+ this.entities.delete(entityId);
+ }
+
+ /**
+ * Clear all interpolation data
+ */
+ clear(): void {
+ this.entities.clear();
+ }
+
+ /**
+ * Get entity count (for debugging)
+ */
+ getEntityCount(): number {
+ return this.entities.size;
+ }
+}
+
+// Singleton instance
+let _interpolationSystem: InterpolationSystem | null = null;
+
+/**
+ * Get the singleton interpolation system
+ */
+export function getInterpolationSystem(): InterpolationSystem {
+ if (!_interpolationSystem) {
+ _interpolationSystem = new InterpolationSystem();
+ }
+ return _interpolationSystem;
+}
+
+/**
+ * Reset the interpolation system (for testing or game restart)
+ */
+export function resetInterpolationSystem(): void {
+ if (_interpolationSystem) {
+ _interpolationSystem.clear();
+ }
+ _interpolationSystem = null;
+}
diff --git a/src/network/rendering/localEffects.ts b/src/network/rendering/localEffects.ts
new file mode 100644
index 0000000..51470f1
--- /dev/null
+++ b/src/network/rendering/localEffects.ts
@@ -0,0 +1,245 @@
+/**
+ * Local Effects Manager
+ * Manages client-side visual effects based on server events
+ *
+ * Effects include:
+ * - Bullet flight animations (local prediction)
+ * - Hit/explosion effects
+ * - Damage indicators
+ * - Death effects
+ */
+
+import { EffectCircle } from '../../effects/effectCircle';
+import { BulletRenderProxy } from './renderProxy';
+import type { IEffect } from '../../types/game';
+
+/**
+ * Effect types for different game events
+ */
+export type LocalEffectType =
+ | 'bullet_hit'
+ | 'monster_death'
+ | 'building_damage'
+ | 'building_destroy'
+ | 'tower_attack';
+
+/**
+ * Configuration for different effect types
+ */
+interface EffectConfig {
+ radius: number;
+ duration: number;
+ animation: keyof EffectCircle;
+}
+
+const EFFECT_CONFIGS: Record = {
+ bullet_hit: { radius: 15, duration: 8, animation: 'flashFireAnimation' },
+ monster_death: { radius: 25, duration: 12, animation: 'flashRedAnimation' },
+ building_damage: { radius: 10, duration: 6, animation: 'flashRedAnimation' },
+ building_destroy: { radius: 40, duration: 20, animation: 'destroyAnimation' },
+ tower_attack: { radius: 10, duration: 5, animation: 'flashBlueAnimation' },
+};
+
+/**
+ * Local Effects Manager
+ * Handles creation and lifecycle of client-side visual effects
+ */
+export class LocalEffectsManager {
+ // Active effects
+ private _effects: Set = new Set();
+
+ // Local bullets for attack visualization
+ private _localBullets: Set = new Set();
+
+ // Pending effects to add (batched for performance)
+ private _pendingEffects: { type: LocalEffectType; x: number; y: number }[] = [];
+
+ // Frame counter for effect timing
+ private _frameCount: number = 0;
+
+ /**
+ * Get active effects set
+ */
+ get effects(): Set {
+ return this._effects;
+ }
+
+ /**
+ * Get local bullets set
+ */
+ get localBullets(): Set {
+ return this._localBullets;
+ }
+
+ /**
+ * Create an effect at position
+ */
+ createEffect(type: LocalEffectType, x: number, y: number): void {
+ const config = EFFECT_CONFIGS[type];
+ const effect = EffectCircle.acquire({ x, y });
+
+ effect.circle.r = config.radius;
+ effect.duration = config.duration;
+ effect.animationFunc = (effect as unknown as Record void>)[config.animation];
+
+ this._effects.add(effect);
+ }
+
+ /**
+ * Queue effect for batch processing
+ */
+ queueEffect(type: LocalEffectType, x: number, y: number): void {
+ this._pendingEffects.push({ type, x, y });
+ }
+
+ /**
+ * Create local bullet for attack visualization
+ */
+ createLocalBullet(
+ startX: number,
+ startY: number,
+ targetX: number,
+ targetY: number,
+ radius: number = 5,
+ speed: number = 15
+ ): BulletRenderProxy {
+ const bullet = new BulletRenderProxy(startX, startY, targetX, targetY, radius, speed);
+ this._localBullets.add(bullet);
+ return bullet;
+ }
+
+ /**
+ * Handle tower attack event
+ * Creates local bullet + muzzle flash effect
+ */
+ onTowerAttack(
+ towerX: number,
+ towerY: number,
+ targetX: number,
+ targetY: number,
+ bulletRadius: number = 5,
+ bulletSpeed: number = 15
+ ): void {
+ // Muzzle flash
+ this.createEffect('tower_attack', towerX, towerY);
+
+ // Local bullet
+ this.createLocalBullet(towerX, towerY, targetX, targetY, bulletRadius, bulletSpeed);
+ }
+
+ /**
+ * Handle monster damaged event
+ */
+ onMonsterDamaged(x: number, y: number, _damage: number): void {
+ // Small hit effect
+ this.createEffect('bullet_hit', x, y);
+ }
+
+ /**
+ * Handle monster killed event
+ */
+ onMonsterKilled(x: number, y: number): void {
+ this.createEffect('monster_death', x, y);
+ }
+
+ /**
+ * Handle building damaged event
+ */
+ onBuildingDamaged(x: number, y: number, _damage: number): void {
+ this.createEffect('building_damage', x, y);
+ }
+
+ /**
+ * Handle building destroyed event
+ */
+ onBuildingDestroyed(x: number, y: number): void {
+ this.createEffect('building_destroy', x, y);
+ }
+
+ /**
+ * Update all effects (called each frame)
+ */
+ update(): void {
+ this._frameCount++;
+
+ // Process pending effects
+ for (const pending of this._pendingEffects) {
+ this.createEffect(pending.type, pending.x, pending.y);
+ }
+ this._pendingEffects.length = 0;
+
+ // Update and cleanup effects
+ for (const effect of this._effects) {
+ effect.goStep();
+ if (!effect.isPlay) {
+ this._effects.delete(effect);
+ // Return to pool if it's an EffectCircle
+ if (effect instanceof EffectCircle) {
+ EffectCircle.release(effect);
+ }
+ }
+ }
+
+ // Update and cleanup local bullets
+ for (const bullet of this._localBullets) {
+ bullet.goStep();
+ if (bullet.isExpired) {
+ // Create hit effect at bullet destination
+ this.createEffect('bullet_hit', bullet.pos.x, bullet.pos.y);
+ this._localBullets.delete(bullet);
+ }
+ }
+ }
+
+ /**
+ * Clear all effects
+ */
+ clear(): void {
+ // Return all effects to pool
+ for (const effect of this._effects) {
+ if (effect instanceof EffectCircle) {
+ EffectCircle.release(effect);
+ }
+ }
+ this._effects.clear();
+ this._localBullets.clear();
+ this._pendingEffects.length = 0;
+ }
+
+ /**
+ * Get effect count (for debugging)
+ */
+ getEffectCount(): number {
+ return this._effects.size;
+ }
+
+ /**
+ * Get bullet count (for debugging)
+ */
+ getBulletCount(): number {
+ return this._localBullets.size;
+ }
+}
+
+// Singleton instance
+let _localEffectsManager: LocalEffectsManager | null = null;
+
+/**
+ * Get the singleton local effects manager
+ */
+export function getLocalEffectsManager(): LocalEffectsManager {
+ if (!_localEffectsManager) {
+ _localEffectsManager = new LocalEffectsManager();
+ }
+ return _localEffectsManager;
+}
+
+/**
+ * Reset the local effects manager
+ */
+export function resetLocalEffectsManager(): void {
+ if (_localEffectsManager) {
+ _localEffectsManager.clear();
+ }
+ _localEffectsManager = null;
+}
diff --git a/src/network/rendering/mineRenderProxy.ts b/src/network/rendering/mineRenderProxy.ts
new file mode 100644
index 0000000..dc6fc6c
--- /dev/null
+++ b/src/network/rendering/mineRenderProxy.ts
@@ -0,0 +1,182 @@
+/**
+ * MineRenderProxy - Lightweight proxy that adapts MineState schema data
+ * for client-side rendering. Satisfies the Mine interface expected by
+ * WorldRenderer and PanelManager.
+ */
+
+import { Vector } from '../../core/math/vector';
+import { Circle } from '../../core/math/circle';
+import { MINE_CONFIG, MineStateType } from '@shared/config/mineMeta';
+import { renderStatusBar, createStatusBarCache, BAR_OFFSET, type StatusBarCache } from '../../entities/statusBar';
+import { MyColor } from '../../entities/myColor';
+
+/** HP bar color for mines (green, same as towers) */
+const MINE_HP_COLOR = MyColor.arrTo([2, 230, 13, 0.8]);
+
+export class MineRenderProxy {
+ // Identity
+ id: string = '';
+ gameType: string = 'Mine';
+ name: string = '矿井';
+
+ // Position & collision (compatible with CircleObject interface)
+ pos: Vector;
+ r: number = 15;
+
+ // Mine state
+ state: string = MineStateType.NORMAL;
+ powerPlantLevel: number = 0;
+ hp: number = 0;
+ maxHp: number = 0;
+ ownerId: string = '';
+ repairing: boolean = false;
+ repairProgress: number = 0;
+ inValidTerritory: boolean = true;
+ selected: boolean = false;
+
+ // Constants (for PanelManager compatibility)
+ REPAIR_COST: number = MINE_CONFIG.repairCost;
+ REPAIR_TIME: number = MINE_CONFIG.repairTicks;
+ UPGRADE_PRICES: readonly number[] = MINE_CONFIG.upgradePrices;
+
+ // HP bar rendering cache
+ private _hpBarCache: StatusBarCache = createStatusBarCache();
+ private _bodyCircle: Circle | null = null;
+
+ constructor() {
+ this.pos = new Vector(0, 0);
+ }
+
+ /**
+ * Update proxy from schema state
+ */
+ syncFromSchema(schema: {
+ id: string;
+ position: { x: number; y: number };
+ mineState: string;
+ ownerId: string;
+ level: number;
+ hp: number;
+ maxHp: number;
+ radius: number;
+ repairing: boolean;
+ repairProgress: number;
+ }): void {
+ this.id = schema.id;
+ this.pos.x = schema.position.x;
+ this.pos.y = schema.position.y;
+ this.state = schema.mineState;
+ this.ownerId = schema.ownerId;
+ this.powerPlantLevel = schema.level;
+ this.hp = schema.hp;
+ this.maxHp = schema.maxHp;
+ this.r = schema.radius;
+ this.repairing = schema.repairing;
+ this.repairProgress = schema.repairProgress;
+ this._bodyCircle = null; // Invalidate cache
+ }
+
+ // === PanelManager compatibility methods ===
+
+ getEnergyProduction(): number {
+ if (this.state !== MineStateType.POWER_PLANT) return 0;
+ if (!this.inValidTerritory) return 0;
+ return this.powerPlantLevel * MINE_CONFIG.productionPerLevel;
+ }
+
+ getUpgradePrice(): number | null {
+ if (this.state === MineStateType.NORMAL) return MINE_CONFIG.upgradePrices[0];
+ if (this.state === MineStateType.POWER_PLANT && this.powerPlantLevel < MINE_CONFIG.maxLevel) {
+ return MINE_CONFIG.upgradePrices[this.powerPlantLevel];
+ }
+ return null;
+ }
+
+ getDowngradeRefund(): number {
+ if (this.state !== MineStateType.POWER_PLANT) return 0;
+ return Math.floor(MINE_CONFIG.upgradePrices[this.powerPlantLevel - 1] * MINE_CONFIG.downgradeRefundRatio);
+ }
+
+ getSellPrice(): number {
+ if (this.state !== MineStateType.POWER_PLANT) return 0;
+ let totalValue = 0;
+ for (let i = 0; i < this.powerPlantLevel; i++) {
+ totalValue += MINE_CONFIG.upgradePrices[i];
+ }
+ return Math.floor(totalValue * MINE_CONFIG.sellRefundRatio);
+ }
+
+ getBodyCircle(): Circle {
+ if (!this._bodyCircle) {
+ this._bodyCircle = new Circle(this.pos.x, this.pos.y, this.r);
+ }
+ this._bodyCircle.pos.x = this.pos.x;
+ this._bodyCircle.pos.y = this.pos.y;
+ this._bodyCircle.r = this.r;
+ return this._bodyCircle;
+ }
+
+ getSize(): number {
+ return this.state === MineStateType.DAMAGED ? 30 : 25;
+ }
+
+ // === Rendering ===
+
+ render(ctx: CanvasRenderingContext2D): void {
+ const size = this.getSize();
+ const x = this.pos.x;
+ const y = this.pos.y;
+
+ if (this.state === MineStateType.POWER_PLANT) {
+ // Draw circle body (like CircleObject.renderBody)
+ ctx.fillStyle = '#222222';
+ ctx.beginPath();
+ ctx.arc(x, y, this.r, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Draw HP bar
+ if (this.maxHp > 0) {
+ const barH = 3;
+ const barX = x - this.r;
+ const barY = y - this.r + BAR_OFFSET.HP_TOP * barH;
+ const barW = this.r * 2;
+ const hpRate = this.hp / this.maxHp;
+
+ renderStatusBar(ctx, {
+ x: barX,
+ y: barY,
+ width: barW,
+ height: barH,
+ fillRate: hpRate,
+ fillColor: MINE_HP_COLOR,
+ cache: this._hpBarCache,
+ });
+ }
+
+ // Draw ⚡ symbol
+ ctx.fillStyle = 'yellow';
+ ctx.font = '12px sans-serif';
+ ctx.textAlign = 'center';
+ const lightning = '⚡'.repeat(this.powerPlantLevel);
+ ctx.fillText(lightning, x, y - this.r - 15);
+ } else {
+ // Triangle (normal or damaged)
+ ctx.fillStyle = this.state === MineStateType.NORMAL ? 'black' : 'gray';
+ ctx.beginPath();
+ ctx.moveTo(x, y - size / 2);
+ ctx.lineTo(x - size / 2, y + size / 2);
+ ctx.lineTo(x + size / 2, y + size / 2);
+ ctx.closePath();
+ ctx.fill();
+
+ // Repair progress bar
+ if (this.repairing) {
+ const progress = this.repairProgress / this.REPAIR_TIME;
+ ctx.fillStyle = 'green';
+ ctx.fillRect(x - 15, y + size / 2 + 5, 30 * progress, 4);
+ ctx.strokeStyle = 'black';
+ ctx.strokeRect(x - 15, y + size / 2 + 5, 30, 4);
+ }
+ }
+ }
+}
diff --git a/src/network/rendering/networkFog.ts b/src/network/rendering/networkFog.ts
new file mode 100644
index 0000000..4d5c62c
--- /dev/null
+++ b/src/network/rendering/networkFog.ts
@@ -0,0 +1,117 @@
+/**
+ * NetworkFogProxy - Client-side fog of war for multiplayer mode
+ * Bridges NetworkWorldAdapter data to FogOfWar + FogRenderer via WorldLike adapter.
+ * Reuses ~500 lines of existing fog rendering code.
+ */
+
+import { FogOfWar } from '../../systems/fog/fogOfWar';
+import type { WorldLike, TowerLike } from '../../systems/fog/fogOfWar';
+import type { TowerRenderProxy } from './renderProxy';
+import type { BuildingRenderProxy } from './renderProxy';
+import type { Camera } from '../../core/camera';
+
+/**
+ * Adapts NetworkWorldAdapter data for FogOfWar consumption.
+ * Implements WorldRendererContext.fog interface.
+ */
+export class NetworkFogProxy {
+ private _fog: FogOfWar;
+ private _worldAdapter: WorldLikeAdapter;
+
+ readonly enabled: boolean = true;
+
+ constructor(
+ localPlayerId: string,
+ camera: Camera,
+ mapWidth: number,
+ mapHeight: number,
+ viewWidth: number,
+ viewHeight: number,
+ ) {
+ this._worldAdapter = new WorldLikeAdapter(
+ camera, mapWidth, mapHeight, viewWidth, viewHeight
+ );
+ this._fog = new FogOfWar(this._worldAdapter, localPlayerId);
+ }
+
+ /** FogRenderer instance (for WorldRendererContext.fog.renderer) */
+ get renderer() {
+ return this._fog.renderer;
+ }
+
+ /** Check if a circle is visible through fog */
+ isCircleVisible(x: number, y: number, radius: number): boolean {
+ return this._fog.isCircleVisible(x, y, radius);
+ }
+
+ /**
+ * Update fog state. Must be called per frame.
+ * Sets tower/building data and recalculates vision sources.
+ */
+ update(towers: TowerRenderProxy[], buildings: BuildingRenderProxy[]): void {
+ // Update tower references (FogOfWar reads from world.batterys)
+ this._worldAdapter.setBatterys(towers);
+ this._worldAdapter.setBaseBuilding(buildings);
+ this._fog.update();
+ }
+
+ /** Resize viewport */
+ resize(viewWidth: number, viewHeight: number): void {
+ this._worldAdapter.viewWidth = viewWidth;
+ this._worldAdapter.viewHeight = viewHeight;
+ }
+}
+
+/**
+ * Lightweight WorldLike adapter for FogOfWar.
+ * Provides camera, dimensions, and tower/building data from NetworkWorldAdapter.
+ */
+class WorldLikeAdapter implements WorldLike {
+ width: number;
+ height: number;
+ viewWidth: number;
+ viewHeight: number;
+ camera: Camera;
+ batterys: TowerLike[] = [];
+ territory = undefined;
+
+ private _basePos = { pos: { x: 0, y: 0 } };
+ private _buildings: BuildingRenderProxy[] = [];
+
+ constructor(
+ camera: Camera,
+ mapWidth: number,
+ mapHeight: number,
+ viewWidth: number,
+ viewHeight: number,
+ ) {
+ this.camera = camera;
+ this.width = mapWidth;
+ this.height = mapHeight;
+ this.viewWidth = viewWidth;
+ this.viewHeight = viewHeight;
+ }
+
+ setBatterys(towers: TowerLike[]): void {
+ this.batterys = towers;
+ }
+
+ setBaseBuilding(buildings: BuildingRenderProxy[]): void {
+ this._buildings = buildings;
+ }
+
+ getBaseBuilding(playerId?: string): { pos: { x: number; y: number } } {
+ // Find player's base building
+ for (const b of this._buildings) {
+ if (b.isBase && (!playerId || b.ownerId === playerId)) {
+ this._basePos.pos.x = b.pos.x;
+ this._basePos.pos.y = b.pos.y;
+ return this._basePos;
+ }
+ }
+ // Fallback to map center
+ this._basePos.pos.x = this.width / 2;
+ this._basePos.pos.y = this.height / 2;
+ return this._basePos;
+ }
+}
diff --git a/src/network/rendering/networkWorldAdapter.ts b/src/network/rendering/networkWorldAdapter.ts
new file mode 100644
index 0000000..f881ec8
--- /dev/null
+++ b/src/network/rendering/networkWorldAdapter.ts
@@ -0,0 +1,784 @@
+/**
+ * Network World Adapter
+ * Core adapter that transforms Colyseus GameState into WorldRendererContext
+ *
+ * This allows the existing WorldRenderer to render network game state
+ * without any modifications to the renderer itself.
+ */
+
+import { Camera } from '../../core/camera';
+import { Vector } from '../../core/math/vector';
+import type { WorldRendererContext } from '../../game/rendering/worldRenderer';
+import type { IEffect } from '../../types/game';
+import { Territory } from '../../systems/territory/territory';
+import { TerritoryRenderer } from '../../systems/territory/territoryRenderer';
+
+import {
+ TowerRenderProxy,
+ MonsterRenderProxy,
+ BuildingRenderProxy,
+ BulletRenderProxy,
+ resetCirclePool,
+ type TowerStateView,
+ type MonsterStateView,
+ type BuildingStateView,
+} from './renderProxy';
+import { InterpolationSystem, getInterpolationSystem } from './interpolation';
+import { LocalEffectsManager, getLocalEffectsManager } from './localEffects';
+import { ClientPrediction, getClientPrediction } from './clientPrediction';
+import type { NetworkClient } from '../networkClient';
+import { NetworkEvent } from '../networkClient';
+import { ServerMessage, type BulletFiredPayload, type TerritorySyncPayload } from '../messages';
+import { MineRenderProxy } from './mineRenderProxy';
+import { NetworkFogProxy } from './networkFog';
+
+// ============================================================================
+// Types for Colyseus state access
+// ============================================================================
+
+interface MineStateSchemaView {
+ id: string;
+ position: { x: number; y: number };
+ mineState: string;
+ ownerId: string;
+ level: number;
+ hp: number;
+ maxHp: number;
+ radius: number;
+ repairing: boolean;
+ repairProgress: number;
+}
+
+/**
+ * Minimal GameState interface for type safety
+ * Mirrors the Colyseus GameState schema without importing server code
+ */
+interface GameStateView {
+ mapConfig: { width: number; height: number };
+ wave: {
+ currentWave: number;
+ monstersRemaining: number;
+ nextWaveTime: number;
+ isWaveActive: boolean;
+ };
+ players: Map;
+ towers: Map;
+ monsters: Map;
+ buildings: Map;
+ mines?: Map;
+}
+
+interface PlayerStateView {
+ id: string;
+ money: number;
+ isAlive: boolean;
+ basePosition: { x: number; y: number };
+ energyProduction?: number;
+ energyConsumption?: number;
+ energySatisfaction?: number;
+}
+
+// ============================================================================
+// NetworkWorldAdapter
+// ============================================================================
+
+/**
+ * Adapter that bridges Colyseus GameState to WorldRendererContext
+ * Manages proxy entity caches and subsystems
+ */
+export class NetworkWorldAdapter {
+ // Input sources
+ private _gameState: GameStateView | null = null;
+ private _networkClient: NetworkClient;
+ private _localPlayerId: string;
+
+ // Subsystems
+ private _interpolation: InterpolationSystem;
+ private _localEffects: LocalEffectsManager;
+ private _prediction: ClientPrediction;
+
+ // Local camera (player-controlled)
+ private _camera: Camera;
+
+ // Render entity caches
+ private _towerProxies: Map = new Map();
+ private _monsterProxies: Map = new Map();
+ private _buildingProxies: Map = new Map();
+
+ // Pre-allocated arrays for WorldRendererContext (avoid per-frame allocation)
+ private _towerArray: TowerRenderProxy[] = [];
+ private _buildingArray: BuildingRenderProxy[] = [];
+ private _monsterSet: Set = new Set();
+ private _effectSet: Set = new Set();
+ private _bulletSet: Set = new Set();
+ private _mineProxies: Map = new Map();
+ private _mineSet: Set = new Set();
+
+ // Fog of war
+ private _fogProxy: NetworkFogProxy | null = null;
+
+ // Territory system
+ private _territories: Map = new Map();
+ private _territoryArray: Territory[] = [];
+
+ // User state cache
+ private _userState = {
+ money: 0,
+ putLoc: { x: 0, y: 0, able: false, building: null as { r: number; rangeR: number } | null },
+ moveTarget: null as { x: number; y: number; r: number } | null,
+ };
+
+ // Time tracking
+ private _time: number = 0;
+
+ // Event handlers bound once
+ private _eventHandlersBound: boolean = false;
+
+
+ // Territory sync counters
+ private _territoryIncrementalCounter: number = 0;
+ private readonly TERRITORY_FULL_RECALC_INTERVAL = 300; // Every 5 seconds at 60fps
+
+ constructor(
+ networkClient: NetworkClient,
+ localPlayerId: string,
+ viewWidth: number,
+ viewHeight: number,
+ worldWidth: number = 6000,
+ worldHeight: number = 4000
+ ) {
+ this._networkClient = networkClient;
+ this._localPlayerId = localPlayerId;
+
+ this._interpolation = getInterpolationSystem();
+ this._localEffects = getLocalEffectsManager();
+ this._prediction = getClientPrediction();
+
+ this._camera = new Camera(viewWidth, viewHeight, worldWidth, worldHeight);
+ }
+
+ // ==================== Initialization ====================
+
+ /**
+ * Bind to Colyseus game state
+ * Call this after joining a game room
+ */
+ bindGameState(gameState: GameStateView): void {
+ this._gameState = gameState;
+
+ // Initialize camera from map config
+ this._camera = new Camera(
+ this._camera.viewWidth,
+ this._camera.viewHeight,
+ gameState.mapConfig.width,
+ gameState.mapConfig.height
+ );
+
+ // Center camera on player's base
+ const player = gameState.players.get(this._localPlayerId);
+ if (player) {
+ this._camera.centerOn(new Vector(player.basePosition.x, player.basePosition.y));
+ }
+
+ // Initialize interpolation
+ this._interpolation.initRenderTime(Date.now());
+
+ // Initialize fog of war
+ this._fogProxy = new NetworkFogProxy(
+ this._localPlayerId,
+ this._camera,
+ gameState.mapConfig.width,
+ gameState.mapConfig.height,
+ this._camera.viewWidth,
+ this._camera.viewHeight,
+ );
+
+ // Initialize territories for all players
+ this._initTerritories(gameState);
+
+ // Build initial proxy caches
+ this._rebuildAllProxies();
+ }
+
+ /**
+ * Bind server message handlers for visual effects
+ */
+ bindEventHandlers(): void {
+ if (this._eventHandlersBound) return;
+ this._eventHandlersBound = true;
+
+ const events = this._networkClient.events;
+
+ // Bullet fired events → local bullet effects
+ events.on(ServerMessage.BULLET_FIRED, (...args: unknown[]) => {
+ const data = args[0] as BulletFiredPayload;
+ const tower = this._towerProxies.get(data.towerId);
+ if (tower) {
+ // Calculate pseudo-target from velocity for BulletRenderProxy
+ const speed = Math.sqrt(data.vx * data.vx + data.vy * data.vy);
+ // Server bullet flies maxRange * slideRate (slideRate=2)
+ const maxDist = data.maxRange * 2;
+ const targetX = data.x + (speed > 0 ? (data.vx / speed) * maxDist : 0);
+ const targetY = data.y + (speed > 0 ? (data.vy / speed) * maxDist : 0);
+
+ this._localEffects.onTowerAttack(
+ data.x, data.y,
+ targetX, targetY,
+ data.radius,
+ speed
+ );
+ }
+ });
+
+ // Monster damaged → hit effect
+ events.on(ServerMessage.MONSTER_DAMAGED, (...args: unknown[]) => {
+ const data = args[0] as { monsterId: string; damage: number };
+ const monster = this._monsterProxies.get(data.monsterId);
+ if (monster) {
+ this._localEffects.onMonsterDamaged(monster.pos.x, monster.pos.y, data.damage);
+ }
+ });
+
+ // Monster killed → death effect
+ events.on(ServerMessage.MONSTER_KILLED, (...args: unknown[]) => {
+ const data = args[0] as { monsterId: string };
+ const monster = this._monsterProxies.get(data.monsterId);
+ if (monster) {
+ this._localEffects.onMonsterKilled(monster.pos.x, monster.pos.y);
+ }
+ });
+
+ // Building damaged → damage effect
+ events.on(ServerMessage.BUILDING_DAMAGED, (...args: unknown[]) => {
+ const data = args[0] as { buildingId: string; damage: number };
+ const building = this._buildingProxies.get(data.buildingId);
+ if (building) {
+ this._localEffects.onBuildingDamaged(building.pos.x, building.pos.y, data.damage);
+ }
+ });
+
+ // Building destroyed → destruction effect
+ events.on(ServerMessage.BUILDING_DESTROYED, (...args: unknown[]) => {
+ const data = args[0] as { buildingId: string };
+ const building = this._buildingProxies.get(data.buildingId);
+ if (building) {
+ this._localEffects.onBuildingDestroyed(building.pos.x, building.pos.y);
+ }
+ });
+
+ // Action rejected → reject corresponding prediction
+ events.on(NetworkEvent.ACTION_REJECTED, (...args: unknown[]) => {
+ const data = args[0] as { action: string; reason: string; errorCode?: string };
+ switch (data.action) {
+ case 'BUILD_TOWER':
+ this._prediction.rejectOldestPendingBuild();
+ break;
+ case 'SELL_TOWER':
+ this._prediction.rejectOldestPendingSell();
+ break;
+ }
+ });
+
+ // Territory sync from server
+ events.on(ServerMessage.TERRITORY_SYNC, (...args: unknown[]) => {
+ const data = args[0] as TerritorySyncPayload;
+ this._applyTerritorySync(data);
+ });
+ }
+
+ /**
+ * Initialize territory system for all players
+ */
+ private _initTerritories(gameState: GameStateView): void {
+ this._territories.clear();
+
+ // Create minimal world-like object for Territory
+ const self = this;
+ const pseudoWorld = {
+ width: gameState.mapConfig.width,
+ height: gameState.mapConfig.height,
+ viewWidth: this._camera.viewWidth,
+ viewHeight: this._camera.viewHeight,
+ camera: this._camera,
+ getBaseBuilding: (playerId?: string) => {
+ return self._buildingArray.find(b => b.ownerId === playerId && b.gameType === 'Base') || self._buildingArray[0];
+ },
+ get batterys() { return self._towerArray as any; },
+ get buildings() { return self._buildingArray as any; },
+ get mines() { return self._mineSet as any; },
+ fog: undefined,
+ };
+
+ // Create territory for each player
+ for (const [playerId] of gameState.players) {
+ const territory = new Territory(pseudoWorld as any, playerId);
+ this._territories.set(playerId, territory);
+ }
+
+ this._territoryArray = Array.from(this._territories.values());
+ }
+
+ /**
+ * Update territories when buildings change
+ */
+ private _updateTerritories(): void {
+ this._territoryIncrementalCounter++;
+
+ // Every N updates, do full recalc to prevent drift
+ if (this._territoryIncrementalCounter >= this.TERRITORY_FULL_RECALC_INTERVAL) {
+ this._territoryIncrementalCounter = 0;
+ for (const territory of this._territories.values()) {
+ territory.recalculate();
+ }
+ } else {
+ // Normal incremental update (mark dirty for next frame)
+ for (const territory of this._territories.values()) {
+ territory.markDirty();
+ }
+ }
+ }
+
+
+ /**
+ * Apply authoritative territory state from server
+ * Directly updates Territory valid/invalid building sets
+ */
+ private _applyTerritorySync(data: TerritorySyncPayload): void {
+ for (const [playerId, territoryData] of Object.entries(data.territories)) {
+ const territory = this._territories.get(playerId);
+ if (!territory) continue;
+
+ // Clear existing sets
+ territory.validBuildings.clear();
+ territory.invalidBuildings.clear();
+
+ // Rebuild valid buildings set
+ for (const id of territoryData.validBuildings) {
+ const entity = this._buildingProxies.get(id) ||
+ this._towerProxies.get(id) ||
+ this._mineProxies.get(id);
+ if (entity) {
+ territory.validBuildings.add(entity as any);
+ (entity as any).inValidTerritory = true;
+ }
+ }
+
+ // Rebuild invalid buildings set
+ for (const id of territoryData.invalidBuildings) {
+ const entity = this._buildingProxies.get(id) ||
+ this._towerProxies.get(id) ||
+ this._mineProxies.get(id);
+ if (entity) {
+ territory.invalidBuildings.add(entity as any);
+ (entity as any).inValidTerritory = false;
+ }
+ }
+
+ // Rebuild internal grid cache for fast position queries
+ (territory as any)._rebuildRefCountGrid();
+ territory.renderer.invalidateCache();
+ }
+ }
+
+ // ==================== Per-Frame Update ====================
+
+ /**
+ * Update adapter state each frame
+ * Call this before rendering
+ */
+ update(dt: number): void {
+ if (!this._gameState) return;
+
+ this._time += dt;
+
+ // Update subsystems
+ this._interpolation.updateRenderTime(dt);
+ this._localEffects.update();
+ this._prediction.update();
+
+ // Sync proxy caches with server state
+ this._syncProxies();
+
+ // Update user state
+ this._syncUserState();
+
+ // Build render arrays
+ this._buildRenderCollections();
+
+ // Update fog of war (after render collections are built)
+ if (this._fogProxy) {
+ this._fogProxy.update(this._towerArray, this._buildingArray);
+ }
+
+ // Reset circle pool for this frame
+ resetCirclePool();
+ }
+
+ // ==================== Proxy Cache Management ====================
+
+ /**
+ * Full rebuild of all proxy caches (on init or reconnect)
+ */
+ private _rebuildAllProxies(): void {
+ if (!this._gameState) return;
+
+ // Clear all
+ this._towerProxies.clear();
+ this._monsterProxies.clear();
+ this._buildingProxies.clear();
+ this._interpolation.clear();
+
+ // Rebuild towers
+ for (const [id, tower] of this._gameState.towers) {
+ const proxy = new TowerRenderProxy(tower);
+ this._towerProxies.set(id, proxy);
+ this._interpolation.pushSnapshot(id, tower.position.x, tower.position.y, 0);
+ }
+
+ // Rebuild monsters
+ for (const [id, monster] of this._gameState.monsters) {
+ const proxy = new MonsterRenderProxy(monster);
+ this._monsterProxies.set(id, proxy);
+ this._interpolation.pushSnapshot(id, monster.position.x, monster.position.y, 0);
+ }
+
+ // Rebuild buildings
+ for (const [id, building] of this._gameState.buildings) {
+ const proxy = new BuildingRenderProxy(building);
+ this._buildingProxies.set(id, proxy);
+ }
+ }
+
+ /**
+ * Incremental sync of proxy caches with server state
+ */
+ private _syncProxies(): void {
+ if (!this._gameState) return;
+
+ // Towers: specialized sync with prediction matching
+ this._syncTowersWithPrediction();
+
+ // Sync monsters
+ this._syncEntityMap(
+ this._gameState.monsters,
+ this._monsterProxies,
+ (state) => new MonsterRenderProxy(state),
+ (proxy, state) => proxy.updateFromState(state),
+ true
+ );
+
+ // Sync buildings
+ this._syncEntityMap(
+ this._gameState.buildings,
+ this._buildingProxies,
+ (state) => new BuildingRenderProxy(state),
+ (proxy, state) => proxy.updateFromState(state),
+ false
+ );
+
+ // Sync mines
+ this._syncMines();
+
+ // Update territories when buildings change
+ this._updateTerritories();
+ }
+
+ /**
+ * Sync mine proxies from GameState.mines
+ */
+ private _syncMines(): void {
+ if (!this._gameState) return;
+
+ const serverMines = this._gameState.mines;
+
+ if (!serverMines) return;
+
+ // Remove proxies for mines no longer on server
+ for (const [id] of this._mineProxies) {
+ if (!serverMines.has(id)) {
+ this._mineProxies.delete(id);
+ }
+ }
+
+ // Add/update proxies
+ serverMines.forEach((mineState, id) => {
+ let proxy = this._mineProxies.get(id);
+ if (!proxy) {
+ proxy = new MineRenderProxy();
+ this._mineProxies.set(id, proxy);
+ }
+ proxy.syncFromSchema(mineState);
+ });
+
+ // Rebuild the mine set
+ this._mineSet.clear();
+ for (const proxy of this._mineProxies.values()) {
+ this._mineSet.add(proxy);
+ }
+ }
+
+ /**
+ * Generic entity map sync helper
+ */
+ private _syncEntityMap(
+ serverMap: Map,
+ proxyMap: Map,
+ createProxy: (state: TState) => TProxy,
+ updateProxy: (proxy: TProxy, state: TState) => void,
+ hasPosition: boolean
+ ): void {
+ // Add new / update existing
+ for (const [id, state] of serverMap) {
+ const existing = proxyMap.get(id);
+ if (existing) {
+ updateProxy(existing, state);
+ } else {
+ proxyMap.set(id, createProxy(state));
+ if (hasPosition && state.position) {
+ this._interpolation.pushSnapshot(id, state.position.x, state.position.y, 0);
+ }
+ }
+ }
+
+ // Remove deleted
+ for (const id of proxyMap.keys()) {
+ if (!serverMap.has(id)) {
+ proxyMap.delete(id);
+ if (hasPosition) {
+ this._interpolation.removeEntity(id);
+ }
+ }
+ }
+ }
+
+ /**
+ * Specialized tower sync that integrates with ClientPrediction.
+ * New towers from server → confirm matching ghost prediction.
+ * Removed towers from server → confirm matching sell prediction.
+ */
+ private _syncTowersWithPrediction(): void {
+ if (!this._gameState) return;
+ const serverMap = this._gameState.towers;
+
+ // Add new / update existing
+ for (const [id, state] of serverMap) {
+ const existing = this._towerProxies.get(id);
+ if (existing) {
+ existing.updateFromState(state);
+ } else {
+ // New tower from server - try to match a pending build prediction
+ this._prediction.findAndConfirmBuild(
+ state.towerType,
+ state.position.x,
+ state.position.y
+ );
+ this._towerProxies.set(id, new TowerRenderProxy(state));
+ this._interpolation.pushSnapshot(id, state.position.x, state.position.y, 0);
+ }
+ }
+
+ // Remove deleted
+ for (const id of this._towerProxies.keys()) {
+ if (!serverMap.has(id)) {
+ // Tower removed from server - try to match a pending sell prediction
+ this._prediction.findAndConfirmSellByTowerId(id);
+ this._towerProxies.delete(id);
+ this._interpolation.removeEntity(id);
+ }
+ }
+ }
+
+ /**
+ * Sync user state from server
+ */
+ private _syncUserState(): void {
+ if (!this._gameState) return;
+
+ const player = this._gameState.players.get(this._localPlayerId);
+ if (player) {
+ this._userState.money = player.money;
+ }
+ }
+
+ /**
+ * Build render collections from proxy caches
+ */
+ private _buildRenderCollections(): void {
+ // Build tower array (including ghost towers from predictions)
+ this._towerArray.length = 0;
+ for (const proxy of this._towerProxies.values()) {
+ if (!proxy.isDead()) {
+ proxy.updateRadarAngle();
+ this._towerArray.push(proxy);
+ }
+ }
+ // Add ghost towers from predictions
+ const ghostTowers = this._prediction.getGhostTowers();
+ for (const ghost of ghostTowers) {
+ this._towerArray.push(ghost as unknown as TowerRenderProxy);
+ }
+
+ // Build building array
+ this._buildingArray.length = 0;
+ for (const proxy of this._buildingProxies.values()) {
+ if (!proxy.isDead()) {
+ this._buildingArray.push(proxy);
+ }
+ }
+
+ // Build monster set
+ this._monsterSet.clear();
+ for (const proxy of this._monsterProxies.values()) {
+ if (!proxy.isDead()) {
+ this._monsterSet.add(proxy);
+ }
+ }
+
+ // Build effect set (from local effects manager)
+ this._effectSet.clear();
+ for (const effect of this._localEffects.effects) {
+ this._effectSet.add(effect);
+ }
+
+ // Build bullet set (from local effects manager)
+ this._bulletSet.clear();
+ for (const bullet of this._localEffects.localBullets) {
+ this._bulletSet.add(bullet);
+ }
+ }
+
+ // ==================== WorldRendererContext ====================
+
+ /**
+ * Get WorldRendererContext for WorldRenderer
+ * This is the main interface consumed by the existing rendering pipeline
+ */
+ getRendererContext(): WorldRendererContext {
+ const mapWidth = this._gameState?.mapConfig.width ?? 6000;
+ const mapHeight = this._gameState?.mapConfig.height ?? 4000;
+
+ // Read energy from local player's PlayerState
+ const localPlayerState = this._gameState?.players.get(this._localPlayerId);
+ const energyProxy = localPlayerState ? {
+ getTotalProduction: () => localPlayerState.energyProduction ?? 6,
+ getTotalConsumption: () => localPlayerState.energyConsumption ?? 0,
+ } : undefined;
+
+ return {
+ width: mapWidth,
+ height: mapHeight,
+ viewWidth: this._camera.viewWidth,
+ viewHeight: this._camera.viewHeight,
+ camera: this._camera,
+ time: this._time,
+
+ // Entity collections
+ batterys: this._towerArray as unknown as WorldRendererContext['batterys'],
+ buildings: this._buildingArray as unknown as WorldRendererContext['buildings'],
+ mines: this._mineSet as unknown as WorldRendererContext['mines'],
+ monsters: this._monsterSet as unknown as WorldRendererContext['monsters'],
+ effects: this._effectSet,
+ allBullys: this._bulletSet as unknown as WorldRendererContext['allBullys'],
+ obstacles: [], // Network mode: obstacles managed by server
+
+ // Spatial grids (null = fallback to array traversal)
+ monsterGrid: null,
+ bullyGrid: null,
+
+ // User state
+ user: this._userState,
+
+ // Systems (optional in network mode)
+ territory: undefined,
+ allTerritories: this._territoryArray.length > 0 ? this._territoryArray : undefined,
+ fog: this._fogProxy ?? undefined,
+ energy: energyProxy,
+ energyRenderer: undefined,
+ monsterFlow: this._gameState ? {
+ toString: () => `Wave ${this._gameState!.wave.currentWave}`,
+ level: this._gameState.wave.currentWave,
+ delayTick: this._gameState.wave.nextWaveTime,
+ } : undefined,
+
+ // Methods
+ syncMonsterRenderListFromSet: undefined,
+ };
+ }
+
+ // ==================== Public API ====================
+
+ /**
+ * Get the camera (for input handling)
+ */
+ get camera(): Camera {
+ return this._camera;
+ }
+
+ /**
+ * Get the local player ID
+ */
+ get localPlayerId(): string {
+ return this._localPlayerId;
+ }
+
+ /**
+ * Get prediction manager (for UI integration)
+ */
+ get prediction(): ClientPrediction {
+ return this._prediction;
+ }
+
+ /**
+ * Get local effects manager
+ */
+ get localEffects(): LocalEffectsManager {
+ return this._localEffects;
+ }
+
+ /**
+ * Get interpolation system
+ */
+ get interpolation(): InterpolationSystem {
+ return this._interpolation;
+ }
+
+ /**
+ * Update camera viewport size
+ */
+ updateViewSize(width: number, height: number): void {
+ this._camera.updateViewSize(width, height);
+ this._fogProxy?.resize(width, height);
+ }
+
+ /**
+ * Clean up all resources
+ */
+ dispose(): void {
+ this._towerProxies.clear();
+ this._monsterProxies.clear();
+ this._buildingProxies.clear();
+ this._interpolation.clear();
+ this._localEffects.clear();
+ this._prediction.clear();
+ this._fogProxy = null;
+ this._gameState = null;
+ this._eventHandlersBound = false;
+ }
+
+ // ==================== Debug ====================
+
+ /**
+ * Get debug info
+ */
+ getDebugInfo(): Record {
+ return {
+ towers: this._towerProxies.size,
+ monsters: this._monsterProxies.size,
+ buildings: this._buildingProxies.size,
+ interpolations: this._interpolation.getEntityCount(),
+ effects: this._localEffects.getEffectCount(),
+ localBullets: this._localEffects.getBulletCount(),
+ predictions: this._prediction.getPredictionCount(),
+ };
+ }
+}
diff --git a/src/network/rendering/renderProxy.ts b/src/network/rendering/renderProxy.ts
new file mode 100644
index 0000000..7e93317
--- /dev/null
+++ b/src/network/rendering/renderProxy.ts
@@ -0,0 +1,645 @@
+/**
+ * Render Proxy Entities
+ * Lightweight proxy classes that wrap server Schema objects and implement renderer interfaces
+ * These proxies allow WorldRenderer to render network-synchronized entities
+ */
+
+import { Vector } from '../../core/math/vector';
+import { Circle } from '../../core/math/circle';
+import { MyColor } from '../../entities/myColor';
+import { createStatusBarCache, type StatusBarCache } from '../../entities/statusBar';
+import { getInterpolationSystem } from './interpolation';
+import { getVisionRadius, RADAR_SWEEP_SPEED, VisionType } from '../../../shared/config/visionMeta.js';
+import type { TowerLike as FogTowerLike } from '../../systems/fog/fogOfWar';
+
+// ============================================================================
+// Server state view interfaces (mirrors Colyseus schema, avoids cross-project import)
+// ============================================================================
+
+export interface TowerStateView {
+ id: string;
+ ownerId: string;
+ towerType: string;
+ position: { x: number; y: number };
+ hp: number;
+ maxHp: number;
+ level: number;
+ radius: number;
+ attackRadius: number;
+ attackClock: number;
+ isManual: boolean;
+ autoTargetX: number;
+ autoTargetY: number;
+ autoTargetRadius: number;
+ hasAutoTarget: boolean;
+ currentAmmo: number;
+ maxAmmo: number;
+ visionType: string;
+ visionLevel: number;
+}
+
+export interface MonsterStateView {
+ id: string;
+ ownerId: string;
+ monsterType: string;
+ position: { x: number; y: number };
+ velocity: { x: number; y: number };
+ hp: number;
+ maxHp: number;
+ radius: number;
+ speed: number;
+}
+
+export interface BuildingStateView {
+ id: string;
+ ownerId: string;
+ buildingType: string;
+ position: { x: number; y: number };
+ hp: number;
+ maxHp: number;
+ radius: number;
+ isBase: boolean;
+ isSpawner: boolean;
+}
+
+// ============================================================================
+// Shared render resources (reused across proxies to reduce GC)
+// ============================================================================
+
+// Cached circle for body rendering
+const _bodyCirclePool: Circle[] = [];
+let _poolIndex = 0;
+
+function getPooledCircle(x: number, y: number, r: number): Circle {
+ if (_poolIndex >= _bodyCirclePool.length) {
+ _bodyCirclePool.push(new Circle(x, y, r));
+ }
+ const circle = _bodyCirclePool[_poolIndex++];
+ circle.x = x;
+ circle.y = y;
+ circle.r = r;
+ circle.pos.x = x;
+ circle.pos.y = y;
+ return circle;
+}
+
+function resetCirclePool(): void {
+ _poolIndex = 0;
+}
+
+// Default colors
+const DEFAULT_HP_COLOR = { r: 0, g: 255, b: 0, a: 0.8 };
+const TOWER_FILL_COLOR = new MyColor(100, 150, 200, 1);
+const MONSTER_FILL_COLOR = new MyColor(200, 50, 50, 1);
+const BUILDING_FILL_COLOR = new MyColor(100, 100, 100, 1);
+
+// ============================================================================
+// TowerRenderProxy - Implements renderer interface for towers
+// ============================================================================
+
+/**
+ * Tower render proxy - wraps TowerState and provides rendering interface
+ */
+export class TowerRenderProxy implements FogTowerLike {
+ // Reference to server state
+ private _state: TowerStateView;
+
+ // Cached position (updated from interpolation)
+ private _interpolatedPos: Vector;
+
+ // Cached rendering objects
+ private _bodyCircle: Circle;
+ private _viewCircle: Circle;
+ readonly _hpBarCache: StatusBarCache;
+ readonly _cooldownBarCache: StatusBarCache;
+ readonly _chargeBarCache: StatusBarCache;
+
+ // Renderer interface properties
+ readonly hpBarHeight: number = 6;
+ readonly hpColor = DEFAULT_HP_COLOR;
+ readonly imgIndex: number = 0;
+ readonly selected: boolean = false;
+ readonly bullys: Set = new Set(); // Empty for network mode (bullets not synced)
+ _upIconOffset: Vector | null = null;
+ readonly liveTime: number = 0;
+
+ constructor(state: TowerStateView) {
+ this._state = state;
+ this._interpolatedPos = new Vector(state.position.x, state.position.y);
+ this._bodyCircle = new Circle(state.position.x, state.position.y, state.radius);
+ this._bodyCircle.fillColor = TOWER_FILL_COLOR;
+ this._viewCircle = new Circle(state.position.x, state.position.y, state.attackRadius);
+ this._viewCircle.fillColor.setRGBA(0, 0, 0, 0);
+ this._viewCircle.strokeColor.setRGBA(255, 255, 255, 0.3);
+ this._hpBarCache = createStatusBarCache();
+ this._cooldownBarCache = createStatusBarCache();
+ this._chargeBarCache = createStatusBarCache();
+ }
+
+ // Entity ID
+ get id(): string {
+ return this._state.id;
+ }
+
+ // Owner ID
+ get ownerId(): string {
+ return this._state.ownerId;
+ }
+
+ // Position (interpolated)
+ get pos(): Vector {
+ const interp = getInterpolationSystem();
+ const interpPos = interp.getPositionOrDefault(
+ this._state.id,
+ this._state.position.x,
+ this._state.position.y
+ );
+ this._interpolatedPos.x = interpPos.x;
+ this._interpolatedPos.y = interpPos.y;
+ return this._interpolatedPos;
+ }
+
+ // Radius
+ get r(): number {
+ return this._state.radius;
+ }
+
+ // Range radius
+ get rangeR(): number {
+ return this._state.attackRadius;
+ }
+
+ // HP
+ get hp(): number {
+ return this._state.hp;
+ }
+
+ // Max HP
+ get maxHp(): number {
+ return this._state.maxHp;
+ }
+
+ // Level
+ get level(): number {
+ return this._state.level;
+ }
+
+ // Tower type
+ get towerType(): string {
+ return this._state.towerType;
+ }
+
+ // Territory flag (network mode always valid)
+ get inValidTerritory(): boolean {
+ return true;
+ }
+
+ // Whether this tower can attack buildings (ManualCannon)
+ get canAttackBuildings(): boolean {
+ return this._state.isManual;
+ }
+
+ // Whether this is a manual cannon
+ get isManual(): boolean {
+ return this._state.isManual;
+ }
+
+ // Semi-auto mode status (from server hasAutoTarget)
+ get semiAutoMode(): boolean {
+ return this._state.hasAutoTarget;
+ }
+
+ // Auto-target properties
+ get hasAutoTarget(): boolean {
+ return this._state.hasAutoTarget;
+ }
+
+ get autoTargetX(): number {
+ return this._state.autoTargetX;
+ }
+
+ get autoTargetY(): number {
+ return this._state.autoTargetY;
+ }
+
+ get autoTargetRadius(): number {
+ return this._state.autoTargetRadius;
+ }
+
+ // Ammo properties
+ get currentAmmo(): number {
+ return this._state.currentAmmo;
+ }
+
+ get maxAmmo(): number {
+ return this._state.maxAmmo;
+ }
+
+ // Vision system properties (for FogOfWar TowerLike interface)
+ get visionType(): VisionType {
+ return (this._state.visionType as VisionType) || VisionType.NONE;
+ }
+
+ get visionLevel(): number {
+ return this._state.visionLevel || 0;
+ }
+
+ radarAngle: number = 0;
+
+ /** Advance radar angle for local rendering (call per frame) */
+ updateRadarAngle(): void {
+ if (this.visionType === VisionType.RADAR && this.visionLevel > 0) {
+ this.radarAngle += RADAR_SWEEP_SPEED;
+ if (this.radarAngle > Math.PI * 2) {
+ this.radarAngle -= Math.PI * 2;
+ }
+ }
+ }
+
+ getVisionRadius(): number {
+ return getVisionRadius(this.visionType, this.visionLevel);
+ }
+
+ // Game type identifier (for PanelManager detection)
+ get gameType(): string {
+ return 'Tower';
+ }
+
+ // Dead check
+ isDead(): boolean {
+ return this._state.hp <= 0;
+ }
+
+ // Screen visibility (simplified for network mode)
+ isInScreen(): boolean {
+ return true; // Let WorldRenderer handle culling
+ }
+
+ // Get body circle
+ getBodyCircle(): Circle {
+ const pos = this.pos;
+ this._bodyCircle.x = pos.x;
+ this._bodyCircle.y = pos.y;
+ this._bodyCircle.pos.x = pos.x;
+ this._bodyCircle.pos.y = pos.y;
+ this._bodyCircle.r = this._state.radius;
+ return this._bodyCircle;
+ }
+
+ // Get view (range) circle
+ getViewCircle(): Circle {
+ const pos = this.pos;
+ this._viewCircle.x = pos.x;
+ this._viewCircle.y = pos.y;
+ this._viewCircle.r = this._state.attackRadius;
+ return this._viewCircle;
+ }
+
+ // Image sprite position (stub - network mode may not use sprites)
+ getImgStartPosByIndex(_n: number): Vector {
+ return new Vector(0, 0);
+ }
+
+ // Upgrade check (network mode - based on level)
+ isUpLevelAble(): boolean {
+ return false; // Server controls upgrades
+ }
+
+ // Tower level getter
+ getTowerLevel(): number {
+ return this._state.level;
+ }
+
+ // HP change (no-op in network mode - server controls HP)
+ hpChange(_delta: number): void {
+ // Server authoritative
+ }
+
+ // Update proxy from new state snapshot
+ updateFromState(state: TowerStateView): void {
+ this._state = state;
+ // Push position to interpolation system
+ const interp = getInterpolationSystem();
+ interp.pushSnapshot(state.id, state.position.x, state.position.y, 0);
+ }
+}
+
+// ============================================================================
+// MonsterRenderProxy - Implements renderer interface for monsters
+// ============================================================================
+
+/**
+ * Monster render proxy - wraps MonsterStateView and provides rendering interface
+ */
+export class MonsterRenderProxy {
+ private _state: MonsterStateView;
+ private _interpolatedPos: Vector;
+ private _bodyCircle: Circle;
+
+ // Renderer interface properties
+ readonly _hpBarCache: StatusBarCache;
+ readonly _laserBarCache: StatusBarCache;
+ readonly hpBarHeight: number = 5;
+ readonly hpColor = DEFAULT_HP_COLOR;
+ readonly imgIndex: number = 0;
+ readonly name: string = '';
+
+ // Ability flags (default off for network proxies)
+ readonly bombSelfAble: boolean = false;
+ readonly bombSelfRange: number = 0;
+ readonly haveGArea: boolean = false;
+ readonly gAreaR: number = 0;
+ readonly haveBullyChangeArea: boolean = false;
+ readonly bullyChangeDetails = { r: 0 };
+ readonly haveGain: boolean = false;
+ readonly gainDetails = { gainRadius: 0 };
+ readonly haveLaserDefence: boolean = false;
+ readonly laserRadius: number = 0;
+ readonly laserDefendNum: number = 0;
+ readonly maxLaserNum: number = 0;
+
+ // For sweep collision (stored locally)
+ prevX: number = 0;
+ prevY: number = 0;
+
+ constructor(state: MonsterStateView) {
+ this._state = state;
+ this._interpolatedPos = new Vector(state.position.x, state.position.y);
+ this._bodyCircle = new Circle(state.position.x, state.position.y, state.radius);
+ this._bodyCircle.fillColor = MONSTER_FILL_COLOR;
+ this._hpBarCache = createStatusBarCache();
+ this._laserBarCache = createStatusBarCache();
+ this.prevX = state.position.x;
+ this.prevY = state.position.y;
+ }
+
+ get id(): string {
+ return this._state.id;
+ }
+
+ get ownerId(): string {
+ return this._state.ownerId;
+ }
+
+ get pos(): Vector {
+ const interp = getInterpolationSystem();
+ const interpPos = interp.getPositionOrDefault(
+ this._state.id,
+ this._state.position.x,
+ this._state.position.y
+ );
+ // Update prevX/prevY for sweep collision
+ this.prevX = this._interpolatedPos.x;
+ this.prevY = this._interpolatedPos.y;
+ this._interpolatedPos.x = interpPos.x;
+ this._interpolatedPos.y = interpPos.y;
+ return this._interpolatedPos;
+ }
+
+ get r(): number {
+ return this._state.radius;
+ }
+
+ get hp(): number {
+ return this._state.hp;
+ }
+
+ get maxHp(): number {
+ return this._state.maxHp;
+ }
+
+ get monsterType(): string {
+ return this._state.monsterType;
+ }
+
+ isDead(): boolean {
+ return this._state.hp <= 0;
+ }
+
+ isInScreen(): boolean {
+ return true;
+ }
+
+ isOutScreen(): boolean {
+ return false;
+ }
+
+ getBodyCircle(): Circle {
+ const pos = this.pos;
+ this._bodyCircle.x = pos.x;
+ this._bodyCircle.y = pos.y;
+ this._bodyCircle.pos.x = pos.x;
+ this._bodyCircle.pos.y = pos.y;
+ this._bodyCircle.r = this._state.radius;
+ return this._bodyCircle;
+ }
+
+ getImgStartPosByIndex(_n: number): Vector {
+ return new Vector(0, 0);
+ }
+
+ hpChange(_delta: number): void {
+ // Server authoritative
+ }
+
+ updateFromState(state: MonsterStateView): void {
+ this._state = state;
+ const interp = getInterpolationSystem();
+ interp.pushSnapshot(state.id, state.position.x, state.position.y, 0);
+ }
+}
+
+// ============================================================================
+// BuildingRenderProxy - Implements renderer interface for buildings
+// ============================================================================
+
+/**
+ * Building render proxy - wraps BuildingStateView and provides rendering interface
+ */
+export class BuildingRenderProxy {
+ private _state: BuildingStateView;
+ private _pos: Vector;
+ private _bodyCircle: Circle;
+
+ readonly _hpBarCache: StatusBarCache;
+ readonly hpBarHeight: number = 6;
+ readonly hpColor = DEFAULT_HP_COLOR;
+ readonly otherHpAddAble: boolean = false;
+ readonly otherHpAddRadius: number = 0;
+ readonly gameType: string = '';
+
+ constructor(state: BuildingStateView) {
+ this._state = state;
+ this._pos = new Vector(state.position.x, state.position.y);
+ this._bodyCircle = new Circle(state.position.x, state.position.y, state.radius);
+ this._bodyCircle.fillColor = BUILDING_FILL_COLOR;
+ this._hpBarCache = createStatusBarCache();
+ }
+
+ get id(): string {
+ return this._state.id;
+ }
+
+ get ownerId(): string {
+ return this._state.ownerId;
+ }
+
+ get pos(): Vector {
+ this._pos.x = this._state.position.x;
+ this._pos.y = this._state.position.y;
+ return this._pos;
+ }
+
+ get r(): number {
+ return this._state.radius;
+ }
+
+ get hp(): number {
+ return this._state.hp;
+ }
+
+ get maxHp(): number {
+ return this._state.maxHp;
+ }
+
+ get isBase(): boolean {
+ return this._state.isBase;
+ }
+
+ get buildingType(): string {
+ return this._state.buildingType;
+ }
+
+ isDead(): boolean {
+ return this._state.hp <= 0;
+ }
+
+ isInScreen(): boolean {
+ return true;
+ }
+
+ getBodyCircle(): Circle {
+ const pos = this.pos;
+ this._bodyCircle.x = pos.x;
+ this._bodyCircle.y = pos.y;
+ this._bodyCircle.pos.x = pos.x;
+ this._bodyCircle.pos.y = pos.y;
+ this._bodyCircle.r = this._state.radius;
+ return this._bodyCircle;
+ }
+
+ hpChange(_delta: number): void {
+ // Server authoritative
+ }
+
+ goStep(): void {
+ // No-op in network mode
+ }
+
+ render(_ctx: CanvasRenderingContext2D): void {
+ // Rendering handled by WorldRenderer
+ }
+
+ updateFromState(state: BuildingStateView): void {
+ this._state = state;
+ }
+}
+
+// ============================================================================
+// BulletRenderProxy - Local effect bullet for visualization
+// ============================================================================
+
+/**
+ * Local bullet for visual effects (not synchronized)
+ * Used for showing attack animations between BULLET_FIRED and hit events
+ */
+export class BulletRenderProxy {
+ private _pos: Vector;
+ private _targetPos: Vector;
+ private _bodyCircle: Circle;
+ private _speed: number;
+ private _isExpired: boolean = false;
+
+ readonly r: number;
+ prevX: number;
+ prevY: number;
+
+ constructor(
+ startX: number,
+ startY: number,
+ targetX: number,
+ targetY: number,
+ radius: number = 5,
+ speed: number = 15
+ ) {
+ this._pos = new Vector(startX, startY);
+ this._targetPos = new Vector(targetX, targetY);
+ this._bodyCircle = new Circle(startX, startY, radius);
+ this._bodyCircle.fillColor.setRGBA(255, 200, 0, 1);
+ this.r = radius;
+ this._speed = speed;
+ this.prevX = startX;
+ this.prevY = startY;
+ }
+
+ get pos(): Vector {
+ return this._pos;
+ }
+
+ get isExpired(): boolean {
+ return this._isExpired;
+ }
+
+ isOutScreen(): boolean {
+ return this._isExpired;
+ }
+
+ getBodyCircle(): Circle {
+ this._bodyCircle.x = this._pos.x;
+ this._bodyCircle.y = this._pos.y;
+ this._bodyCircle.pos.x = this._pos.x;
+ this._bodyCircle.pos.y = this._pos.y;
+ return this._bodyCircle;
+ }
+
+ // Move bullet toward target
+ move(): void {
+ this.prevX = this._pos.x;
+ this.prevY = this._pos.y;
+
+ const dx = this._targetPos.x - this._pos.x;
+ const dy = this._targetPos.y - this._pos.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+
+ if (dist <= this._speed) {
+ // Arrived at target
+ this._pos.x = this._targetPos.x;
+ this._pos.y = this._targetPos.y;
+ this._isExpired = true;
+ } else {
+ // Move toward target
+ this._pos.x += (dx / dist) * this._speed;
+ this._pos.y += (dy / dist) * this._speed;
+ }
+ }
+
+ // Collision check (no-op for local effects)
+ collide(_world: unknown): void {
+ // Local effect - no actual collision
+ }
+
+ goStep(): void {
+ this.move();
+ }
+
+ render(ctx: CanvasRenderingContext2D): void {
+ if (this._isExpired) return;
+ this.getBodyCircle().render(ctx);
+ }
+}
+
+// ============================================================================
+// Exports
+// ============================================================================
+
+export { resetCirclePool };
diff --git a/src/network/validation/clientValidator.ts b/src/network/validation/clientValidator.ts
new file mode 100644
index 0000000..b1d38c1
--- /dev/null
+++ b/src/network/validation/clientValidator.ts
@@ -0,0 +1,208 @@
+/**
+ * ClientValidator - Client-side pre-validation for UX fast feedback
+ *
+ * Uses shared validation pure functions + local World state
+ * Does NOT replace server-side validation - just provides quick feedback
+ */
+
+import type { World } from '../../game/world';
+import type { TowerRegistry } from '../../towers/towerRegistry';
+import { TowerRegistry as TowerReg } from '../../towers/towerRegistry';
+import {
+ type ValidationResult,
+ ValidationErrorCode,
+ validationSuccess,
+ validationFailure,
+ validateBuildTowerBasic,
+ validateCannonFire,
+ checkBuildCollision,
+ type TowerMetaData,
+ type TowerValidationState,
+ type PlayerValidationState,
+} from '../../../shared/validation';
+import { SPAWNABLE_MONSTERS } from '../../buildings/spawnerConfig';
+
+/**
+ * Minimal local player state for validation
+ */
+interface LocalPlayerState {
+ isAlive: boolean;
+ money: number;
+}
+
+/**
+ * Get tower metadata from TowerRegistry
+ */
+function getTowerMeta(towerType: string): TowerMetaData | undefined {
+ const meta = TowerReg.getMeta(towerType);
+ if (!meta) return undefined;
+ // levelUpArr not directly available from meta, return empty for basic check
+ return {
+ id: towerType,
+ price: meta.basePrice,
+ levelUpArr: [], // Will be filled if we have config access
+ };
+}
+
+/**
+ * Client-side validator for pre-flight checks
+ */
+export class ClientValidator {
+ private world: World;
+
+ constructor(world: World) {
+ this.world = world;
+ }
+
+ /**
+ * Update world reference
+ */
+ setWorld(world: World): void {
+ this.world = world;
+ }
+
+ /**
+ * Get local player state for validation
+ */
+ private getLocalPlayer(): PlayerValidationState | undefined {
+ // In single player or local context, we use world's getMoney
+ return {
+ id: 'local',
+ isAlive: !this.world.getBaseBuilding()?.isDead?.(),
+ money: this.world.getMoney() ?? 0,
+ };
+ }
+
+ /**
+ * Pre-validate BUILD_TOWER
+ * Quick check without full territory calculation
+ */
+ validateBuildTower(
+ towerType: string,
+ x: number,
+ y: number
+ ): ValidationResult {
+ const player = this.getLocalPlayer();
+ const meta = getTowerMeta(towerType);
+ const bounds = {
+ width: this.world.width,
+ height: this.world.height,
+ };
+
+ // Basic validation
+ const basicResult = validateBuildTowerBasic(player, towerType, x, y, meta, bounds);
+ if (!basicResult.valid) return basicResult;
+
+ // Simple territory check (in own territory)
+ const territory = this.world.territory;
+ if (territory && !territory.isPositionInValidTerritory({ x, y } as any)) {
+ // Check if in enemy territory (for PVP, need different check)
+ // For now, just fail if not in own territory
+ return validationFailure(ValidationErrorCode.POSITION_NOT_IN_TERRITORY);
+ }
+
+ // Collision check with existing towers and buildings
+ const towers = this.world.batterys || [];
+ const buildings = this.world.buildings || [];
+
+ const towerColliders = towers.map((t: any) => ({
+ position: { x: t.pos?.x ?? t.x, y: t.pos?.y ?? t.y },
+ radius: t.r,
+ id: t.id,
+ }));
+ const buildingColliders = (buildings as any[]).map((b: any) => ({
+ position: { x: b.pos?.x ?? b.x, y: b.pos?.y ?? b.y },
+ radius: b.r,
+ id: b.id,
+ }));
+
+ const collisionResult = checkBuildCollision(x, y, 15, towerColliders, buildingColliders);
+ if (collisionResult.collides) {
+ return validationFailure(ValidationErrorCode.POSITION_COLLISION);
+ }
+
+ // Money check
+ const price = meta!.price;
+ if (player!.money < price) {
+ return validationFailure(
+ ValidationErrorCode.INSUFFICIENT_MONEY,
+ `Need ${price}, have ${player!.money}`
+ );
+ }
+
+ return validationSuccess({ cost: price, towerType });
+ }
+
+ /**
+ * Pre-validate SPAWN_MONSTER
+ */
+ validateSpawnMonster(
+ monsterType: string,
+ currentWave: number
+ ): ValidationResult {
+ const player = this.getLocalPlayer();
+
+ if (!player || !player.isAlive) {
+ return validationFailure(ValidationErrorCode.PLAYER_NOT_ALIVE);
+ }
+
+ // Find monster config
+ const config = SPAWNABLE_MONSTERS.find(m => m.monsterId === monsterType);
+ if (!config) {
+ return validationFailure(
+ ValidationErrorCode.MONSTER_TYPE_INVALID,
+ `Unknown monster type: ${monsterType}`
+ );
+ }
+
+ // Wave unlock check
+ if (currentWave < config.unlockWave) {
+ return validationFailure(
+ ValidationErrorCode.MONSTER_NOT_UNLOCKED,
+ `Unlocks at wave ${config.unlockWave}`
+ );
+ }
+
+ // Money check
+ if (player.money < config.cost) {
+ return validationFailure(
+ ValidationErrorCode.INSUFFICIENT_MONEY,
+ `Need ${config.cost}, have ${player.money}`
+ );
+ }
+
+ return validationSuccess({ cost: config.cost });
+ }
+
+ /**
+ * Pre-validate CANNON_FIRE
+ */
+ validateCannonFire(
+ towerId: string,
+ targetX: number,
+ targetY: number
+ ): ValidationResult {
+ const player = this.getLocalPlayer();
+
+ // Find tower in world
+ const towers = this.world.batterys || [];
+ const tower = towers.find((t: any) => t.id === towerId) as any;
+
+ if (!tower) {
+ return validationFailure(ValidationErrorCode.CANNON_NOT_FOUND);
+ }
+
+ const towerValidation: TowerValidationState = {
+ id: towerId,
+ ownerId: 'local',
+ towerType: tower.constructor.name,
+ position: { x: tower.pos?.x ?? tower.x, y: tower.pos?.y ?? tower.y },
+ radius: tower.r,
+ attackRadius: tower.rangeR || 200,
+ isManual: tower.isManual || false,
+ currentAmmo: tower.currentAmmo ?? 1,
+ };
+
+ return validateCannonFire(player, towerValidation, targetX, targetY);
+ }
+}
diff --git a/src/network/validation/index.ts b/src/network/validation/index.ts
new file mode 100644
index 0000000..1431153
--- /dev/null
+++ b/src/network/validation/index.ts
@@ -0,0 +1,4 @@
+/**
+ * Client Validation Module
+ */
+export { ClientValidator } from './clientValidator';
diff --git a/src/styles/index.less b/src/styles/index.less
index ada52c2..421014b 100644
--- a/src/styles/index.less
+++ b/src/styles/index.less
@@ -653,6 +653,270 @@ body {
}
+// ==================== 怪物生成塔面板样式 ====================
+#spawnerPanel {
+ position: absolute;
+ min-width: 250px;
+ max-width: 320px;
+ padding: 8px;
+ border-radius: 5px;
+ background-color: rgba(255, 255, 255, 0.5);
+ z-index: 1000;
+
+ .spawner-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+ padding-bottom: 6px;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.2);
+
+ .spawner-title {
+ color: #333;
+ font-weight: bold;
+ font-size: 14px;
+ }
+
+ .spawner-close {
+ cursor: pointer;
+ color: #666;
+ font-size: 18px;
+ line-height: 1;
+ transition: all 0.2s;
+
+ &:hover {
+ color: #333;
+ transform: scale(1.1);
+ }
+ }
+ }
+
+ .spawner-target-section {
+ margin-bottom: 8px;
+
+ label {
+ color: #333;
+ font-size: 12px;
+ margin-right: 8px;
+ }
+
+ .spawner-target-select {
+ padding: 4px 8px;
+ border-radius: 5px;
+ border: none;
+ background-color: rgba(255, 255, 255, 0.5);
+ color: #333;
+ font-size: 12px;
+ cursor: pointer;
+
+ &:focus {
+ outline: none;
+ }
+ }
+ }
+
+ .spawner-monster-list {
+ max-height: 200px;
+ overflow-y: auto;
+
+ .spawner-monster-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 6px 8px;
+ margin-bottom: 3px;
+ border-radius: 5px;
+ background-color: rgba(255, 255, 255, 0.5);
+ transition: all 0.2s;
+
+ .monster-name {
+ color: #333;
+ font-size: 12px;
+ font-weight: bold;
+ flex: 1;
+ }
+
+ .monster-cost {
+ color: #b8860b;
+ font-size: 11px;
+ margin: 0 10px;
+ font-weight: bold;
+ }
+
+ .monster-status {
+ color: #666;
+ font-size: 10px;
+ text-align: right;
+ min-width: 70px;
+ }
+
+ &.available {
+ cursor: pointer;
+
+ &:hover {
+ transform: scale(1.05);
+ background-color: rgba(255, 255, 255, 0.7);
+ }
+
+ .monster-status {
+ color: #228b22;
+ }
+ }
+
+ &.disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+
+ .monster-name {
+ color: #666;
+ }
+ }
+ }
+ }
+
+ .spawner-hint {
+ color: #c00;
+ font-size: 12px;
+ text-align: center;
+ padding: 10px;
+ display: none;
+ }
+}
+
+// ==================== 手动炮塔面板样式 ====================
+#manualCannonPanel {
+ position: absolute;
+ min-width: 220px;
+ max-width: 280px;
+ padding: 8px;
+ border-radius: 5px;
+ background-color: rgba(255, 255, 255, 0.5);
+ z-index: 1000;
+
+ .cannon-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+ padding-bottom: 6px;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.2);
+
+ .cannon-title {
+ color: #333;
+ font-weight: bold;
+ font-size: 14px;
+ }
+
+ .cannon-close {
+ cursor: pointer;
+ color: #666;
+ font-size: 18px;
+ line-height: 1;
+ transition: all 0.2s;
+
+ &:hover {
+ color: #333;
+ transform: scale(1.1);
+ }
+ }
+ }
+
+ .cannon-stats {
+ margin-bottom: 8px;
+
+ .cannon-stat {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 3px 0;
+ font-size: 12px;
+
+ .stat-label {
+ color: #555;
+ }
+
+ .cannon-ammo {
+ color: #b8860b;
+ font-weight: bold;
+ }
+
+ .cannon-damage {
+ color: #c00;
+ font-weight: bold;
+ }
+
+ .cannon-range {
+ color: #228b22;
+ }
+ }
+ }
+
+ .cannon-reload-bar {
+ height: 4px;
+ background-color: rgba(0, 0, 0, 0.2);
+ border-radius: 2px;
+ margin-bottom: 8px;
+ overflow: hidden;
+
+ .cannon-reload-progress {
+ height: 100%;
+ width: 0%;
+ background-color: #4a9eff;
+ border-radius: 2px;
+ transition: width 0.1s ease;
+ }
+ }
+
+ .cannon-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ .cannon-btn {
+ padding: 6px 10px;
+ border: none;
+ border-radius: 5px;
+ font-size: 12px;
+ cursor: pointer;
+ transition: all 0.2s;
+ color: #333;
+ background-color: rgba(255, 255, 255, 0.5);
+
+ &:hover:not(:disabled) {
+ transform: scale(1.05);
+ background-color: rgba(255, 255, 255, 0.7);
+ }
+
+ &.cannon-fire-btn {
+ font-weight: bold;
+ }
+
+ &.cannon-semi-btn {
+ font-weight: bold;
+ }
+
+ &.cannon-clear-btn {
+ display: none;
+ color: #c00;
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none;
+ }
+ }
+ }
+
+ .cannon-hint {
+ color: #666;
+ font-size: 11px;
+ text-align: center;
+ padding: 6px 0 0 0;
+ font-style: italic;
+ }
+}
+
button {
color: white;
}
@@ -1133,6 +1397,540 @@ button {
}
}
+// ==================== 多人模式界面样式 ====================
+// Multiplayer interface common styles (unified with main game UI)
+
+// Multiplayer button mixin - matches .interfaceBtn() style
+.multiplayerBtn() {
+ background-color: #1e1e1e;
+ color: rgb(241, 243, 244);
+ display: block;
+ padding: 14px 32px;
+ transition: all 0.2s;
+ cursor: pointer;
+ border: none;
+ border-radius: 100px;
+ font-family: myFont, "Times New Roman", serif;
+ font-size: 18px;
+ font-weight: bold;
+
+ &:hover:not(:disabled) {
+ transform: scale(1.1);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+}
+
+.multiplayer-connect-interface,
+.multiplayer-lobby-interface,
+.multiplayer-waiting-interface {
+ // Use default .header() styles for h1/h2 - remove position override
+
+ .leftTopArea {
+ position: absolute;
+ left: 0;
+ top: 0;
+ }
+}
+
+// Connect Interface
+.multiplayer-connect-interface {
+ .multiplayer-content {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: 400px;
+ padding: 30px;
+ background-color: rgba(30, 30, 30, 0.85);
+ border-radius: 20px;
+ }
+
+ .input-group {
+ margin-bottom: 20px;
+
+ label {
+ display: block;
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 14px;
+ margin-bottom: 8px;
+ font-family: myFont, "Times New Roman", serif;
+ }
+
+ input {
+ width: 100%;
+ padding: 12px 16px;
+ background-color: rgba(255, 255, 255, 0.1);
+ border: 2px solid rgba(255, 255, 255, 0.2);
+ border-radius: 100px;
+ color: #f1f3f4;
+ font-size: 16px;
+ transition: all 0.2s;
+
+ &:focus {
+ outline: none;
+ border-color: rgba(255, 255, 255, 0.5);
+ }
+
+ &::placeholder {
+ color: #666;
+ }
+ }
+ }
+
+ .connect-btn {
+ .multiplayerBtn();
+ width: 100%;
+ margin-top: 10px;
+ }
+
+ .connection-status {
+ text-align: center;
+ margin-top: 16px;
+ padding: 8px;
+ border-radius: 100px;
+ font-size: 14px;
+ font-family: myFont, "Times New Roman", serif;
+
+ &.info {
+ color: #aaa;
+ }
+
+ &.success {
+ color: #6bff6b;
+ background-color: rgba(107, 255, 107, 0.1);
+ }
+
+ &.error {
+ color: #ff6b6b;
+ background-color: rgba(255, 107, 107, 0.1);
+ }
+ }
+}
+
+// Lobby Interface
+.multiplayer-lobby-interface {
+ .lobby-header {
+ position: absolute;
+ right: 30px;
+ top: 30px;
+ color: #f1f3f4;
+ font-size: 16px;
+ font-family: myFont, "Times New Roman", serif;
+
+ .lobby-player-label {
+ background-color: rgba(30, 30, 30, 0.8);
+ padding: 8px 20px;
+ border-radius: 100px;
+ }
+ }
+
+ .lobby-content {
+ position: absolute;
+ left: 50%;
+ top: 55%;
+ transform: translate(-50%, -50%);
+ width: 500px;
+ max-height: 70vh;
+ }
+
+ .room-list-section {
+ background-color: rgba(30, 30, 30, 0.85);
+ border-radius: 20px;
+ overflow: hidden;
+ margin-bottom: 20px;
+ }
+
+ .room-list-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 20px;
+ background-color: rgba(0, 0, 0, 0.3);
+ color: #f1f3f4;
+ font-weight: bold;
+ font-family: myFont, "Times New Roman", serif;
+
+ .refresh-btn {
+ .multiplayerBtn();
+ padding: 6px 16px;
+ font-size: 12px;
+ }
+ }
+
+ .room-list-container {
+ max-height: 300px;
+ overflow-y: auto;
+ padding: 10px;
+ }
+
+ .empty-room-list {
+ text-align: center;
+ color: #888;
+ padding: 40px 20px;
+ font-size: 16px;
+ font-family: myFont, "Times New Roman", serif;
+ }
+
+ .room-item {
+ display: flex;
+ align-items: center;
+ padding: 12px 16px;
+ margin-bottom: 8px;
+ background-color: rgba(255, 255, 255, 0.05);
+ border-radius: 100px;
+ transition: all 0.2s;
+
+ &:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+ }
+
+ &.room-full {
+ opacity: 0.6;
+ }
+
+ .room-icon {
+ font-size: 20px;
+ margin-right: 12px;
+ }
+
+ .room-name {
+ flex: 1;
+ color: #f1f3f4;
+ font-size: 14px;
+ font-family: myFont, "Times New Roman", serif;
+ }
+
+ .room-players {
+ color: #aaa;
+ font-size: 12px;
+ margin-right: 12px;
+ }
+
+ .join-room-btn {
+ .multiplayerBtn();
+ padding: 6px 20px;
+ font-size: 12px;
+ }
+ }
+
+ .lobby-actions {
+ display: flex;
+ gap: 12px;
+ margin-bottom: 16px;
+ }
+
+ .lobby-btn {
+ .multiplayerBtn();
+ flex: 1;
+ text-align: center;
+
+ &.searching {
+ background-color: #ff9800;
+ animation: searchPulse 1.5s ease-in-out infinite;
+ }
+ }
+
+ .match-status {
+ text-align: center;
+ color: #aaa;
+ font-size: 14px;
+ font-family: myFont, "Times New Roman", serif;
+
+ &.success {
+ color: #6bff6b;
+ }
+
+ &.error {
+ color: #ff6b6b;
+ }
+ }
+}
+
+@keyframes searchPulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.7; }
+}
+
+// Create Room Dialog
+.multiplayer-dialog {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ background-color: rgba(0, 0, 0, 0.7);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 10000;
+ backdrop-filter: blur(3px);
+
+ .dialog-content {
+ background-color: rgba(30, 30, 30, 0.95);
+ padding: 28px;
+ border-radius: 20px;
+ min-width: 380px;
+
+ h3 {
+ color: #f1f3f4;
+ text-align: center;
+ margin-bottom: 20px;
+ font-size: 20px;
+ font-family: myFont, "Times New Roman", serif;
+ }
+ }
+
+ .dialog-body {
+ margin-bottom: 20px;
+ }
+
+ .input-group {
+ margin-bottom: 16px;
+
+ label {
+ display: block;
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 14px;
+ margin-bottom: 8px;
+ font-family: myFont, "Times New Roman", serif;
+ }
+
+ input[type="text"] {
+ width: 100%;
+ padding: 10px 16px;
+ background-color: rgba(255, 255, 255, 0.1);
+ border: 2px solid rgba(255, 255, 255, 0.2);
+ border-radius: 100px;
+ color: #f1f3f4;
+ font-size: 14px;
+
+ &:focus {
+ outline: none;
+ border-color: rgba(255, 255, 255, 0.5);
+ }
+ }
+ }
+
+ .map-size-group {
+ margin-bottom: 16px;
+
+ > label {
+ display: block;
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 14px;
+ margin-bottom: 10px;
+ font-family: myFont, "Times New Roman", serif;
+ }
+
+ .radio-options {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .radio-option {
+ display: flex;
+ align-items: center;
+ padding: 10px 16px;
+ background-color: rgba(255, 255, 255, 0.05);
+ border-radius: 100px;
+ cursor: pointer;
+ transition: all 0.2s;
+
+ &:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+ }
+
+ input[type="radio"] {
+ margin-right: 10px;
+ accent-color: #f1f3f4;
+ }
+
+ span {
+ color: #ddd;
+ font-size: 14px;
+ font-family: myFont, "Times New Roman", serif;
+ }
+ }
+ }
+
+ .checkbox-group {
+ label {
+ display: flex;
+ align-items: center;
+ color: #ddd;
+ font-size: 14px;
+ cursor: pointer;
+ font-family: myFont, "Times New Roman", serif;
+
+ input[type="checkbox"] {
+ margin-right: 10px;
+ accent-color: #f1f3f4;
+ }
+ }
+ }
+
+ .dialog-buttons {
+ display: flex;
+ gap: 12px;
+ justify-content: center;
+
+ .dialog-btn {
+ .multiplayerBtn();
+ padding: 10px 28px;
+ font-size: 14px;
+
+ &.cancel {
+ background-color: #444;
+ }
+ }
+ }
+}
+
+// Waiting Room Interface
+.multiplayer-waiting-interface {
+ .waiting-room-info {
+ position: absolute;
+ left: 50%;
+ top: 28%;
+ transform: translateX(-50%);
+ color: #aaa;
+ font-size: 16px;
+ font-family: myFont, "Times New Roman", serif;
+ background-color: rgba(30, 30, 30, 0.8);
+ padding: 8px 24px;
+ border-radius: 100px;
+ }
+
+ .waiting-content {
+ position: absolute;
+ left: 50%;
+ top: 55%;
+ transform: translate(-50%, -50%);
+ width: 450px;
+ text-align: center;
+ }
+
+ .waiting-player-list {
+ margin-bottom: 24px;
+ }
+
+ .player-slot {
+ margin-bottom: 16px;
+ padding: 16px 20px;
+ background-color: rgba(30, 30, 30, 0.85);
+ border-radius: 20px;
+
+ &.filled.ready {
+ box-shadow: 0 0 10px rgba(107, 255, 107, 0.3);
+ }
+
+ .player-number {
+ color: #888;
+ font-size: 12px;
+ text-align: left;
+ margin-bottom: 8px;
+ font-family: myFont, "Times New Roman", serif;
+ }
+
+ .player-card {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ }
+
+ .player-avatar {
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ flex-shrink: 0;
+
+ &.empty {
+ opacity: 0.3;
+ }
+ }
+
+ .player-info {
+ flex: 1;
+ text-align: left;
+
+ .player-name {
+ color: #f1f3f4;
+ font-size: 16px;
+ font-weight: bold;
+ font-family: myFont, "Times New Roman", serif;
+ }
+
+ .host-badge {
+ display: inline-block;
+ margin-left: 8px;
+ padding: 2px 10px;
+ background-color: rgba(255, 215, 0, 0.3);
+ color: #ffd700;
+ font-size: 10px;
+ border-radius: 100px;
+ font-family: myFont, "Times New Roman", serif;
+ }
+ }
+
+ .player-status {
+ font-size: 14px;
+ font-family: myFont, "Times New Roman", serif;
+
+ &.ready {
+ color: #6bff6b;
+ }
+
+ &.waiting {
+ color: #888;
+ }
+ }
+ }
+
+ .ready-btn {
+ .multiplayerBtn();
+ width: 200px;
+ margin: 0 auto;
+
+ &.ready {
+ background-color: #2d5a2d;
+ }
+ }
+
+ .game-countdown {
+ display: inline-block;
+ margin-top: 16px;
+ padding: 12px 28px;
+ background-color: rgba(255, 152, 0, 0.2);
+ color: #ff9800;
+ font-size: 18px;
+ font-weight: bold;
+ font-family: myFont, "Times New Roman", serif;
+ border-radius: 100px;
+ animation: countdownPulse 1s ease-in-out infinite;
+ }
+
+ .waiting-hint {
+ margin-top: 16px;
+ color: #888;
+ font-size: 14px;
+ font-family: myFont, "Times New Roman", serif;
+
+ &.error {
+ color: #ff6b6b;
+ }
+ }
+}
+
+@keyframes countdownPulse {
+ 0%, 100% { transform: scale(1); }
+ 50% { transform: scale(1.02); }
+}
+
// 公告面板
.notePanel {
position: fixed;
@@ -1421,3 +2219,120 @@ button {
}
}
}
+
+/* Game End Modal Styles */
+.game-end-modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ background: rgba(0, 0, 0, 0.85);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 10000;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+
+ &.show {
+ opacity: 1;
+ }
+}
+
+.game-end-content {
+ background: linear-gradient(135deg, #2d2d2d 0%, #1e1e1e 100%);
+ border-radius: 20px;
+ padding: 50px 60px;
+ text-align: center;
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
+ border: 2px solid rgba(74, 158, 255, 0.3);
+ transform: scale(0.8);
+ animation: modalAppear 0.3s ease forwards;
+}
+
+@keyframes modalAppear {
+ to {
+ transform: scale(1);
+ }
+}
+
+.game-end-icon {
+ font-size: 80px;
+ margin-bottom: 20px;
+ animation: iconBounce 0.6s ease;
+
+ &.victory {
+ filter: drop-shadow(0 0 20px rgba(255, 215, 0, 0.6));
+ }
+
+ &.defeat {
+ filter: drop-shadow(0 0 20px rgba(255, 69, 58, 0.6));
+ }
+}
+
+@keyframes iconBounce {
+ 0%, 100% { transform: scale(1); }
+ 50% { transform: scale(1.2); }
+}
+
+.game-end-title {
+ font-size: 48px;
+ font-weight: bold;
+ margin-bottom: 15px;
+ background: linear-gradient(45deg, #4a9eff, #00d4ff);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ font-family: "Microsoft YaHei", sans-serif;
+}
+
+.game-end-message {
+ font-size: 20px;
+ color: #ccc;
+ margin-bottom: 40px;
+ font-family: "Microsoft YaHei", sans-serif;
+}
+
+.game-end-buttons {
+ display: flex;
+ gap: 20px;
+ justify-content: center;
+}
+
+.game-end-btn {
+ padding: 15px 40px;
+ font-size: 18px;
+ border: none;
+ border-radius: 10px;
+ cursor: pointer;
+ font-family: "Microsoft YaHei", sans-serif;
+ transition: all 0.3s ease;
+ font-weight: bold;
+
+ &.primary {
+ background: linear-gradient(135deg, #4a9eff, #00d4ff);
+ color: white;
+ box-shadow: 0 4px 15px rgba(74, 158, 255, 0.4);
+
+ &:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(74, 158, 255, 0.6);
+ }
+ }
+
+ &.secondary {
+ background: rgba(255, 255, 255, 0.1);
+ color: #4a9eff;
+ border: 2px solid #4a9eff;
+
+ &:hover {
+ background: rgba(74, 158, 255, 0.2);
+ transform: translateY(-2px);
+ }
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+}
diff --git a/src/systems/energy/energy.ts b/src/systems/energy/energy.ts
index fcce521..bdf44da 100644
--- a/src/systems/energy/energy.ts
+++ b/src/systems/energy/energy.ts
@@ -1,21 +1,48 @@
/**
* Energy - Energy system core management
* Handles energy production/consumption calculation, penalty logic
+ *
+ * In multiplayer mode, each player has their own Energy instance with a playerId.
+ * The instance only counts mines/towers owned by that player.
+ * In single-player mode, playerId is null and all entities are counted.
*/
import { scalePeriod } from '../../core/speedScale';
+interface MineLike {
+ getEnergyProduction: () => number;
+ ownerId?: string | null;
+}
+
+interface TowerLike {
+ inValidTerritory: boolean;
+ getTowerLevel: () => number;
+ ownerId?: string | null;
+}
+
+interface BuildingLike {
+ otherHpAddAble?: boolean;
+ inValidTerritory: boolean;
+ ownerId?: string | null;
+}
+
interface WorldLike {
time: number;
- user: { money: number };
cheatMode: { enabled: boolean; disableEnergy: boolean };
- mines: Set<{ getEnergyProduction: () => number }>;
- batterys: Array<{ inValidTerritory: boolean; getTowerLevel: () => number }>;
- buildings: Array<{ otherHpAddAble?: boolean; inValidTerritory: boolean }>;
+ mines: Set;
+ batterys: TowerLike[];
+ buildings: BuildingLike[];
+ // Money management (multiplayer compatible)
+ addMoneyToOwner(ownerId: string | null, amount: number): void;
+ spendMoneyFromOwner(ownerId: string | null, amount: number, force?: boolean): boolean;
}
export class Energy {
world: WorldLike;
+
+ // Player ID for multiplayer (null = single-player mode, counts all entities)
+ playerId: string | null = null;
+
PENALTY_INTERVAL: number = scalePeriod(120); // Penalty interval (ticks)
PENALTY_COST: number = 1; // Money cost per energy deficit unit
ROOT_PRODUCTION: number = 6; // Headquarters fixed production
@@ -27,8 +54,9 @@ export class Energy {
private _productionDirty: boolean = true;
private _consumptionDirty: boolean = true;
- constructor(world: WorldLike) {
+ constructor(world: WorldLike, playerId?: string) {
this.world = world;
+ this.playerId = playerId ?? null;
}
/**
@@ -41,12 +69,19 @@ export class Energy {
/**
* Calculate total energy production
+ * In multiplayer mode, only counts mines owned by this player
*/
getTotalProduction(): number {
if (this._productionDirty) {
let total = this.ROOT_PRODUCTION; // Headquarters fixed 6 units
for (const mine of this.world.mines) {
- total += mine.getEnergyProduction();
+ // Multiplayer: only count mines owned by this player
+ if (this.playerId !== null && mine.ownerId !== this.playerId) continue;
+ const prod = mine.getEnergyProduction();
+ // Guard against NaN
+ if (!Number.isNaN(prod)) {
+ total += prod;
+ }
}
this._productionCache = total;
this._productionDirty = false;
@@ -56,18 +91,27 @@ export class Energy {
/**
* Calculate total energy consumption
+ * In multiplayer mode, only counts towers/buildings owned by this player
*/
getTotalConsumption(): number {
if (this._consumptionDirty) {
let total = 0;
// Tower consumption
for (const t of this.world.batterys) {
+ // Multiplayer: only count towers owned by this player
+ if (this.playerId !== null && t.ownerId !== this.playerId) continue;
if (t.inValidTerritory) {
- total += 0.5 * t.getTowerLevel();
+ const level = t.getTowerLevel();
+ // Guard against NaN
+ if (!Number.isNaN(level)) {
+ total += 0.5 * level;
+ }
}
}
// Repair tower consumption
for (const b of this.world.buildings) {
+ // Multiplayer: only count buildings owned by this player
+ if (this.playerId !== null && b.ownerId !== this.playerId) continue;
if (b.otherHpAddAble && b.inValidTerritory) {
total += 0.5;
}
@@ -90,6 +134,10 @@ export class Energy {
* Returns 1 if no consumption or surplus, returns ratio if deficit
*/
getSatisfactionRatio(): number {
+ // Return 1 (no penalty) when energy system is disabled
+ if (this.world.cheatMode.enabled && this.world.cheatMode.disableEnergy) {
+ return 1;
+ }
const production = this.getTotalProduction();
const consumption = this.getTotalConsumption();
if (consumption <= 0) return 1;
@@ -114,7 +162,7 @@ export class Energy {
goTick(): void {
// Mark cache as dirty at the start of each tick
this.markDirty();
-
+
// Disable energy penalty in cheat mode
if (this.world.cheatMode.enabled && this.world.cheatMode.disableEnergy) {
return;
@@ -122,16 +170,17 @@ export class Energy {
const balance = this.getBalance();
if (balance < 0 && this.world.time % this.PENALTY_INTERVAL === 0) {
- if (this.world.user.money >= this.PENALTY_COST) {
- this.world.user.money -= this.PENALTY_COST;
- } else {
- this.world.user.money = 0;
- }
+ // Use playerId to penalize the correct player
+ this.world.spendMoneyFromOwner(this.playerId, this.PENALTY_COST, true);
}
-
+
// Energy surplus bonus: +1 gold per surplus energy every BONUS_INTERVAL ticks
if (balance > 0 && this.world.time % this.BONUS_INTERVAL === 0) {
- this.world.user.money += balance;
+ // Guard against NaN to prevent money corruption
+ if (!Number.isNaN(balance)) {
+ // Use playerId to reward the correct player
+ this.world.addMoneyToOwner(this.playerId, balance);
+ }
}
}
}
diff --git a/src/systems/energy/index.ts b/src/systems/energy/index.ts
index 8fa883c..3ed620f 100644
--- a/src/systems/energy/index.ts
+++ b/src/systems/energy/index.ts
@@ -3,5 +3,6 @@
*/
export { Energy } from './energy';
+export { MultiPlayerEnergy } from './multiPlayerEnergy';
export { EnergyRenderer } from './energyRenderer';
export { Mine } from './mine';
diff --git a/src/systems/energy/mine.ts b/src/systems/energy/mine.ts
index 32f65d0..ec9fcca 100644
--- a/src/systems/energy/mine.ts
+++ b/src/systems/energy/mine.ts
@@ -9,14 +9,16 @@ import { CircleObject } from '../../entities/base/circleObject';
import { EffectCircle } from '../../effects/effectCircle';
interface WorldLike {
+ width: number;
+ height: number;
buildings: unknown[];
mines: Set;
- territory?: {
+ territory?: {
markDirty: () => void;
addBuildingIncremental: (building: unknown) => void;
};
addEffect: (effect: unknown) => void;
- user: { money: number };
+ markBuildingQuadTreeDirty?(): void;
}
export class Mine extends CircleObject {
@@ -28,6 +30,9 @@ export class Mine extends CircleObject {
// Upgrade prices for each level (level 1, 2, 3)
static UPGRADE_PRICES: readonly number[] = [150, 200, 250];
+ // Override world type for Mine-specific interface
+ declare world: WorldLike;
+
gameType: string = "Mine";
name: string = "矿井";
state: string = Mine.STATE_NORMAL;
@@ -99,7 +104,7 @@ export class Mine extends CircleObject {
/**
* Upgrade to power plant / upgrade power plant level
*/
- upgrade(): void {
+ upgrade(playerId?: string): void {
const hpValues = [3000, 5000, 10000];
if (this.state === Mine.STATE_NORMAL) {
@@ -109,6 +114,10 @@ export class Mine extends CircleObject {
this.maxHp = hpValues[0];
this.hp = this.maxHp;
this._updateRadius();
+ // Set owner ID for multiplayer
+ if (playerId) {
+ this.ownerId = playerId;
+ }
// Add to buildings set so monsters can attack
(this.world as any).buildings.push(this);
// Immediate territory update (no 100ms delay)
@@ -143,9 +152,7 @@ export class Mine extends CircleObject {
buildings.splice(idx, 1);
}
// Mark building quadtree as dirty so spatial queries are updated
- if ((this.world as any)._spatialSystem) {
- (this.world as any)._spatialSystem.markBuildingQuadTreeDirty();
- }
+ this.world.markBuildingQuadTreeDirty?.();
this.state = Mine.STATE_NORMAL;
this.powerPlantLevel = 0;
this.hp = 0;
@@ -197,9 +204,7 @@ export class Mine extends CircleObject {
buildings.splice(idx, 1);
}
// Mark building quadtree as dirty so spatial queries are updated
- if ((this.world as any)._spatialSystem) {
- (this.world as any)._spatialSystem.markBuildingQuadTreeDirty();
- }
+ this.world.markBuildingQuadTreeDirty?.();
}
// Downgrade to damaged mine
@@ -220,11 +225,8 @@ export class Mine extends CircleObject {
*/
startRepair(): void {
if (this.state === Mine.STATE_DAMAGED && !this.repairing) {
- if ((this.world as any).user.money >= this.REPAIR_COST) {
- (this.world as any).user.money -= this.REPAIR_COST;
- this.repairing = true;
- this.repairProgress = 0;
- }
+ this.repairing = true;
+ this.repairProgress = 0;
}
}
diff --git a/src/systems/energy/multiPlayerEnergy.ts b/src/systems/energy/multiPlayerEnergy.ts
new file mode 100644
index 0000000..307e553
--- /dev/null
+++ b/src/systems/energy/multiPlayerEnergy.ts
@@ -0,0 +1,67 @@
+/**
+ * MultiPlayerEnergy - Manages multiple Energy instances for multiplayer mode
+ *
+ * In multiplayer mode, each player has their own Energy instance that tracks
+ * only their owned mines, towers, and buildings.
+ */
+
+import { Energy } from './energy';
+
+interface WorldLike {
+ time: number;
+ cheatMode: { enabled: boolean; disableEnergy: boolean };
+ mines: Set<{ getEnergyProduction: () => number; ownerId?: string | null }>;
+ batterys: Array<{ inValidTerritory: boolean; getTowerLevel: () => number; ownerId?: string | null }>;
+ buildings: Array<{ otherHpAddAble?: boolean; inValidTerritory: boolean; ownerId?: string | null }>;
+ getMoney(): number;
+ addMoney(amount: number): void;
+ spendMoney(amount: number, force?: boolean): boolean;
+ addMoneyToOwner(ownerId: string | null, amount: number): void;
+ spendMoneyFromOwner(ownerId: string | null, amount: number, force?: boolean): boolean;
+}
+
+export class MultiPlayerEnergy {
+ private _energyByPlayer: Map = new Map();
+ private _world: WorldLike;
+
+ constructor(world: WorldLike, playerIds: string[]) {
+ this._world = world;
+ for (const playerId of playerIds) {
+ this._energyByPlayer.set(playerId, new Energy(world, playerId));
+ }
+ }
+
+ /**
+ * Get Energy instance for a specific player
+ */
+ getEnergy(playerId: string): Energy | undefined {
+ return this._energyByPlayer.get(playerId);
+ }
+
+ /**
+ * Get all Energy instances
+ */
+ getAllEnergies(): Energy[] {
+ return Array.from(this._energyByPlayer.values());
+ }
+
+ /**
+ * Mark all energy instances as dirty
+ */
+ markDirty(): void {
+ for (const energy of this._energyByPlayer.values()) {
+ energy.markDirty();
+ }
+ }
+
+ /**
+ * Update all energy instances each tick
+ * Note: In multiplayer, each player's penalties/bonuses should use their own money
+ * For now, we'll need World to provide player-specific money methods
+ */
+ goTick(): void {
+ for (const energy of this._energyByPlayer.values()) {
+ energy.goTick();
+ }
+ }
+}
diff --git a/src/systems/fog/fogOfWar.ts b/src/systems/fog/fogOfWar.ts
index 0edb13e..73946e0 100644
--- a/src/systems/fog/fogOfWar.ts
+++ b/src/systems/fog/fogOfWar.ts
@@ -7,22 +7,25 @@ import { VisionSource, RadarSweepArea, VISION_CONFIG, VisionType } from './visio
import { FogRenderer } from './fogRenderer';
// Interface definitions (for decoupling)
-interface TowerLike {
+// Exported for NetworkFogProxy to implement
+export interface TowerLike {
pos: { x: number; y: number };
inValidTerritory: boolean;
visionType: VisionType;
visionLevel: number;
radarAngle: number;
getVisionRadius(): number;
+ // Owner ID for multiplayer (null = neutral/single-player)
+ ownerId?: string | null;
}
-interface WorldLike {
+export interface WorldLike {
width: number;
height: number;
viewWidth: number;
viewHeight: number;
camera: { x: number; y: number; zoom: number; viewWidth: number; viewHeight: number };
- rootBuilding: { pos: { x: number; y: number } };
+ getBaseBuilding(playerId?: string): { pos: { x: number; y: number } };
batterys: TowerLike[];
territory?: {
validBuildings: Set;
@@ -35,6 +38,9 @@ export class FogOfWar {
renderer: FogRenderer;
enabled: boolean = true;
+ // Player ID for multiplayer (null = single-player mode, includes all entities)
+ playerId: string | null = null;
+
// Vision source cache
private _visionSourcesDirty: boolean = true;
private _staticVisionSources: VisionSource[] = [];
@@ -60,8 +66,9 @@ export class FogOfWar {
private _lastRadarAngles: WeakMap = new WeakMap();
private _lastRadarCount: number = 0;
- constructor(world: WorldLike) {
+ constructor(world: WorldLike, playerId?: string) {
this.world = world;
+ this.playerId = playerId ?? null;
this.renderer = new FogRenderer(this);
this._initVisibilityGrid();
}
@@ -299,6 +306,7 @@ export class FogOfWar {
/**
* Get static vision sources (headquarters + towers in valid territory)
+ * In multiplayer mode, only includes towers owned by this player
*/
getStaticVisionSources(): VisionSource[] {
if (!this._visionSourcesDirty) {
@@ -309,9 +317,10 @@ export class FogOfWar {
this._visionSourcesDirty = false;
// Headquarters always provides vision
+ const baseBuilding = this.world.getBaseBuilding(this.playerId ?? undefined);
this._staticVisionSources.push({
- x: this.world.rootBuilding.pos.x,
- y: this.world.rootBuilding.pos.y,
+ x: baseBuilding.pos.x,
+ y: baseBuilding.pos.y,
radius: VISION_CONFIG.headquarters,
type: 'static'
});
@@ -319,6 +328,8 @@ export class FogOfWar {
// Only towers in valid territory provide vision
for (const tower of this.world.batterys) {
if (!tower.inValidTerritory) continue;
+ // Multiplayer: only include towers owned by this player
+ if (this.playerId !== null && tower.ownerId !== this.playerId) continue;
// Radar tower: basic 120px + 5*level (125, 130, 135, 140, 145)
// Other towers: use getVisionRadius()
@@ -352,6 +363,8 @@ export class FogOfWar {
private _collectRadarTowers(): TowerLike[] {
const radarTowers: TowerLike[] = [];
for (const t of this.world.batterys) {
+ // Multiplayer: only include towers owned by this player
+ if (this.playerId !== null && t.ownerId !== this.playerId) continue;
if (t.inValidTerritory && t.visionType === VisionType.RADAR && t.visionLevel > 0) {
radarTowers.push(t);
}
diff --git a/src/systems/fog/fogRenderer.ts b/src/systems/fog/fogRenderer.ts
index 8e8266c..6a344c9 100644
--- a/src/systems/fog/fogRenderer.ts
+++ b/src/systems/fog/fogRenderer.ts
@@ -298,6 +298,9 @@ export class FogRenderer {
this._lastZoom = camera.zoom;
this._staticCacheValid = true;
+ // Static cache rebuilt with new buffer coordinates, composite frame must also update
+ this._dynamicDirty = true;
+
// Clear old radar data (buffer rebuild invalidates coordinates)
this._lastRadarAreas = [];
}
@@ -483,6 +486,8 @@ export class FogRenderer {
for (const tower of towers) {
if (!tower.inValidTerritory) continue;
if (tower.visionType !== VisionType.RADAR || tower.visionLevel <= 0) continue;
+ // Filter by player - only render scan lines for own towers
+ if (this._fog.playerId !== null && tower.ownerId !== this._fog.playerId) continue;
const radius = tower.getVisionRadius();
diff --git a/src/systems/fog/index.ts b/src/systems/fog/index.ts
index 91690e6..fa77c68 100644
--- a/src/systems/fog/index.ts
+++ b/src/systems/fog/index.ts
@@ -4,4 +4,6 @@
export * from './visionConfig';
export { FogOfWar } from './fogOfWar';
+export type { TowerLike as FogTowerLike, WorldLike as FogWorldLike } from './fogOfWar';
+export { MultiPlayerFogOfWar } from './multiPlayerFogOfWar';
export { FogRenderer } from './fogRenderer';
diff --git a/src/systems/fog/multiPlayerFogOfWar.ts b/src/systems/fog/multiPlayerFogOfWar.ts
new file mode 100644
index 0000000..8ccd5c2
--- /dev/null
+++ b/src/systems/fog/multiPlayerFogOfWar.ts
@@ -0,0 +1,78 @@
+/**
+ * MultiPlayerFogOfWar - Manages multiple FogOfWar instances for multiplayer mode
+ *
+ * In multiplayer mode, each player has their own FogOfWar instance that tracks
+ * only their owned towers for vision. For performance optimization, only the
+ * local player's fog is updated each frame.
+ */
+
+import { FogOfWar } from './fogOfWar';
+
+interface WorldLike {
+ width: number;
+ height: number;
+ viewWidth: number;
+ viewHeight: number;
+ camera: { x: number; y: number; zoom: number; viewWidth: number; viewHeight: number };
+ getBaseBuilding(playerId?: string): { pos: { x: number; y: number } };
+ batterys: Array<{
+ pos: { x: number; y: number };
+ inValidTerritory: boolean;
+ ownerId?: string | null;
+ }>;
+ territory?: {
+ validBuildings: Set;
+ recalculate(): void;
+ };
+}
+
+export class MultiPlayerFogOfWar {
+ private _fogByPlayer: Map = new Map();
+ private _localPlayerId: string;
+ private _world: WorldLike;
+
+ constructor(world: WorldLike, playerIds: string[], localPlayerId: string) {
+ this._world = world;
+ this._localPlayerId = localPlayerId;
+
+ // Only create FogOfWar for the local player - non-local instances are never used
+ this._fogByPlayer.set(localPlayerId, new FogOfWar(world as any, localPlayerId));
+ }
+
+ /**
+ * Get FogOfWar instance for the local player
+ */
+ getLocalFog(): FogOfWar | undefined {
+ return this._fogByPlayer.get(this._localPlayerId);
+ }
+
+ /**
+ * Get FogOfWar instance for a specific player
+ */
+ getFog(playerId: string): FogOfWar | undefined {
+ return this._fogByPlayer.get(playerId);
+ }
+
+ /**
+ * Get all FogOfWar instances
+ */
+ getAllFogs(): FogOfWar[] {
+ return Array.from(this._fogByPlayer.values());
+ }
+
+ /**
+ * Update fog of war - only updates the local player's fog for performance
+ */
+ update(): void {
+ this.getLocalFog()?.update();
+ }
+
+ /**
+ * Mark all fog instances as dirty
+ */
+ markDirty(): void {
+ for (const fog of this._fogByPlayer.values()) {
+ fog.markDirty();
+ }
+ }
+}
diff --git a/src/systems/fog/visionConfig.ts b/src/systems/fog/visionConfig.ts
index e5dd245..21fbfcb 100644
--- a/src/systems/fog/visionConfig.ts
+++ b/src/systems/fog/visionConfig.ts
@@ -1,61 +1,67 @@
/**
* Vision Configuration for Fog of War System
- * Defines vision types, sources, and configuration constants
+ * Shared constants are imported from shared/config/visionMeta.ts.
+ * This file retains client-only rendering params and re-exports shared types.
*/
-export enum VisionType {
- NONE = 'none', // No special vision (use basic 120px)
- OBSERVER = 'observer', // Observer tower
- RADAR = 'radar' // Radar tower
-}
+// Re-export shared types and constants for backward compatibility
+export { VisionType } from '../../../shared/config/visionMeta.js';
+export type { VisionSource, RadarSweepArea };
+
+import {
+ HEADQUARTERS_VISION, BASIC_TOWER_VISION,
+ OBSERVER_RADIUS, OBSERVER_PRICE,
+ RADAR_RADIUS, RADAR_PRICE,
+ RADAR_SWEEP_ANGLE, RADAR_SWEEP_SPEED,
+} from '../../../shared/config/visionMeta.js';
-export interface VisionSource {
+interface VisionSource {
x: number;
y: number;
radius: number;
- type: 'static' | 'radar'; // Static vision or radar sweep
+ type: 'static' | 'radar';
}
-export interface RadarSweepArea {
+interface RadarSweepArea {
x: number;
y: number;
radius: number;
startAngle: number;
endAngle: number;
- alpha: number; // Opacity for trail gradient
+ alpha: number;
}
export const VISION_CONFIG = {
- // Basic vision radius
- headquarters: 200, // Headquarters vision
- basicTower: 120, // Tower basic vision
+ // Basic vision radius (from shared)
+ headquarters: HEADQUARTERS_VISION,
+ basicTower: BASIC_TOWER_VISION,
- // Observer tower config
+ // Observer tower config (from shared)
observer: {
- radius: { 1: 180, 2: 250, 3: 350 } as Record,
- price: { 1: 60, 2: 120, 3: 180 } as Record // Cumulative: 60, 180, 360
+ radius: OBSERVER_RADIUS,
+ price: OBSERVER_PRICE,
},
- // Radar tower config
+ // Radar tower config (from shared)
radar: {
- radius: { 1: 300, 2: 550, 3: 800, 4: 1050, 5: 1300 } as Record,
- price: { 1: 100, 2: 150, 3: 200, 4: 250, 5: 300 } as Record, // Cumulative: 100, 250, 450, 700, 1000
- sweepAngle: Math.PI / 6, // 30 degree sweep width
- sweepSpeed: 0.03, // Rotation speed (rad/frame) - 1.5x faster
- revealDuration: 180, // Temp reveal duration in frames (~3s @60fps)
- tailSegments: 6 // Trail segments (base value, adjusted by radar count)
+ radius: RADAR_RADIUS,
+ price: RADAR_PRICE,
+ sweepAngle: RADAR_SWEEP_ANGLE,
+ sweepSpeed: RADAR_SWEEP_SPEED,
+ revealDuration: 180,
+ tailSegments: 6,
},
- // Fog appearance
- fogColor: { r: 40, g: 40, b: 45, a: 1.0 }, // Dark gray fog (100% opacity - fully opaque)
- edgeGradientRatio: 0.15, // Edge gradient as ratio of radar radius (15%, 0% -> 100% fog)
- outerGradientSize: 80, // Outer gradient width for static vision (extends beyond visible radius)
- innerEdgeFogOpacity: 0.25, // Fog opacity at gradient inner edge (25%)
+ // Client-only: fog appearance
+ fogColor: { r: 40, g: 40, b: 45, a: 1.0 },
+ edgeGradientRatio: 0.15,
+ outerGradientSize: 80,
+ innerEdgeFogOpacity: 0.25,
- // Performance: visibility grid
- visibilityGridCellSize: 25, // Grid cell size (px) - smaller = better edge precision
+ // Client-only: performance
+ visibilityGridCellSize: 25,
- // Vision fade timing
- visionFadeDelay: 30, // Frames before vision starts fading (~0.5s)
- visionFadeDuration: 60 // Fade duration in frames (~1s)
+ // Client-only: vision fade timing
+ visionFadeDelay: 30,
+ visionFadeDuration: 60,
};
diff --git a/src/systems/save/saveManager.ts b/src/systems/save/saveManager.ts
index b6e007e..8ca28c0 100644
--- a/src/systems/save/saveManager.ts
+++ b/src/systems/save/saveManager.ts
@@ -163,19 +163,22 @@ interface WorldLike {
width: number;
height: number;
gameSpeed: number;
- user: { money: number };
monsterFlow: { level: number; delayTick: number; state: string };
monsterFlowNext?: unknown;
camera: { x: number; y: number; zoom: number; clampPosition: () => void };
obstacles: Obstacle[];
cheatMode: { enabled: boolean; priceMultiplier: number; infiniteHp: boolean; disableEnergy: boolean };
- rootBuilding: { pos: Vector; hp: number; maxHp: number };
+ getBaseBuilding(): { pos: Vector; hp: number; maxHp: number };
batterys: unknown[];
buildings: unknown[];
monsters: Set;
mines: Set;
territory?: { markDirty: () => void; recalculate: () => void };
+ fog?: { markDirty: () => void };
markStaticLayerDirty: () => void;
+ // Money management (multiplayer compatible)
+ getMoney(): number;
+ setMoney(amount: number): void;
}
interface MonsterGroupClassLike {
@@ -259,6 +262,26 @@ export class SaveManager {
return null;
}
+ /**
+ * Find creator function name by matching building properties
+ */
+ static findBuildingCreatorName(building: unknown, world: WorldLike): string | null {
+ const buildingObj = building as { name: string };
+ const names = BuildingRegistry.getNames();
+ for (const name of names) {
+ // Skip Root as it's handled separately
+ if (name === 'Root') continue;
+ const creator = BuildingRegistry.getCreator(name);
+ if (creator) {
+ const sample = creator(world as any) as { name: string };
+ if (sample.name === buildingObj.name) {
+ return name;
+ }
+ }
+ }
+ return null;
+ }
+
/**
* Serialize world state to save data object
*/
@@ -271,7 +294,7 @@ export class SaveManager {
world: {
time: world.time,
- money: world.user.money,
+ money: world.getMoney(),
flowLevel: world.monsterFlow.level,
flowDelayTick: world.monsterFlow.delayTick,
flowState: world.monsterFlow.state,
@@ -296,10 +319,10 @@ export class SaveManager {
},
rootBuilding: {
- x: world.rootBuilding.pos.x,
- y: world.rootBuilding.pos.y,
- hp: world.rootBuilding.hp,
- maxHp: world.rootBuilding.maxHp,
+ x: world.getBaseBuilding().pos.x,
+ y: world.getBaseBuilding().pos.y,
+ hp: world.getBaseBuilding().hp,
+ maxHp: world.getBaseBuilding().maxHp,
},
towers: [],
@@ -364,12 +387,12 @@ export class SaveManager {
// Serialize buildings (excluding rootBuilding and Mine)
for (const building of world.buildings) {
const b = building as any;
- if (building === world.rootBuilding) continue;
+ if (building === world.getBaseBuilding()) continue;
if (b.gameType === "Mine") continue;
const buildingData: BuildingData = {
type: "Building",
- creatorFunc: b.name === "金矿" ? "Collector" : "Treatment",
+ creatorFunc: this.findBuildingCreatorName(building, world) || "Treatment",
x: b.pos.x,
y: b.pos.y,
hp: b.hp,
@@ -465,7 +488,7 @@ export class SaveManager {
// Restore world basic properties
world.time = saveData.world.time;
- world.user.money = saveData.world.money;
+ world.setMoney(saveData.world.money);
world.mode = saveData.mode;
world.haveFlow = saveData.haveFlow;
@@ -475,12 +498,13 @@ export class SaveManager {
// Restore rootBuilding position and HP
// Position must be restored to avoid overlapping with other buildings
+ const baseBuilding = world.getBaseBuilding();
if (saveData.rootBuilding.x !== undefined && saveData.rootBuilding.y !== undefined) {
- world.rootBuilding.pos.x = saveData.rootBuilding.x;
- world.rootBuilding.pos.y = saveData.rootBuilding.y;
+ baseBuilding.pos.x = saveData.rootBuilding.x;
+ baseBuilding.pos.y = saveData.rootBuilding.y;
}
- world.rootBuilding.hp = saveData.rootBuilding.hp;
- world.rootBuilding.maxHp = saveData.rootBuilding.maxHp;
+ baseBuilding.hp = saveData.rootBuilding.hp;
+ baseBuilding.maxHp = saveData.rootBuilding.maxHp;
// Restore wave state
if (MonsterGroupClass) {
@@ -516,7 +540,7 @@ export class SaveManager {
// Clear existing entities (except rootBuilding)
world.batterys = [];
- world.buildings = [world.rootBuilding];
+ world.buildings = [world.getBaseBuilding()];
world.monsters = new Set();
world.mines = new Set();
@@ -608,8 +632,9 @@ export class SaveManager {
monster.speedFreezeNumb = monsterData.speedFreezeNumb;
monster.burnRate = monsterData.burnRate;
monster.colishDamage = monsterData.colishDamage;
- // Create a copy, not a reference! Otherwise monster AI will modify rootBuilding.pos
- monster.destination = new Vector(world.rootBuilding.pos.x, world.rootBuilding.pos.y);
+ // Create a copy, not a reference! Otherwise monster AI will modify baseBuilding.pos
+ const baseBuildingPos = world.getBaseBuilding().pos;
+ monster.destination = new Vector(baseBuildingPos.x, baseBuildingPos.y);
if (monsterData.laserDefendNum !== undefined) {
monster.laserDefendNum = monsterData.laserDefendNum;
@@ -677,8 +702,8 @@ export class SaveManager {
}
// Rebuild fog vision cache after loading
- if ((world as any).fog) {
- (world as any).fog.markDirty();
+ if (world.fog) {
+ world.fog.markDirty();
}
// Mark static layer dirty to rebuild building render cache
diff --git a/src/systems/territory/index.ts b/src/systems/territory/index.ts
index 1277f62..5e742c3 100644
--- a/src/systems/territory/index.ts
+++ b/src/systems/territory/index.ts
@@ -3,4 +3,5 @@
*/
export { Territory } from './territory';
+export { MultiPlayerTerritory } from './multiPlayerTerritory';
export { TerritoryRenderer } from './territoryRenderer';
diff --git a/src/systems/territory/multiPlayerTerritory.ts b/src/systems/territory/multiPlayerTerritory.ts
new file mode 100644
index 0000000..c06af6e
--- /dev/null
+++ b/src/systems/territory/multiPlayerTerritory.ts
@@ -0,0 +1,120 @@
+/**
+ * MultiPlayerTerritory - Manages multiple Territory instances for multiplayer mode
+ *
+ * In multiplayer mode, each player has their own Territory instance that tracks
+ * only their owned buildings. This class provides a unified interface to manage
+ * all player territories and calculate build cost multipliers for building in
+ * enemy territory.
+ */
+
+import { Territory } from './territory';
+import { Vector } from '../../core/math/vector';
+import { PVP_CONFIG } from '../../types/player';
+
+interface BuildingLike {
+ pos: Vector;
+ ownerId?: string | null;
+}
+
+interface WorldLike {
+ width: number;
+ height: number;
+ viewWidth: number;
+ viewHeight: number;
+ camera: { x: number; y: number; zoom: number; viewWidth: number; viewHeight: number };
+ getBaseBuilding(playerId?: string): BuildingLike;
+ batterys: BuildingLike[];
+ buildings: BuildingLike[];
+ mines: Set;
+ fog?: { markDirty(): void };
+}
+
+export class MultiPlayerTerritory {
+ private _territoryByPlayer: Map = new Map();
+ private _world: WorldLike;
+
+ constructor(world: WorldLike, playerIds: string[]) {
+ this._world = world;
+ for (const playerId of playerIds) {
+ this._territoryByPlayer.set(playerId, new Territory(world as any, playerId));
+ }
+ }
+
+ /**
+ * Get Territory instance for a specific player
+ */
+ getTerritory(playerId: string): Territory | undefined {
+ return this._territoryByPlayer.get(playerId);
+ }
+
+ /**
+ * Get all Territory instances
+ */
+ getAllTerritories(): Territory[] {
+ return Array.from(this._territoryByPlayer.values());
+ }
+
+ /**
+ * Check if a position is in an enemy's valid territory
+ * Returns true if the position is in any player's valid territory other than the specified player
+ */
+ isPositionInEnemyTerritory(pos: Vector, playerId: string): boolean {
+ for (const [id, territory] of this._territoryByPlayer) {
+ if (id === playerId) continue;
+ if (territory.isPositionInValidTerritory(pos)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Get build cost multiplier for a position
+ * Returns 2x (PVP_CONFIG.territory.enemyBuildCostMultiplier) if in enemy territory, 1x otherwise
+ */
+ getBuildCostMultiplier(pos: Vector, playerId: string): number {
+ if (this.isPositionInEnemyTerritory(pos, playerId)) {
+ return PVP_CONFIG.territory.enemyBuildCostMultiplier;
+ }
+ return 1;
+ }
+
+ /**
+ * Mark all territories as dirty
+ */
+ markDirty(): void {
+ for (const territory of this._territoryByPlayer.values()) {
+ territory.markDirty();
+ }
+ }
+
+ /**
+ * Recalculate all territories
+ */
+ recalculate(): void {
+ for (const territory of this._territoryByPlayer.values()) {
+ territory.recalculate();
+ }
+ }
+
+ /**
+ * Add building incrementally to the appropriate player's territory
+ * Note: The building's ownerId determines which territory it belongs to
+ */
+ addBuildingIncremental(building: BuildingLike): void {
+ if (building.ownerId) {
+ const territory = this._territoryByPlayer.get(building.ownerId);
+ territory?.addBuildingIncremental(building as any);
+ }
+ }
+
+ /**
+ * Remove building incrementally from the appropriate player's territory
+ */
+ removeBuildingIncremental(building: BuildingLike): void {
+ if (building.ownerId) {
+ const territory = this._territoryByPlayer.get(building.ownerId);
+ territory?.removeBuildingIncremental(building as any);
+ }
+ }
+}
diff --git a/src/systems/territory/territory.ts b/src/systems/territory/territory.ts
index c917f55..f53dd2c 100644
--- a/src/systems/territory/territory.ts
+++ b/src/systems/territory/territory.ts
@@ -7,6 +7,7 @@ import { TerritoryRenderer } from './territoryRenderer';
import { Vector } from '../../core/math/vector';
import { QuadTree } from '../../core/physics/quadTree';
import type { FogOfWarLike } from '../../types/systems';
+import { TERRITORY_PENALTY } from '../../../shared/config/index';
interface BuildingLike {
pos: Vector;
@@ -20,6 +21,8 @@ interface BuildingLike {
_territoryPenaltyApplied: boolean;
_originalMaxHp: number | null;
_originalRangeR?: number | null;
+ // Owner ID for multiplayer (null = neutral/single-player)
+ ownerId?: string | null;
}
interface WorldLike {
@@ -28,7 +31,7 @@ interface WorldLike {
viewWidth: number;
viewHeight: number;
camera: { x: number; y: number; zoom: number; viewWidth: number; viewHeight: number };
- rootBuilding: BuildingLike;
+ getBaseBuilding(playerId?: string): BuildingLike;
batterys: BuildingLike[];
buildings: BuildingLike[];
mines: Set;
@@ -37,6 +40,10 @@ interface WorldLike {
export class Territory {
world: WorldLike;
+
+ // Player ID for multiplayer (null = single-player mode, includes all entities)
+ playerId: string | null = null;
+
territoryRadius: number = 100; // Territory radius in px
private _territoryRadiusSq: number = 10000; // territoryRadius² (cached)
dirty: boolean = true; // Dirty flag, needs recalculation
@@ -59,8 +66,9 @@ export class Territory {
// Renderer
renderer: TerritoryRenderer;
- constructor(world: WorldLike) {
+ constructor(world: WorldLike, playerId?: string) {
this.world = world;
+ this.playerId = playerId ?? null;
this.renderer = new TerritoryRenderer(this);
// Perform initial calculation immediately (rootBuilding should be present)
this.recalculate();
@@ -119,9 +127,10 @@ export class Territory {
const visited = new Set();
// Use index-based queue to avoid O(n) shift() operations
- const queue: BuildingLike[] = [this.world.rootBuilding];
+ const baseBuilding = this._getBaseBuilding();
+ const queue: BuildingLike[] = [baseBuilding];
let queueIndex = 0;
- visited.add(this.world.rootBuilding);
+ visited.add(baseBuilding);
// Reusable array for QuadTree queries to avoid GC pressure
const retrieveBuffer: any[] = [];
@@ -292,11 +301,25 @@ export class Territory {
return false;
}
+ /**
+ * Get the base building for this territory
+ * In multiplayer mode, returns the base for this player
+ */
+ private _getBaseBuilding(): BuildingLike {
+ return this.world.getBaseBuilding(this.playerId ?? undefined);
+ }
+
/**
* Get all buildings list (towers + buildings + mines)
+ * In multiplayer mode, only includes buildings owned by this player
*/
_getAllBuildings(): BuildingLike[] {
- return [...this.world.batterys, ...this.world.buildings, ...this.world.mines];
+ const all = [...this.world.batterys, ...this.world.buildings, ...this.world.mines];
+ // Multiplayer: filter by owner
+ if (this.playerId !== null) {
+ return all.filter(b => b.ownerId === this.playerId);
+ }
+ return all;
}
/**
@@ -704,7 +727,7 @@ export class Territory {
*/
_canProvideTerritory(building: BuildingLike): boolean {
// Headquarters can provide territory
- if (building === this.world.rootBuilding) return true;
+ if (building === this._getBaseBuilding()) return true;
// Mines/power plants cannot provide territory (same as gold mines)
if (building.gameType === "Mine") return false;
// Repair towers and gold mines cannot provide territory
@@ -725,7 +748,7 @@ export class Territory {
*/
_applyInvalidPenalty(building: BuildingLike): void {
// Headquarters is never penalized
- if (building === this.world.rootBuilding) return;
+ if (building === this._getBaseBuilding()) return;
// Skip if penalty already applied
if (building._territoryPenaltyApplied) return;
@@ -734,8 +757,8 @@ export class Territory {
// HP halved (all buildings receive this penalty)
building._originalMaxHp = building.maxHp;
- building.maxHp = Math.round(building.maxHp / 2);
- building.hp = Math.round(building.hp / 2);
+ building.maxHp = Math.round(building.maxHp * TERRITORY_PENALTY.HP_MULTIPLIER);
+ building.hp = Math.round(building.hp * TERRITORY_PENALTY.HP_MULTIPLIER);
// Towers: save original range (for display, actual range calculated dynamically via getEffectiveRangeR())
if (building.gameType === 'Tower') {
@@ -748,7 +771,7 @@ export class Territory {
*/
_removeInvalidPenalty(building: BuildingLike): void {
// Headquarters is never penalized
- if (building === this.world.rootBuilding) return;
+ if (building === this._getBaseBuilding()) return;
// Skip if penalty not applied
if (!building._territoryPenaltyApplied) return;
diff --git a/src/towers/base/index.ts b/src/towers/base/index.ts
index 420926c..e51a822 100644
--- a/src/towers/base/index.ts
+++ b/src/towers/base/index.ts
@@ -7,3 +7,4 @@ export { TowerHell } from './towerHell';
export { TowerHammer } from './towerHammer';
export { TowerBoomerang } from './towerBoomerang';
export { TowerRay } from './towerRay';
+export { TowerManualCannon } from './towerManualCannon';
diff --git a/src/towers/base/tower.ts b/src/towers/base/tower.ts
index b7aa704..2ac319a 100644
--- a/src/towers/base/tower.ts
+++ b/src/towers/base/tower.ts
@@ -8,14 +8,41 @@ import { MyColor } from '../../entities/myColor';
import { CircleObject } from '../../entities/base/circleObject';
import { TowerRegistry } from '../towerRegistry';
import { TOWER_IMG_PRE_WIDTH, TOWER_IMG_PRE_HEIGHT, getTowersImg } from '../towerConstants';
+import { renderTower } from '../rendering/towerRenderer';
import { VisionType, VISION_CONFIG } from '@/systems/fog/visionConfig';
+import {
+ getVisionRadius as sharedGetVisionRadius,
+ getVisionUpgradePrice as sharedGetVisionUpgradePrice,
+ canUpgradeVision as sharedCanUpgradeVision,
+} from '../../../shared/config/visionMeta.js';
import { scaleSpeed, scalePeriod } from '../../core/speedScale';
+import { isEnemy } from '@/game/player/ownership';
+import { TERRITORY_PENALTY } from '../../../shared/config/index';
+import type {
+ MonsterLike as BaseMonsterLike,
+ TerritoryLike,
+ FogOfWarLike,
+ UserLike,
+ TowerLike,
+ EnergyLike,
+} from '@/types/worldLike';
// Declare globals for non-migrated modules
declare const BullyFinally: { Normal: () => BulletLike } | undefined;
declare const SoundManager: { play(src: string): void } | undefined;
declare const UP_LEVEL_ICON: HTMLImageElement | undefined;
+// Extended FogOfWarLike with markDirty for tower
+interface FogOfWarLikeExt extends FogOfWarLike {
+ markDirty(): void;
+}
+
+// Extended MonsterLike for tower targeting (pos is Vector)
+interface MonsterLike extends BaseMonsterLike {
+ pos: Vector;
+}
+
+// Tower-specific bullet interface (has tower-specific properties)
interface BulletLike {
originalPos: Vector;
father: Tower;
@@ -30,49 +57,31 @@ interface BulletLike {
split(): void;
outTowerViewRange(): boolean;
remove(): void;
- // 分离的移动和碰撞方法
+ // Two-phase update methods
move(): void;
rChange(): void;
getTarget(): void;
collide(world: WorldLike): void;
+ // Owner ID for multiplayer
+ ownerId?: string | null;
}
-interface MonsterLike {
- pos: Vector;
- getBodyCircle(): Circle;
- hpChange(delta: number): void;
- isDead(): boolean;
-}
-
-interface TerritoryLike {
- markDirty(): void;
- addBuildingIncremental(building: unknown): void;
- removeBuildingIncremental(building: unknown): void;
-}
-
-interface FogOfWarLike {
- enabled: boolean;
- isPositionVisible(x: number, y: number): boolean;
- isCircleVisible(x: number, y: number, radius: number): boolean;
- markDirty(): void;
-}
-
-interface UserLike {
- money: number;
-}
-
+// WorldLike interface for Tower (uses unified sub-interfaces)
interface WorldLike {
width: number;
height: number;
- batterys: Tower[];
+ batterys: TowerLike[];
territory?: TerritoryLike;
- fog?: FogOfWarLike;
+ fog?: FogOfWarLikeExt;
user: UserLike;
- energy: { getSatisfactionRatio(): number };
+ energy: EnergyLike;
getMonstersInRange(x: number, y: number, range: number): MonsterLike[];
addBully(bully: BulletLike): void;
removeBully(bully: BulletLike): void;
addEffect?(effect: unknown): void;
+ getMoney(): number;
+ spendMoney(amount: number): boolean;
+ getEnergyForOwner?(ownerId: string | null): EnergyLike;
}
type AttackFunc = () => void;
@@ -242,7 +251,7 @@ export class Tower extends CircleObject {
}
// Use incremental update instead of markDirty
if (this.world.territory) {
- this.world.territory.removeBuildingIncremental(this as any);
+ this.world.territory.removeBuildingIncremental?.(this as any);
}
super.remove();
}
@@ -269,6 +278,10 @@ export class Tower extends CircleObject {
let nearbyMonsters = this.world.getMonstersInRange(this.pos.x, this.pos.y, this.rangeR + 50);
const viewCircle = this.getViewCircle();
for (let m of nearbyMonsters) {
+ // Filter friendly monsters (same owner)
+ if (!isEnemy(this, m)) {
+ continue;
+ }
// Check fog first (fast rejection), using circle visibility for edge detection
const mc = m.getBodyCircle();
if (this.world.fog?.enabled && !this.world.fog.isCircleVisible(mc.x, mc.y, mc.r)) {
@@ -295,6 +308,10 @@ export class Tower extends CircleObject {
let nearbyMonsters = this.world.getMonstersInRange(this.pos.x, this.pos.y, this.rangeR + 50);
const viewCircle = this.getViewCircle();
for (let m of nearbyMonsters) {
+ // Filter friendly monsters (same owner)
+ if (!isEnemy(this, m)) {
+ continue;
+ }
// Check fog first (fast rejection), using circle visibility for edge detection
const mc = m.getBodyCircle();
if (this.world.fog?.enabled && !this.world.fog.isCircleVisible(mc.x, mc.y, mc.r)) {
@@ -338,6 +355,7 @@ export class Tower extends CircleObject {
res.speed = bDir;
res.slideRate = this.bullySlideRate;
res.damage = res.damage * this.getDamageMultiplier();
+ res.ownerId = this.ownerId;
return res;
}
@@ -364,11 +382,7 @@ export class Tower extends CircleObject {
}
render(ctx: CanvasRenderingContext2D): void {
- if (this.isDead()) {
- return;
- }
- this.renderBody(ctx);
- this.renderBars(ctx);
+ renderTower(this as any, ctx);
}
/**
@@ -450,6 +464,10 @@ export class Tower extends CircleObject {
const viewCircle = this.getViewCircle();
for (const m of nearbyMonsters) {
+ // Filter friendly monsters (same owner)
+ if (!isEnemy(this, m)) {
+ continue;
+ }
const mc = m.getBodyCircle();
// Check fog first (fast rejection)
if (this.world.fog?.enabled && !this.world.fog.isCircleVisible(mc.x, mc.y, mc.r)) {
@@ -463,20 +481,21 @@ export class Tower extends CircleObject {
}
getDamageMultiplier(): number {
- let multiplier = this.inValidTerritory ? 1 : (1 / 3);
- // Apply energy deficit penalty
- const energyRatio = this.world.energy.getSatisfactionRatio();
+ let multiplier = this.inValidTerritory ? 1 : TERRITORY_PENALTY.DAMAGE_MULTIPLIER;
+ // Apply energy deficit penalty (multiplayer: use owner's energy system)
+ const energy = this.world.getEnergyForOwner?.(this.ownerId) ?? this.world.energy;
+ const energyRatio = energy.getSatisfactionRatio();
return multiplier * energyRatio;
}
getEffectiveRangeR(): number {
- return this.inValidTerritory ? this.rangeR : this.rangeR * (2 / 3);
+ return this.inValidTerritory ? this.rangeR : this.rangeR * TERRITORY_PENALTY.RANGE_MULTIPLIER;
}
isUpLevelAble(): boolean {
for (let towerName of this.levelUpArr) {
const meta = TowerRegistry.getMeta(towerName);
- if (meta && this.world.user.money >= meta.basePrice) {
+ if (meta && this.world.getMoney() >= meta.basePrice) {
return true;
}
}
@@ -485,36 +504,15 @@ export class Tower extends CircleObject {
// Vision system methods
getVisionRadius(): number {
- switch (this.visionType) {
- case VisionType.OBSERVER:
- return VISION_CONFIG.observer.radius[this.visionLevel] || VISION_CONFIG.basicTower;
- case VisionType.RADAR:
- if (VISION_CONFIG.radar.radius[this.visionLevel] !== undefined) {
- return VISION_CONFIG.radar.radius[this.visionLevel];
- }
- const maxDefinedRadarLevel = Math.max(...Object.keys(VISION_CONFIG.radar.radius).map(Number));
- return VISION_CONFIG.radar.radius[maxDefinedRadarLevel] || VISION_CONFIG.basicTower;
- default:
- return VISION_CONFIG.basicTower;
- }
+ return sharedGetVisionRadius(this.visionType, this.visionLevel);
}
getVisionUpgradePrice(type: VisionType): number {
- const nextLevel = this.visionType === type ? this.visionLevel + 1 : 1;
- if (type === VisionType.OBSERVER) {
- return VISION_CONFIG.observer.price[nextLevel] || 0;
- } else if (type === VisionType.RADAR) {
- return VISION_CONFIG.radar.price[nextLevel] || 0;
- }
- return 0;
+ return sharedGetVisionUpgradePrice(this.visionType, this.visionLevel, type);
}
canUpgradeVision(type: VisionType): boolean {
- if (this.visionType !== VisionType.NONE && this.visionType !== type) {
- return false; // Already has other type
- }
- const maxLevel = type === VisionType.OBSERVER ? 3 : 5;
- return this.visionLevel < maxLevel;
+ return sharedCanUpgradeVision(this.visionType, this.visionLevel, type);
}
upgradeVision(type: VisionType): boolean {
diff --git a/src/towers/base/towerBoomerang.ts b/src/towers/base/towerBoomerang.ts
index b5ba4e8..5c753cd 100644
--- a/src/towers/base/towerBoomerang.ts
+++ b/src/towers/base/towerBoomerang.ts
@@ -9,33 +9,38 @@ import { Circle } from '../../core/math/circle';
import { MyColor } from '../../entities/myColor';
import { Tower } from './tower';
import { TowerRegistry } from '../towerRegistry';
+import { renderTowerBoomerang } from '../rendering/towerRenderer';
import { scaleSpeed, scalePeriod } from '../../core/speedScale';
-
-interface VectorLike {
- x: number;
- y: number;
-}
-
-interface CircleLike {
- x: number;
- y: number;
- r: number;
+import { isEnemy } from '@/game/player/ownership';
+import type {
+ VectorLike,
+ CircleLike,
+ MonsterLike as BaseMonsterLike,
+ TerritoryLike,
+ FogOfWarLike,
+ UserLike,
+ TowerLike,
+} from '@/types/worldLike';
+
+// Extended CircleLike with impact method
+interface CircleLikeExt extends CircleLike {
impact(other: CircleLike): boolean;
}
-interface MonsterLike {
+// Extended MonsterLike for boomerang tower (uses Vector for pos)
+interface MonsterLike extends BaseMonsterLike {
pos: Vector;
- getBodyCircle(): CircleLike;
- hpChange(delta: number): void;
+ getBodyCircle(): CircleLikeExt;
}
+// WorldLike interface for TowerBoomerang
interface WorldLike {
width: number;
height: number;
- batterys: Tower[];
- territory?: { markDirty(): void };
- fog?: { enabled: boolean; isPositionVisible(x: number, y: number): boolean; isCircleVisible(x: number, y: number, radius: number): boolean };
- user: { money: number };
+ batterys: TowerLike[];
+ territory?: TerritoryLike;
+ fog?: FogOfWarLike;
+ user: UserLike;
getMonstersInRange(x: number, y: number, range: number): MonsterLike[];
addBully(bully: unknown): void;
removeBully(bully: unknown): void;
@@ -119,7 +124,7 @@ export class TowerBoomerang extends Tower {
continue;
}
if (this.bar.intersectWithCircle(mc as any)) {
- m.hpChange(-actualDamage);
+ m.hpChange(-actualDamage, this.ownerId);
}
}
this.barGo();
@@ -155,6 +160,10 @@ export class TowerBoomerang extends Tower {
let nearbyMonsters = this.world.getMonstersInRange(barCenter.x, barCenter.y, barLen);
let actualDamage = this.damage * this.getDamageMultiplier();
for (let m of nearbyMonsters) {
+ // Filter friendly monsters (same owner)
+ if (!isEnemy(this, m)) {
+ continue;
+ }
const mc = m.getBodyCircle();
if (this.world.fog?.enabled && !this.world.fog.isCircleVisible(mc.x, mc.y, mc.r)) {
continue;
@@ -165,18 +174,14 @@ export class TowerBoomerang extends Tower {
continue;
}
if (this.bar.intersectWithCircle(mc as any)) {
- m.hpChange(-actualDamage);
+ m.hpChange(-actualDamage, this.ownerId);
this.hitCooldown.set(m, this.liveTime);
}
}
}
render(ctx: CanvasRenderingContext2D): void {
- if (this.isDead()) {
- return;
- }
- this.renderBody(ctx);
- this.renderBars(ctx);
+ renderTowerBoomerang(this as any, ctx);
}
/**
diff --git a/src/towers/base/towerHammer.ts b/src/towers/base/towerHammer.ts
index 93dbd81..f99f334 100644
--- a/src/towers/base/towerHammer.ts
+++ b/src/towers/base/towerHammer.ts
@@ -10,33 +10,44 @@ import { MyColor } from '../../entities/myColor';
import { CircleObject } from '../../entities/base/circleObject';
import { Tower } from './tower';
import { TowerRegistry } from '../towerRegistry';
+import { renderTowerHammer } from '../rendering/towerRenderer';
import { scalePeriod } from '../../core/speedScale';
-
-interface VectorLike {
- x: number;
- y: number;
+import { isEnemy } from '@/game/player/ownership';
+import type {
+ VectorLike,
+ CircleLike,
+ MonsterLike as BaseMonsterLike,
+ TerritoryLike,
+ FogOfWarLike,
+ UserLike,
+ TowerLike,
+} from '@/types/worldLike';
+
+// Extended VectorLike with dis method
+interface VectorLikeExt extends VectorLike {
dis(other: VectorLike): number;
+ disSq(other: VectorLike): number;
}
-interface CircleLike {
- x: number;
- y: number;
- r: number;
+// Extended CircleLike with impact method
+interface CircleLikeExt extends CircleLike {
impact(other: CircleLike): boolean;
}
-interface MonsterLike {
- pos: VectorLike;
- getBodyCircle(): CircleLike;
- hpChange(delta: number): void;
+// Extended MonsterLike for hammer tower
+interface MonsterLike extends BaseMonsterLike {
+ pos: VectorLikeExt;
+ getBodyCircle(): CircleLikeExt;
}
+// WorldLike interface for TowerHammer
interface WorldLike {
width: number;
height: number;
- batterys: Tower[];
- territory?: { markDirty(): void };
- user: { money: number };
+ batterys: TowerLike[];
+ territory?: TerritoryLike;
+ fog?: FogOfWarLike;
+ user: UserLike;
getMonstersInRange(x: number, y: number, range: number): MonsterLike[];
addBully(bully: unknown): void;
removeBully(bully: unknown): void;
@@ -95,7 +106,7 @@ export class TowerHammer extends Tower {
continue;
}
if (Circle.collides(itemCircle.x, itemCircle.y, itemCircle.r, mc.x, mc.y, mc.r)) {
- m.hpChange(-actualDamage);
+ m.hpChange(-actualDamage, this.ownerId);
}
}
}
@@ -105,6 +116,10 @@ export class TowerHammer extends Tower {
let effectiveRangeSq = effectiveRange * effectiveRange;
let nearbyMonsters = this.world.getMonstersInRange(this.pos.x, this.pos.y, effectiveRange);
for (let m of nearbyMonsters) {
+ // Filter friendly monsters (same owner)
+ if (!isEnemy(this, m)) {
+ continue;
+ }
// Check fog first, using circle visibility for edge detection
const mc = m.getBodyCircle();
if (this.world.fog?.enabled && !this.world.fog.isCircleVisible(mc.x, mc.y, mc.r)) {
@@ -127,11 +142,7 @@ export class TowerHammer extends Tower {
}
render(ctx: CanvasRenderingContext2D): void {
- if (this.isDead()) {
- return;
- }
- this.renderBody(ctx);
- this.renderBars(ctx);
+ renderTowerHammer(this as any, ctx);
}
/**
@@ -172,6 +183,10 @@ export class TowerHammer extends Tower {
let itemCircle = this.additionItem.getBodyCircle();
let actualDamage = this.itemDamage * this.getDamageMultiplier();
for (let m of nearbyMonsters) {
+ // Filter friendly monsters (same owner)
+ if (!isEnemy(this, m)) {
+ continue;
+ }
const mc = m.getBodyCircle();
if (this.world.fog?.enabled && !this.world.fog.isCircleVisible(mc.x, mc.y, mc.r)) {
continue;
@@ -182,7 +197,7 @@ export class TowerHammer extends Tower {
continue;
}
if (Circle.collides(itemCircle.x, itemCircle.y, itemCircle.r, mc.x, mc.y, mc.r)) {
- m.hpChange(-actualDamage);
+ m.hpChange(-actualDamage, this.ownerId);
this.hitCooldown.set(m, this.liveTime);
}
}
diff --git a/src/towers/base/towerHell.ts b/src/towers/base/towerHell.ts
index b1fe5c6..4c1c33d 100644
--- a/src/towers/base/towerHell.ts
+++ b/src/towers/base/towerHell.ts
@@ -7,21 +7,23 @@ import { MyColor } from '../../entities/myColor';
import { Circle } from '../../core/math/circle';
import { Tower } from './tower';
import { TowerRegistry } from '../towerRegistry';
+import type {
+ VectorLike,
+ CircleLike,
+ MonsterLike as BaseMonsterLike,
+ TerritoryLike,
+ FogOfWarLike,
+ UserLike,
+ TowerLike,
+} from '@/types/worldLike';
// Declare globals for non-migrated modules
declare const EffectLine: {
acquire(start: VectorLike, end: VectorLike): EffectLineLike;
} | undefined;
-interface VectorLike {
- x: number;
- y: number;
-}
-
-interface CircleLike {
- x: number;
- y: number;
- r: number;
+// Extended CircleLike with impact method
+interface CircleLikeExt extends CircleLike {
impact(other: CircleLike): boolean;
}
@@ -29,20 +31,19 @@ interface EffectLineLike {
initLineStyle(color: MyColor, width: number): void;
}
-interface MonsterLike {
- pos: VectorLike;
- getBodyCircle(): CircleLike;
- hpChange(delta: number): void;
- isDead(): boolean;
+// Extended MonsterLike for hell tower
+interface MonsterLike extends BaseMonsterLike {
+ getBodyCircle(): CircleLikeExt;
}
+// WorldLike interface for TowerHell
interface WorldLike {
width: number;
height: number;
- batterys: Tower[];
- territory?: { markDirty(): void };
- fog?: { enabled: boolean; isPositionVisible(x: number, y: number): boolean; isCircleVisible(x: number, y: number, radius: number): boolean };
- user: { money: number };
+ batterys: TowerLike[];
+ territory?: TerritoryLike;
+ fog?: FogOfWarLike;
+ user: UserLike;
getMonstersInRange(x: number, y: number, range: number): MonsterLike[];
addBully(bully: unknown): void;
removeBully(bully: unknown): void;
@@ -96,7 +97,7 @@ export class TowerHell extends Tower {
if (this.laserFreezeNow === this.laserFreezeMax) {
let damage = Math.pow(this.targetLiveTime, 2) / this.damageRate;
damage = damage * this.getDamageMultiplier();
- this.target.hpChange(-damage);
+ this.target.hpChange(-damage, this.ownerId);
this.targetLiveTime++;
if (typeof EffectLine !== 'undefined') {
diff --git a/src/towers/base/towerLaser.ts b/src/towers/base/towerLaser.ts
index 2981bad..8931aee 100644
--- a/src/towers/base/towerLaser.ts
+++ b/src/towers/base/towerLaser.ts
@@ -15,7 +15,18 @@ import {
} from '../../entities/statusBar';
import { Tower } from './tower';
import { TowerRegistry } from '../towerRegistry';
+import { renderTowerLaser } from '../rendering/towerRenderer';
import { scalePeriod } from '../../core/speedScale';
+import { isEnemy } from '../../game/player/ownership';
+import type {
+ VectorLike,
+ CircleLike,
+ MonsterLike as BaseMonsterLike,
+ TerritoryLike,
+ FogOfWarLike,
+ UserLike,
+ TowerLike,
+} from '@/types/worldLike';
// Declare globals for non-migrated modules
declare const EffectLine: {
@@ -26,17 +37,14 @@ declare const EffectCircle: {
acquire(pos: VectorLike): EffectCircleLike;
} | undefined;
-interface VectorLike {
- x: number;
- y: number;
- copy(): VectorLike;
- sub(other: VectorLike): VectorLike;
+// Extended VectorLike with copy/sub methods for effect rendering
+interface VectorLikeExt extends VectorLike {
+ copy(): VectorLikeExt;
+ sub(other: VectorLike): VectorLikeExt;
}
-interface CircleLike {
- x: number;
- y: number;
- r: number;
+// Extended CircleLike with impact method
+interface CircleLikeExt extends CircleLike {
impact(other: CircleLike): boolean;
}
@@ -56,20 +64,20 @@ interface EffectCircleLike {
initCircleStyle(fillColor: MyColor, strokeColor: MyColor, strokeWidth: number): void;
}
-interface MonsterLike {
- pos: VectorLike;
- getBodyCircle(): CircleLike;
- hpChange(delta: number): void;
- isDead(): boolean;
+// Extended MonsterLike for laser tower
+interface MonsterLike extends BaseMonsterLike {
+ pos: VectorLikeExt;
+ getBodyCircle(): CircleLikeExt;
}
+// WorldLike interface for TowerLaser
interface WorldLike {
width: number;
height: number;
- batterys: Tower[];
- territory?: { markDirty(): void };
- fog?: { enabled: boolean; isPositionVisible(x: number, y: number): boolean; isCircleVisible(x: number, y: number, radius: number): boolean };
- user: { money: number };
+ batterys: TowerLike[];
+ territory?: TerritoryLike;
+ fog?: FogOfWarLike;
+ user: UserLike;
getMonstersInRange(x: number, y: number, range: number): MonsterLike[];
addBully(bully: unknown): void;
removeBully(bully: unknown): void;
@@ -201,6 +209,11 @@ export class TowerLaser extends Tower {
continue;
}
+ // Skip friendly monsters
+ if (!isEnemy(this, m)) {
+ continue;
+ }
+
// Check fog visibility
if (fogEnabled) {
const mc = m.getBodyCircle();
@@ -218,7 +231,7 @@ export class TowerLaser extends Tower {
// Check collision with circle
const mc = m.getBodyCircle() as Circle;
if (Circle.collides(node.pos.x, node.pos.y, searchRadius, mc.x, mc.y, mc.r)) {
- m.hpChange(-currentDamage);
+ m.hpChange(-currentDamage, this.ownerId);
monsterSet.add(m);
attacked = true;
@@ -270,12 +283,16 @@ export class TowerLaser extends Tower {
let nearbyMonsters = this.world.getMonstersInRange(this.pos.x, this.pos.y, effectiveRange);
let viewCircle = this.getViewCircle();
for (let m of nearbyMonsters) {
+ // Skip friendly monsters
+ if (!isEnemy(this, m)) {
+ continue;
+ }
const mc = m.getBodyCircle() as Circle;
if (Circle.collides(viewCircle.x, viewCircle.y, viewCircle.r, mc.x, mc.y, mc.r)) {
if (this.laserFreezeNow === this.laserFreezeMax) {
let d = this.laserBaseDamage + this.laserDamageAdd;
d = d * this.getDamageMultiplier();
- m.hpChange(-d);
+ m.hpChange(-d, this.ownerId);
isAttacked = true;
}
}
@@ -321,7 +338,7 @@ export class TowerLaser extends Tower {
d = d * this.getDamageMultiplier();
this.laserFreezeNow = 0;
this.laserDamageAdd = 0;
- this.target.hpChange(-d);
+ this.target.hpChange(-d, this.ownerId);
if (typeof EffectLine !== 'undefined') {
let e = EffectLine.acquire(this.pos, this.target.pos);
@@ -334,11 +351,7 @@ export class TowerLaser extends Tower {
}
render(ctx: CanvasRenderingContext2D): void {
- if (this.isDead()) {
- return;
- }
- this.renderBody(ctx);
- this.renderBars(ctx);
+ renderTowerLaser(this as any, ctx);
}
/**
diff --git a/src/towers/base/towerManualCannon.ts b/src/towers/base/towerManualCannon.ts
new file mode 100644
index 0000000..039a6d1
--- /dev/null
+++ b/src/towers/base/towerManualCannon.ts
@@ -0,0 +1,444 @@
+/**
+ * TowerManualCannon - Manual cannon tower base class
+ *
+ * Special tower that requires manual targeting and can attack enemy buildings.
+ * Has ammo system with reload mechanics.
+ */
+
+import { Vector } from '../../core/math/vector';
+import { Circle } from '../../core/math/circle';
+import { MyColor } from '../../entities/myColor';
+import { Tower } from './tower';
+import { TowerRegistry } from '../towerRegistry';
+import { BulletRegistry } from '../../bullets/bulletRegistry';
+import { isEnemy } from '../../game/player/ownership';
+import { scalePeriod, scaleSpeed } from '../../core/speedScale';
+import {
+ renderTowerBody,
+ renderTowerBars,
+} from '../rendering/towerRenderer';
+import {
+ renderStatusBar,
+ BAR_OFFSET,
+ BAR_COLORS,
+ type StatusBarCache
+} from '../../entities/statusBar';
+
+// Sound manager reference
+declare const SoundManager: { play(src: string): void } | undefined;
+
+/**
+ * Extended world interface for ManualCannon
+ */
+interface ManualCannonWorld {
+ batterys: unknown[];
+ buildings: unknown[];
+ monsters: unknown[];
+ addBully(bullet: unknown): void;
+ addEffect(effect: unknown): void;
+ getMonstersInRange(x: number, y: number, r: number): unknown[];
+ getBuildingsInRange(x: number, y: number, r: number): unknown[];
+ fog?: { enabled: boolean; isCircleVisible(x: number, y: number, r: number): boolean };
+ energy?: { getSatisfactionRate(): number };
+ territory?: unknown;
+}
+
+/**
+ * Target entity interface
+ */
+interface TargetEntity {
+ pos: Vector;
+ r: number;
+ ownerId: string | null;
+ hp: number;
+ isDead(): boolean;
+ hpChange(amount: number): void;
+ getBodyCircle(): Circle;
+}
+
+/**
+ * TowerManualCannon class
+ */
+export class TowerManualCannon extends Tower {
+ /** Maximum ammo capacity */
+ maxAmmo: number = 3;
+
+ /** Current ammo count */
+ currentAmmo: number = 3;
+
+ /** Ticks to reload one ammo */
+ reloadTime: number = scalePeriod(60);
+
+ /** Current reload progress (ticks) */
+ reloadProgress: number = 0;
+
+ /** Explosion damage */
+ explosionDamage: number = 100;
+
+ /** Explosion radius */
+ explosionRadius: number = 50;
+
+ /** Target position for manual firing */
+ targetPos: Vector | null = null;
+
+ /** Whether currently aiming */
+ isAiming: boolean = false;
+
+ /** Can attack buildings (unique to ManualCannon) */
+ canAttackBuildings: boolean = true;
+
+ /** Semi-auto mode: marked target area center */
+ markedTargetPos: Vector | null = null;
+
+ /** Semi-auto mode: marked target radius */
+ markedTargetRadius: number = 100;
+
+ /** Semi-auto mode enabled */
+ semiAutoMode: boolean = false;
+
+ /** Ammo bar cache for rendering */
+ _ammoBarCache: StatusBarCache = { border: null, fill: null, lastValueInt: -1, valueStr: '' };
+
+ /** Shell audio */
+ shellAudioSrc: string = '/sound/子弹音效/火炮爆炸.ogg';
+
+ constructor(x: number, y: number, world: any) {
+ super(x, y, world);
+ this.gameType = "Tower";
+ this.name = "手动炮塔";
+ this.imgIndex = 100; // Will need a specific image index
+ this.price = 800;
+ this.r = 12;
+ this.hpInit(2000);
+ this.rangeR = 400;
+ this.clock = scalePeriod(30); // Faster than reload, but ammo limited
+
+ // Disable normal auto-attack
+ this.attackFunc = this.manualAttack;
+
+ // Visual styling
+ this.bodyColor = new MyColor(60, 60, 80, 0.9);
+ this.bodyStrokeColor = new MyColor(100, 100, 120, 1);
+ this.bodyStrokeWidth = 3;
+ }
+
+ /**
+ * Get world with extended interface
+ */
+ private get cannonWorld(): ManualCannonWorld {
+ return this.world as unknown as ManualCannonWorld;
+ }
+
+ /**
+ * Override goStepMove to handle reload
+ */
+ goStepMove(): void {
+ super.goStepMove();
+ this.handleReload();
+ }
+
+ /**
+ * Override goStepCollide for semi-auto targeting
+ */
+ goStepCollide(): void {
+ super.goStepCollide();
+
+ // Semi-auto mode: automatically fire at marked area
+ if (this.semiAutoMode && this.markedTargetPos && this.currentAmmo > 0) {
+ const target = this.findTargetInMarkedArea();
+ if (target) {
+ this.fireAt(target.pos);
+ }
+ }
+ }
+
+ /**
+ * Handle ammo reload
+ */
+ private handleReload(): void {
+ if (this.currentAmmo < this.maxAmmo) {
+ this.reloadProgress++;
+ if (this.reloadProgress >= this.reloadTime) {
+ this.currentAmmo++;
+ this.reloadProgress = 0;
+ }
+ }
+ }
+
+ /**
+ * Manual attack function (does nothing by default, requires manual trigger)
+ */
+ manualAttack(): void {
+ // Manual cannon doesn't auto-attack
+ // Attack is triggered by fireAt() from UI interaction
+ }
+
+ /**
+ * Fire a shell at target position
+ * @returns true if fired successfully
+ */
+ fireAt(targetPos: Vector): boolean {
+ if (this.currentAmmo <= 0) {
+ return false;
+ }
+
+ // Check range
+ const distance = this.pos.dis(targetPos);
+ if (distance > this.rangeR) {
+ return false;
+ }
+
+ // Check valid territory
+ if (!this.inValidTerritory) {
+ return false;
+ }
+
+ // Create shell bullet
+ const bullet = BulletRegistry.create('ManualCannon_Shell', this.world) as any;
+ if (!bullet) {
+ return false;
+ }
+
+ // Set bullet properties
+ const direction = targetPos.sub(this.pos).to1();
+ const speed = direction.mul(scaleSpeed(8));
+
+ bullet.pos = this.pos.copy();
+ bullet.originalPos = this.pos.copy();
+ bullet.speed = speed;
+ bullet.father = this;
+ bullet.ownerId = this.ownerId;
+
+ // Set explosion properties
+ bullet.haveBomb = true;
+ bullet.bombDamage = this.explosionDamage * this.getDamageMultiplier();
+ bullet.bombRange = this.explosionRadius;
+ bullet.targetPos = targetPos;
+
+ // Add to world
+ this.cannonWorld.addBully(bullet);
+ this.bullys.add(bullet as any);
+
+ // Consume ammo
+ this.currentAmmo--;
+
+ // Play sound
+ if (typeof SoundManager !== 'undefined') {
+ SoundManager.play(this.shellAudioSrc);
+ }
+
+ return true;
+ }
+
+ /**
+ * Set marked target area for semi-auto mode
+ */
+ setMarkedTarget(pos: Vector, radius: number = 100): void {
+ this.markedTargetPos = pos;
+ this.markedTargetRadius = radius;
+ this.semiAutoMode = true;
+ }
+
+ /**
+ * Clear marked target (disable semi-auto mode)
+ */
+ clearMarkedTarget(): void {
+ this.markedTargetPos = null;
+ this.semiAutoMode = false;
+ }
+
+ /**
+ * Find a valid target in the marked area (for semi-auto mode)
+ */
+ private findTargetInMarkedArea(): TargetEntity | null {
+ if (!this.markedTargetPos) return null;
+
+ const world = this.cannonWorld;
+ const searchRadius = this.markedTargetRadius + 50;
+
+ // Priority 1: Enemy buildings (towers, bases)
+ const buildings = world.getBuildingsInRange(
+ this.markedTargetPos.x,
+ this.markedTargetPos.y,
+ searchRadius
+ ) as TargetEntity[];
+
+ for (const building of buildings) {
+ if (!building.isDead() && isEnemy(this, building)) {
+ // Check if within marked area
+ if (building.pos.dis(this.markedTargetPos) <= this.markedTargetRadius) {
+ // Check if within attack range
+ if (this.pos.dis(building.pos) <= this.rangeR) {
+ return building;
+ }
+ }
+ }
+ }
+
+ // Priority 2: Enemy monsters
+ const monsters = world.getMonstersInRange(
+ this.markedTargetPos.x,
+ this.markedTargetPos.y,
+ searchRadius
+ ) as TargetEntity[];
+
+ for (const monster of monsters) {
+ if (!monster.isDead() && isEnemy(this, monster)) {
+ if (monster.pos.dis(this.markedTargetPos) <= this.markedTargetRadius) {
+ if (this.pos.dis(monster.pos) <= this.rangeR) {
+ return monster;
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get valid targets in range (for UI display)
+ */
+ getValidTargetsInRange(): { monsters: TargetEntity[]; buildings: TargetEntity[] } {
+ const world = this.cannonWorld;
+ const result = {
+ monsters: [] as TargetEntity[],
+ buildings: [] as TargetEntity[]
+ };
+
+ // Get all monsters in range
+ const monsters = world.getMonstersInRange(this.pos.x, this.pos.y, this.rangeR) as TargetEntity[];
+ for (const monster of monsters) {
+ if (!monster.isDead() && isEnemy(this, monster)) {
+ // Check fog visibility
+ if (world.fog?.enabled) {
+ const mc = monster.getBodyCircle();
+ if (!world.fog.isCircleVisible(mc.x, mc.y, mc.r)) {
+ continue;
+ }
+ }
+ result.monsters.push(monster);
+ }
+ }
+
+ // Get all buildings in range
+ const buildings = world.getBuildingsInRange(this.pos.x, this.pos.y, this.rangeR) as TargetEntity[];
+ for (const building of buildings) {
+ if (!building.isDead() && isEnemy(this, building)) {
+ result.buildings.push(building);
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Check if a position is valid target
+ */
+ isValidTargetPosition(pos: Vector): boolean {
+ const distance = this.pos.dis(pos);
+ return distance <= this.rangeR && this.inValidTerritory && this.currentAmmo > 0;
+ }
+
+ /**
+ * Override render to show ammo bar
+ */
+ render(ctx: CanvasRenderingContext2D): void {
+ if (this.isDead()) return;
+
+ // Render base tower
+ renderTowerBody(this as any, ctx);
+ renderTowerBars(this as any, ctx);
+
+ // Render ammo bar
+ this.renderAmmoBar(ctx);
+
+ // Render marked target area if in semi-auto mode
+ if (this.semiAutoMode && this.markedTargetPos) {
+ this.renderMarkedArea(ctx);
+ }
+ }
+
+ /**
+ * Render ammo bar
+ */
+ private renderAmmoBar(ctx: CanvasRenderingContext2D): void {
+ const barH = this.hpBarHeight;
+ const barX = this.pos.x - this.r;
+ const barY = this.pos.y + this.r + BAR_OFFSET.BOTTOM_1 * barH;
+ const barW = this.r * 2;
+ const ammoRate = this.currentAmmo / this.maxAmmo;
+
+ // Ammo bar (yellow-ish color)
+ renderStatusBar(ctx, {
+ x: barX,
+ y: barY,
+ width: barW,
+ height: barH,
+ fillRate: ammoRate,
+ fillColor: { r: 200, g: 180, b: 50, a: 1 },
+ cache: this._ammoBarCache
+ });
+
+ // Show reload progress if reloading
+ if (this.currentAmmo < this.maxAmmo) {
+ const reloadRate = this.reloadProgress / this.reloadTime;
+ const reloadBarY = this.pos.y + this.r + BAR_OFFSET.BOTTOM_2 * barH;
+ ctx.fillStyle = `rgba(100, 100, 255, 0.5)`;
+ ctx.fillRect(barX, reloadBarY, barW * reloadRate, barH);
+ }
+ }
+
+ /**
+ * Render marked target area for semi-auto mode
+ */
+ private renderMarkedArea(ctx: CanvasRenderingContext2D): void {
+ if (!this.markedTargetPos) return;
+
+ ctx.save();
+ ctx.strokeStyle = 'rgba(255, 100, 100, 0.5)';
+ ctx.lineWidth = 2;
+ ctx.setLineDash([5, 5]);
+ ctx.beginPath();
+ ctx.arc(this.markedTargetPos.x, this.markedTargetPos.y, this.markedTargetRadius, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ /**
+ * Custom serialization
+ */
+ toJSON(): Record {
+ return {
+ currentAmmo: this.currentAmmo,
+ reloadProgress: this.reloadProgress,
+ semiAutoMode: this.semiAutoMode,
+ markedTargetPos: this.markedTargetPos ? { x: this.markedTargetPos.x, y: this.markedTargetPos.y } : null,
+ markedTargetRadius: this.markedTargetRadius
+ };
+ }
+
+ /**
+ * Restore from save data
+ */
+ fromJSON(data: Record): void {
+ if (typeof data.currentAmmo === 'number') {
+ this.currentAmmo = data.currentAmmo;
+ }
+ if (typeof data.reloadProgress === 'number') {
+ this.reloadProgress = data.reloadProgress;
+ }
+ if (typeof data.semiAutoMode === 'boolean') {
+ this.semiAutoMode = data.semiAutoMode;
+ }
+ if (data.markedTargetPos && typeof data.markedTargetPos === 'object') {
+ const pos = data.markedTargetPos as { x: number; y: number };
+ this.markedTargetPos = new Vector(pos.x, pos.y);
+ }
+ if (typeof data.markedTargetRadius === 'number') {
+ this.markedTargetRadius = data.markedTargetRadius;
+ }
+ }
+}
+
+// Register class type for save system
+TowerRegistry.registerClassType('TowerManualCannon', () => TowerManualCannon);
diff --git a/src/towers/base/towerRay.ts b/src/towers/base/towerRay.ts
index 02b38f2..da223c5 100644
--- a/src/towers/base/towerRay.ts
+++ b/src/towers/base/towerRay.ts
@@ -10,45 +10,49 @@ import { MyColor, ReadonlyColor } from '../../entities/myColor';
import { LineObject } from '../../entities/base/lineObject';
import { Tower } from './tower';
import { TowerRegistry } from '../towerRegistry';
+import { renderTowerRay } from '../rendering/towerRenderer';
import { scaleSpeed } from '../../core/speedScale';
+import { isEnemy } from '@/game/player/ownership';
+import type {
+ VectorLike,
+ CircleLike,
+ MonsterLike as BaseMonsterLike,
+ TerritoryLike,
+ FogOfWarLike,
+ UserLike,
+ TowerLike,
+} from '@/types/worldLike';
// Declare globals for non-migrated modules
declare const EffectLine: {
acquire(start: VectorLike, end: VectorLike): EffectLineLike;
} | undefined;
-interface VectorLike {
- x: number;
- y: number;
-}
-
-interface CircleLike {
- x: number;
- y: number;
- r: number;
+// Extended CircleLike with impact method
+interface CircleLikeExt extends CircleLike {
impact(other: CircleLike): boolean;
}
interface EffectLineLike {
duration: number;
initLineStyle(color: ReadonlyColor, width: number): void;
- initDamage(world: unknown, damage: number): void;
+ initDamage(world: unknown, damage: number, ownerId?: string | null): void;
}
-interface MonsterLike {
+// Extended MonsterLike for ray tower (uses Vector for pos)
+interface MonsterLike extends BaseMonsterLike {
pos: Vector;
- getBodyCircle(): CircleLike;
- hpChange(delta: number): void;
- isDead(): boolean;
+ getBodyCircle(): CircleLikeExt;
}
+// WorldLike interface for TowerRay
interface WorldLike {
width: number;
height: number;
- batterys: Tower[];
- territory?: { markDirty(): void };
- fog?: { enabled: boolean; isPositionVisible(x: number, y: number): boolean; isCircleVisible(x: number, y: number, radius: number): boolean };
- user: { money: number };
+ batterys: TowerLike[];
+ territory?: TerritoryLike;
+ fog?: FogOfWarLike;
+ user: UserLike;
getMonstersInRange(x: number, y: number, range: number): MonsterLike[];
addBully(bully: unknown): void;
removeBully(bully: unknown): void;
@@ -78,7 +82,7 @@ export class TowerRay extends Tower {
declare attackFunc: RayAttackFunc;
// Cached query results for the current frame
- private _cachedMonstersInRange: MonsterLike[] | null = null;
+ private _cachedMonstersInRange: MonsterLike[] | undefined = undefined;
private _cachedQueryRange: number = 0;
constructor(x: number, y: number, world: any) {
@@ -140,13 +144,17 @@ export class TowerRay extends Tower {
let actualDamage = this.damage * this.getDamageMultiplier();
for (let m of nearbyMonsters) {
+ // Filter friendly monsters (same owner)
+ if (!isEnemy(this, m)) {
+ continue;
+ }
// Check fog first, using circle visibility for edge detection
const mc = m.getBodyCircle();
if (this.world.fog?.enabled && !this.world.fog.isCircleVisible(mc.x, mc.y, mc.r)) {
continue;
}
if (line.intersectWithCircle(mc as any)) {
- m.hpChange(-actualDamage);
+ m.hpChange(-actualDamage, this.ownerId);
}
}
if (typeof EffectLine !== 'undefined') {
@@ -154,7 +162,7 @@ export class TowerRay extends Tower {
e.initLineStyle(this.rayColor, this.rayWidth);
e.duration = 50;
if (this.attackFunc === this.scanningAttack) {
- e.initDamage(this.world, actualDamage);
+ e.initDamage(this.world, actualDamage, this.ownerId);
}
this.world.addEffect?.(e);
}
@@ -300,13 +308,17 @@ export class TowerRay extends Tower {
if (doCollision) {
let nearbyMonsters = this.world.getMonstersInRange(br.PosEnd.x, br.PosEnd.y, this.rayLen);
for (let m of nearbyMonsters) {
+ // Skip friendly monsters
+ if (!isEnemy(this, m)) {
+ continue;
+ }
// Check fog first, using circle visibility for edge detection
const mc = m.getBodyCircle();
if (this.world.fog?.enabled && !this.world.fog.isCircleVisible(mc.x, mc.y, mc.r)) {
continue;
}
if (br.intersectWithCircle(mc as any)) {
- m.hpChange(-actualDamage);
+ m.hpChange(-actualDamage, this.ownerId);
if (!this.rayThrowAble) {
toDelete.push(br);
break;
@@ -324,11 +336,7 @@ export class TowerRay extends Tower {
}
render(ctx: CanvasRenderingContext2D): void {
- if (this.isDead()) {
- return;
- }
- this.renderBody(ctx);
- this.renderBars(ctx);
+ renderTowerRay(this as any, ctx);
}
/**
diff --git a/src/towers/config/manualCannonTower.ts b/src/towers/config/manualCannonTower.ts
new file mode 100644
index 0000000..ddf40ee
--- /dev/null
+++ b/src/towers/config/manualCannonTower.ts
@@ -0,0 +1,57 @@
+/**
+ * Manual Cannon tower configuration
+ *
+ * Special tower that can attack enemy buildings.
+ * Only one tower type - no upgrade tree (intentional design).
+ */
+
+import type { TowerBaseConfig, TowerParams } from './types';
+
+/**
+ * Manual cannon specific parameters
+ */
+export interface ManualCannonParams extends TowerParams {
+ /** Maximum ammo capacity */
+ maxAmmo?: number;
+ /** Ticks to reload one ammo (before speed scaling) */
+ reloadTime?: number;
+ /** Explosion damage */
+ explosionDamage?: number;
+ /** Explosion radius */
+ explosionRadius?: number;
+}
+
+/**
+ * Manual cannon tower config type
+ */
+export interface ManualCannonTowerConfig extends TowerBaseConfig {
+ baseClass: 'TowerManualCannon';
+ params?: ManualCannonParams;
+}
+
+/**
+ * ManualCannon - The only tower that can attack enemy buildings
+ */
+export const MANUAL_CANNON_CONFIG: ManualCannonTowerConfig = {
+ id: 'ManualCannon',
+ baseClass: 'TowerManualCannon',
+ name: '手动炮塔',
+ imgIndex: 100,
+ price: 800,
+ comment: '唯一能够攻击敌方建筑的塔。需要手动瞄准目标,发射爆炸性炮弹。弹药有限,需要时间装填。',
+ levelUpArr: [], // No upgrades - intentional
+ levelDownGetter: null, // Cannot downgrade
+ params: {
+ rAdd: 0, // r = 12 (set in constructor)
+ rangeR: 400,
+ hp: 2000,
+ maxAmmo: 3,
+ reloadTime: 60, // ~1 second at base speed
+ explosionDamage: 100,
+ explosionRadius: 50
+ }
+};
+
+export const MANUAL_CANNON_TOWER_CONFIGS: ManualCannonTowerConfig[] = [
+ MANUAL_CANNON_CONFIG
+];
diff --git a/src/towers/index.ts b/src/towers/index.ts
index bb15c7a..a14c3f0 100644
--- a/src/towers/index.ts
+++ b/src/towers/index.ts
@@ -24,7 +24,8 @@ export {
TowerHell,
TowerHammer,
TowerBoomerang,
- TowerRay
+ TowerRay,
+ TowerManualCannon
} from './base/index';
// Import variants to trigger registration (side effect)
@@ -104,5 +105,8 @@ export function getTowerFuncArr() {
TowerRegistry.getCreator('ThunderBall_1'),
TowerRegistry.getCreator('AirCannon_1'),
+
+ // Manual Cannon (multiplayer special tower)
+ TowerRegistry.getCreator('ManualCannon'),
].filter(Boolean);
}
diff --git a/src/towers/rendering/index.ts b/src/towers/rendering/index.ts
new file mode 100644
index 0000000..02f3fc7
--- /dev/null
+++ b/src/towers/rendering/index.ts
@@ -0,0 +1,4 @@
+/**
+ * Tower rendering module
+ */
+export * from './towerRenderer';
diff --git a/src/towers/rendering/towerRenderer.ts b/src/towers/rendering/towerRenderer.ts
new file mode 100644
index 0000000..d0ac8de
--- /dev/null
+++ b/src/towers/rendering/towerRenderer.ts
@@ -0,0 +1,300 @@
+/**
+ * TowerRenderer - Rendering functions for Tower and its subclasses
+ * Extracted from Tower, TowerLaser, TowerHammer, TowerRay, TowerBoomerang
+ */
+import { Vector } from '../../core/math/vector';
+import { Circle } from '../../core/math/circle';
+import { Line } from '../../core/math/line';
+import { MyColor } from '../../entities/myColor';
+import { scalePeriod } from '../../core/speedScale';
+import {
+ renderStatusBar,
+ BAR_OFFSET,
+ BAR_COLORS,
+ type StatusBarCache
+} from '../../entities/statusBar';
+import { getTowersImg, TOWER_IMG_PRE_WIDTH, TOWER_IMG_PRE_HEIGHT } from '../towerConstants';
+
+// Declare globals for non-migrated modules
+declare const UP_LEVEL_ICON: HTMLImageElement | undefined;
+
+// ============================================================================
+// Type interfaces for loose coupling
+// ============================================================================
+
+interface Renderable {
+ render(ctx: CanvasRenderingContext2D): void;
+}
+
+interface CircleObjectLike {
+ pos: Vector;
+ r: number;
+ hp: number;
+ maxHp: number;
+ hpBarHeight: number;
+ hpColor: { r: number; g: number; b: number; a: number };
+ _hpBarCache: StatusBarCache;
+ liveTime: number;
+
+ isInScreen(): boolean;
+ isDead(): boolean;
+ getBodyCircle(): Circle;
+}
+
+interface TowerLike extends CircleObjectLike {
+ imgIndex: number;
+ selected: boolean;
+ bullys: Set;
+ _upIconOffset: Vector | null;
+
+ getImgStartPosByIndex(n: number): Vector;
+ getViewCircle(): Circle;
+ isUpLevelAble(): boolean;
+}
+
+interface TowerLaserLike extends TowerLike {
+ laserFreezeNow: number;
+ laserFreezeMax: number;
+ laserDamageAdd: number;
+ laserMaxDamage: number;
+ _cooldownBarCache: StatusBarCache;
+ _chargeBarCache: StatusBarCache;
+}
+
+interface TowerHammerLike extends TowerLike {
+ additionItem: CircleObjectLike & Renderable & { pos: Vector; bodyColor: MyColor };
+}
+
+interface TowerRayLike extends TowerLike {
+ rayBullys: Set;
+}
+
+interface TowerBoomerangLike extends TowerLike {
+ bar: Line & { strokeColor: MyColor; getCenter(): Vector };
+}
+
+// ============================================================================
+// Core rendering functions
+// ============================================================================
+
+/**
+ * Render tower body circle + sprite image
+ */
+export function renderTowerBody(tower: TowerLike, ctx: CanvasRenderingContext2D): void {
+ if (tower.isDead()) return;
+
+ // Base circle
+ const c = tower.getBodyCircle();
+ c.render(ctx);
+
+ // Sprite image
+ const TOWERS_IMG = getTowersImg();
+ const imgStartPos = tower.getImgStartPosByIndex(tower.imgIndex);
+ if (TOWERS_IMG) {
+ ctx.drawImage(
+ TOWERS_IMG,
+ imgStartPos.x,
+ imgStartPos.y,
+ TOWER_IMG_PRE_WIDTH,
+ TOWER_IMG_PRE_HEIGHT,
+ tower.pos.x - tower.r,
+ tower.pos.y - tower.r,
+ tower.r * 2,
+ tower.r * 2,
+ );
+ }
+
+ // Selection range circle
+ if (!tower.isDead() && tower.selected) {
+ tower.getViewCircle().renderView(ctx);
+ }
+
+ // Render bullets
+ for (const b of tower.bullys) {
+ b.render(ctx);
+ }
+
+ // Upgrade icon
+ if (tower.isUpLevelAble()) {
+ if (!tower._upIconOffset) {
+ tower._upIconOffset = new Vector(0, 0);
+ }
+ tower._upIconOffset.x = tower.pos.x + tower.r * 0.2;
+ tower._upIconOffset.y = tower.pos.y - tower.r * 1.5 + Math.sin(tower.liveTime / scalePeriod(15)) * 5;
+ if (typeof UP_LEVEL_ICON !== 'undefined') {
+ ctx.drawImage(
+ UP_LEVEL_ICON,
+ 0, 0, 100, 100,
+ tower._upIconOffset.x,
+ tower._upIconOffset.y,
+ 20, 20
+ );
+ }
+ }
+}
+
+/**
+ * Render tower HP bar (from CircleObject base)
+ */
+export function renderTowerBars(tower: TowerLike, ctx: CanvasRenderingContext2D): void {
+ if (!tower.isInScreen()) return;
+ if (tower.maxHp > 0 && !tower.isDead()) {
+ const barH = tower.hpBarHeight;
+ const barX = tower.pos.x - tower.r;
+ const barY = tower.pos.y - tower.r + BAR_OFFSET.HP_TOP * barH;
+ const barW = tower.r * 2;
+ const hpRate = tower.hp / tower.maxHp;
+
+ renderStatusBar(ctx, {
+ x: barX,
+ y: barY,
+ width: barW,
+ height: barH,
+ fillRate: hpRate,
+ fillColor: tower.hpColor,
+ showText: true,
+ textValue: tower.hp,
+ cache: tower._hpBarCache
+ });
+ }
+}
+
+// ============================================================================
+// Subclass-specific rendering
+// ============================================================================
+
+/**
+ * Render TowerLaser bars (HP bar + cooldown + charge)
+ */
+export function renderTowerLaserBars(tower: TowerLaserLike, ctx: CanvasRenderingContext2D): void {
+ // HP bar
+ renderTowerBars(tower, ctx);
+
+ const barH = tower.hpBarHeight;
+ const barX = tower.pos.x - tower.r;
+ const barW = tower.r * 2;
+
+ // Cooldown bar
+ const cooldownY = tower.pos.y + tower.r + BAR_OFFSET.BOTTOM_1 * barH;
+ const cooldownRate = tower.laserFreezeNow / tower.laserFreezeMax;
+ renderStatusBar(ctx, {
+ x: barX,
+ y: cooldownY,
+ width: barW,
+ height: barH,
+ fillRate: cooldownRate,
+ fillColor: BAR_COLORS.COOLDOWN,
+ cache: tower._cooldownBarCache
+ });
+
+ // Charge bar
+ const chargeY = tower.pos.y + tower.r + BAR_OFFSET.BOTTOM_2 * barH;
+ const chargeRate = tower.laserDamageAdd / tower.laserMaxDamage;
+ renderStatusBar(ctx, {
+ x: barX,
+ y: chargeY,
+ width: barW,
+ height: barH,
+ fillRate: chargeRate,
+ fillColor: BAR_COLORS.CHARGE,
+ cache: tower._chargeBarCache
+ });
+}
+
+/**
+ * Render TowerHammer extra (hammer item + connecting line)
+ */
+export function renderTowerHammerExtra(tower: TowerHammerLike, ctx: CanvasRenderingContext2D): void {
+ tower.additionItem.render(ctx);
+ const line = new Line(tower.pos, tower.additionItem.pos);
+ line.strokeWidth = 10;
+ line.strokeColor = tower.additionItem.bodyColor;
+ line.render(ctx);
+}
+
+/**
+ * Render TowerRay extra (ray bullets)
+ */
+export function renderTowerRayExtra(tower: TowerRayLike, ctx: CanvasRenderingContext2D): void {
+ for (const b of tower.rayBullys) {
+ b.render(ctx);
+ }
+}
+
+/**
+ * Render TowerBoomerang extra (boomerang bar + connecting line)
+ */
+export function renderTowerBoomerangExtra(tower: TowerBoomerangLike, ctx: CanvasRenderingContext2D): void {
+ tower.bar.render(ctx);
+ const line = new Line(tower.pos, tower.bar.getCenter());
+ line.strokeWidth = 0.1;
+ line.strokeColor = tower.bar.strokeColor;
+ line.render(ctx);
+}
+
+// ============================================================================
+// Composite rendering functions
+// ============================================================================
+
+/**
+ * Render complete Tower (base class)
+ */
+export function renderTower(tower: TowerLike, ctx: CanvasRenderingContext2D): void {
+ if (tower.isDead()) return;
+ renderTowerBody(tower, ctx);
+ renderTowerBars(tower, ctx);
+}
+
+/**
+ * Render complete TowerLaser
+ */
+export function renderTowerLaser(tower: TowerLaserLike, ctx: CanvasRenderingContext2D): void {
+ if (tower.isDead()) return;
+ renderTowerBody(tower, ctx);
+ renderTowerLaserBars(tower, ctx);
+}
+
+/**
+ * Render complete TowerHammer
+ */
+export function renderTowerHammer(tower: TowerHammerLike, ctx: CanvasRenderingContext2D): void {
+ if (tower.isDead()) return;
+ // Body + hammer item
+ renderTowerBody(tower, ctx);
+ renderTowerHammerExtra(tower, ctx);
+ // Bars (base)
+ renderTowerBars(tower, ctx);
+}
+
+/**
+ * Render complete TowerRay
+ */
+export function renderTowerRay(tower: TowerRayLike, ctx: CanvasRenderingContext2D): void {
+ if (tower.isDead()) return;
+ renderTowerBody(tower, ctx);
+ renderTowerRayExtra(tower, ctx);
+ renderTowerBars(tower, ctx);
+}
+
+/**
+ * Render complete TowerBoomerang
+ */
+export function renderTowerBoomerang(tower: TowerBoomerangLike, ctx: CanvasRenderingContext2D): void {
+ if (tower.isDead()) return;
+ // Boomerang renders bar BEFORE body (order matters)
+ renderTowerBoomerangExtra(tower, ctx);
+ renderTowerBody(tower, ctx);
+ renderTowerBars(tower, ctx);
+}
+
+// ============================================================================
+// Export types
+// ============================================================================
+
+export type {
+ TowerLike as TowerRenderable,
+ TowerLaserLike as TowerLaserRenderable,
+ TowerHammerLike as TowerHammerRenderable,
+ TowerRayLike as TowerRayRenderable,
+ TowerBoomerangLike as TowerBoomerangRenderable,
+};
diff --git a/src/towers/variants/index.ts b/src/towers/variants/index.ts
index b61f44d..9709024 100644
--- a/src/towers/variants/index.ts
+++ b/src/towers/variants/index.ts
@@ -21,6 +21,7 @@ import * as spray from './spray';
import * as shot from './shot';
import * as thunder from './thunder';
import * as laser from './laser';
+import * as manualCannon from './manualCannon';
// Re-export all variants for direct access if needed
export {
@@ -39,5 +40,6 @@ export {
spray,
shot,
thunder,
- laser
+ laser,
+ manualCannon
};
diff --git a/src/towers/variants/manualCannon.ts b/src/towers/variants/manualCannon.ts
new file mode 100644
index 0000000..0259dce
--- /dev/null
+++ b/src/towers/variants/manualCannon.ts
@@ -0,0 +1,54 @@
+/**
+ * Manual Cannon tower variant
+ *
+ * Special tower that can attack enemy buildings.
+ * Uses manual targeting with ammo system.
+ */
+
+import { TowerManualCannon } from '../base/towerManualCannon';
+import { TowerRegistry, type TowerMeta } from '../towerRegistry';
+import { MANUAL_CANNON_CONFIG } from '../config/manualCannonTower';
+import { scalePeriod } from '../../core/speedScale';
+
+interface WorldLike {
+ batterys: unknown[];
+}
+
+/**
+ * ManualCannon factory function
+ */
+export function ManualCannon(world: WorldLike): TowerManualCannon {
+ const tower = new TowerManualCannon(0, 0, world);
+ const params = MANUAL_CANNON_CONFIG.params;
+
+ // Apply config values
+ tower.name = MANUAL_CANNON_CONFIG.name;
+ tower.imgIndex = MANUAL_CANNON_CONFIG.imgIndex;
+ tower.price = MANUAL_CANNON_CONFIG.price;
+ tower.comment = MANUAL_CANNON_CONFIG.comment;
+ tower.levelUpArr = [...MANUAL_CANNON_CONFIG.levelUpArr];
+ tower.levelDownGetter = MANUAL_CANNON_CONFIG.levelDownGetter;
+
+ if (params) {
+ if (params.rangeR !== undefined) tower.rangeR = params.rangeR;
+ if (params.hp !== undefined) tower.hpInit(params.hp);
+ if (params.maxAmmo !== undefined) tower.maxAmmo = params.maxAmmo;
+ if (params.reloadTime !== undefined) tower.reloadTime = scalePeriod(params.reloadTime);
+ if (params.explosionDamage !== undefined) tower.explosionDamage = params.explosionDamage;
+ if (params.explosionRadius !== undefined) tower.explosionRadius = params.explosionRadius;
+ }
+
+ // Initialize ammo
+ tower.currentAmmo = tower.maxAmmo;
+
+ return tower;
+}
+
+// Register tower with registry
+const meta: TowerMeta = {
+ name: MANUAL_CANNON_CONFIG.name,
+ imgIndex: MANUAL_CANNON_CONFIG.imgIndex,
+ basePrice: MANUAL_CANNON_CONFIG.price
+};
+
+TowerRegistry.register('ManualCannon', ManualCannon as any, meta);
diff --git a/src/types/entities.ts b/src/types/entities.ts
index 538e266..dd09386 100644
--- a/src/types/entities.ts
+++ b/src/types/entities.ts
@@ -57,7 +57,7 @@ export interface ICircleObject extends IGameObject {
bodyRadiusChange(delta: number): void;
hpInit(maxHp: number): void;
hpSet(hp: number): void;
- hpChange(delta: number): void;
+ hpChange(delta: number, attackerId?: string | null): void;
isDead(): boolean;
isInScreen(): boolean;
isOutScreen(): boolean;
diff --git a/src/types/index.ts b/src/types/index.ts
index a7567dd..7ed372b 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -113,3 +113,41 @@ export type {
SystemEventHandler,
ISystemManager,
} from './systems';
+
+// WorldLike types (unified interfaces for decoupling)
+export type {
+ VectorLike,
+ CircleLike,
+ UserLike,
+ MonsterLike,
+ BulletLike,
+ BuildingLike,
+ TowerLike,
+ TerritoryLike,
+ FogOfWarLike,
+ CameraLike,
+ EnergyLike,
+ CheatModeLike,
+ RootBuildingLike,
+ WorldLike,
+ WorldLikeForTower,
+ WorldLikeForMonster,
+ WorldLikeForBullet,
+ WorldLikeForBuilding,
+ WorldLikeForCircleObject,
+ WorldLikeForFactory,
+} from './worldLike';
+
+// Player types (multiplayer support)
+export type {
+ Player,
+ PlayerConfig,
+ OwnedEntity,
+} from './player';
+
+export {
+ DEFAULT_PLAYER_ID,
+ NEUTRAL_OWNER_ID,
+ PVP_CONFIG,
+ PLAYER_COLORS,
+} from './player';
diff --git a/src/types/player.ts b/src/types/player.ts
new file mode 100644
index 0000000..babdae2
--- /dev/null
+++ b/src/types/player.ts
@@ -0,0 +1,95 @@
+/**
+ * Player types for multiplayer support
+ */
+
+import type { BuildingLike, VectorLike } from './worldLike';
+
+/**
+ * Player interface - represents a player in the game
+ */
+export interface Player {
+ /** Unique player identifier */
+ id: string;
+ /** Display name */
+ name: string;
+ /** Player color for UI and entity rendering */
+ color: string;
+ /** Base building reference */
+ baseBuilding: BuildingLike | null;
+ /** Current money */
+ money: number;
+ /** Whether player is still alive */
+ isAlive: boolean;
+}
+
+/**
+ * Player configuration for game initialization
+ */
+export interface PlayerConfig {
+ id: string;
+ name: string;
+ color: string;
+ /** Initial base position */
+ basePosition: VectorLike;
+ /** Initial money amount */
+ initialMoney: number;
+}
+
+/**
+ * Entity with owner ID for multiplayer ownership tracking
+ */
+export interface OwnedEntity {
+ /** Owner player ID, null for neutral entities */
+ ownerId: string | null;
+}
+
+/**
+ * Default player ID for single-player mode
+ * All entities default to this owner when ownerId is null
+ */
+export const DEFAULT_PLAYER_ID = 'player-0';
+
+/**
+ * Neutral entity owner ID (e.g., neutral monsters)
+ */
+export const NEUTRAL_OWNER_ID: null = null;
+
+/**
+ * PvP configuration constants
+ */
+export const PVP_CONFIG = {
+ // Player settings
+ maxPlayers: 2,
+ initialMoney: 1000,
+
+ // Map sizes
+ mapSizes: {
+ small: { width: 4000, height: 3000 },
+ medium: { width: 6000, height: 4000 },
+ large: { width: 8000, height: 5000 },
+ },
+
+ // Base positions (ratio relative to map size)
+ basePositions: [
+ { xRatio: 0.20, yRatio: 0.50 }, // Player A (left)
+ { xRatio: 0.80, yRatio: 0.50 }, // Player B (right)
+ ],
+
+ // Spawn system
+ spawn: {
+ costMultiplier: 2, // spawn cost = addPrice × 2
+ },
+
+ // Territory
+ territory: {
+ enemyBuildCostMultiplier: 2, // cost multiplier for building in enemy territory
+ },
+
+ // Vision
+ vision: {
+ spawnedMonsterProvidesVision: false,
+ },
+} as const;
+
+// Re-export from shared config (single source of truth)
+export { PLAYER_COLORS } from '@shared/config/playerMeta';
diff --git a/src/types/worldLike.ts b/src/types/worldLike.ts
new file mode 100644
index 0000000..b987563
--- /dev/null
+++ b/src/types/worldLike.ts
@@ -0,0 +1,338 @@
+/**
+ * Unified WorldLike interfaces for decoupling
+ *
+ * These interfaces are used throughout the codebase to avoid circular dependencies.
+ * Each module only needs a subset of WorldLike properties, so we use interface
+ * composition to keep dependencies minimal.
+ */
+
+import type { Vector } from '@/core/math/vector';
+import type { Circle } from '@/core/math/circle';
+
+// ============================================================================
+// Basic type interfaces (used as building blocks)
+// ============================================================================
+
+/** Minimal vector interface */
+export interface VectorLike {
+ x: number;
+ y: number;
+}
+
+/** Minimal circle interface */
+export interface CircleLike {
+ x: number;
+ y: number;
+ r: number;
+ impact(other: CircleLike): boolean;
+}
+
+// ============================================================================
+// User and Player interfaces
+// ============================================================================
+
+/** User state interface */
+export interface UserLike {
+ money: number;
+}
+
+// ============================================================================
+// Entity interfaces (minimal versions to avoid circular deps)
+// ============================================================================
+
+/** Minimal monster interface for targeting/collision */
+export interface MonsterLike {
+ pos: VectorLike;
+ r?: number;
+ getBodyCircle(): CircleLike;
+ hpChange(delta: number, attackerId?: string | null): void;
+ isDead(): boolean;
+ /** Owner player ID for multiplayer (null = neutral) */
+ ownerId: string | null;
+ /** Target destination for multiplayer mode */
+ destination?: VectorLike;
+}
+
+/** Minimal bullet interface */
+export interface BulletLike {
+ pos: VectorLike;
+ speed: VectorLike;
+ r: number;
+ laserDestoryAble?: boolean;
+ bodyRadiusChange(dr: number): void;
+ acceleration: VectorLike;
+ damageChange(delta: number): void;
+ remove(): void;
+ /** Owner player ID for multiplayer (null = neutral) */
+ ownerId?: string | null;
+}
+
+/** Minimal building interface for targeting/collision */
+export interface BuildingLike {
+ pos: VectorLike;
+ hp: number;
+ maxHp: number;
+ r?: number;
+ getBodyCircle(): CircleLike;
+ hpChange(delta: number, attackerId?: string | null): void;
+ isDead(): boolean;
+ // Tower-specific (optional, for threat calculation)
+ damage?: number;
+ clock?: number;
+ // Territory related
+ gameType?: string;
+ otherHpAddAble?: boolean;
+ moneyAddedAble?: boolean;
+ rangeR?: number;
+ inValidTerritory?: boolean;
+ _territoryPenaltyApplied?: boolean;
+ _originalMaxHp?: number | null;
+ _originalRangeR?: number | null;
+ /** Owner player ID for multiplayer (null = neutral) */
+ ownerId: string | null;
+ // UI state
+ selected?: boolean;
+ // MonsterSpawner specific
+ canSpawnMonsters?: boolean;
+ // Rendering methods
+ renderStatic?(ctx: CanvasRenderingContext2D): void;
+ renderDynamic?(ctx: CanvasRenderingContext2D): void;
+}
+
+/** Minimal tower interface */
+export interface TowerLike {
+ pos: VectorLike;
+ r?: number;
+ rangeR?: number;
+ hpChange(delta: number, attackerId?: string | null): void;
+ getBodyCircle?(): CircleLike;
+ getTowerLevel?(): number;
+ inValidTerritory?: boolean;
+ /** Owner player ID for multiplayer (null = neutral) */
+ ownerId: string | null;
+ // Type identification
+ gameType?: string;
+ // UI state
+ selected?: boolean;
+ // ManualCannon specific
+ canAttackBuildings?: boolean;
+ // Rendering methods
+ renderBody?(ctx: CanvasRenderingContext2D): void;
+ renderBars?(ctx: CanvasRenderingContext2D): void;
+}
+
+// ============================================================================
+// System interfaces
+// ============================================================================
+
+/** Territory system interface */
+export interface TerritoryLike {
+ markDirty(): void;
+ addBuildingIncremental?(building: unknown): void;
+ removeBuildingIncremental?(building: unknown): void;
+ validBuildings?: Set;
+ recalculate?(): void;
+}
+
+/** Fog of war system interface */
+export interface FogOfWarLike {
+ enabled: boolean;
+ isPositionVisible(x: number, y: number): boolean;
+ isCircleVisible(x: number, y: number, radius: number): boolean;
+ markDirty(): void;
+}
+
+/** Camera interface */
+export interface CameraLike {
+ x: number;
+ y: number;
+ zoom: number;
+ viewWidth?: number;
+ viewHeight?: number;
+}
+
+/** Energy system interface */
+export interface EnergyLike {
+ getSatisfactionRatio(): number;
+}
+
+/** Cheat mode interface */
+export interface CheatModeLike {
+ enabled: boolean;
+ priceMultiplier?: number;
+ infiniteHp: boolean;
+ disableEnergy?: boolean;
+}
+
+/** Root building interface */
+export interface RootBuildingLike {
+ pos: VectorLike;
+ hp?: number;
+ maxHp?: number;
+}
+
+// ============================================================================
+// WorldLike interface (unified)
+// ============================================================================
+
+/**
+ * Unified WorldLike interface
+ *
+ * This is the complete interface. Individual modules should use more
+ * specific sub-interfaces when possible to minimize coupling.
+ */
+export interface WorldLike {
+ // Dimensions
+ width: number;
+ height: number;
+ viewWidth?: number;
+ viewHeight?: number;
+
+ // Precomputed world constants (for monster spawning)
+ worldCenterX?: number;
+ worldCenterY?: number;
+ minMonsterRadius?: number;
+ monsterRadiusRange?: number;
+
+ // User state
+ user: UserLike;
+
+ // Entity collections
+ batterys: TowerLike[];
+ monsters?: Set;
+ buildings?: BuildingLike[] | Set;
+ mines?: Set;
+ allBullys?: Iterable;
+ othersBullys?: BulletLike[];
+
+ // Core building
+ rootBuilding: RootBuildingLike;
+
+ // Systems (all optional for flexibility)
+ territory?: TerritoryLike;
+ fog?: FogOfWarLike;
+ camera?: CameraLike;
+ energy?: EnergyLike;
+ cheatMode?: CheatModeLike;
+
+ // Game state
+ time?: number;
+ maxMonsterNum?: number;
+
+ // Query methods
+ getMonstersInRange(x: number, y: number, range: number): MonsterLike[];
+ getBullysInRange?(x: number, y: number, range: number): BulletLike[];
+ getBuildingsInRange?(x: number, y: number, range: number): BuildingLike[];
+ getAllBuildingArr?(): BuildingLike[];
+
+ // Entity management methods
+ addBully?(bully: unknown): void;
+ removeBully?(bully: unknown): void;
+ addMonster?(monster: unknown): void;
+ removeMonster?(monster: unknown): void;
+ addEffect?(effect: unknown): void;
+
+ // Money management (multiplayer compatible)
+ addMoneyToOwner?(ownerId: string | null, amount: number): void;
+ getMoney?(playerId?: string): number;
+ setMoney?(amount: number, playerId?: string): void;
+ addMoney?(amount: number, playerId?: string): void;
+ spendMoney?(amount: number, force?: boolean): boolean;
+
+ // Base building accessor (multiplayer compatible)
+ getBaseBuilding?(playerId?: string): RootBuildingLike;
+
+ // Static layer
+ markStaticLayerDirty?(): void;
+
+ // Spatial system methods (for entity movement tracking)
+ markBuildingQuadTreeDirty?(): void;
+ markSpatialDirty?(entity: unknown): void;
+
+ // Energy system (multiplayer compatible)
+ getEnergyForOwner?(ownerId: string | null): EnergyLike;
+}
+
+// ============================================================================
+// Specialized WorldLike sub-interfaces for specific use cases
+// ============================================================================
+
+/** WorldLike for tower base classes */
+export interface WorldLikeForTower {
+ width: number;
+ height: number;
+ batterys: TowerLike[];
+ territory?: TerritoryLike;
+ fog?: FogOfWarLike;
+ user: UserLike;
+ energy?: EnergyLike;
+ getMonstersInRange(x: number, y: number, range: number): MonsterLike[];
+ addBully?(bully: unknown): void;
+ removeBully?(bully: unknown): void;
+ addEffect?(effect: unknown): void;
+}
+
+/** WorldLike for monster base classes */
+export interface WorldLikeForMonster {
+ width: number;
+ height: number;
+ worldCenterX?: number;
+ worldCenterY?: number;
+ minMonsterRadius?: number;
+ monsterRadiusRange?: number;
+ monsters: Set;
+ allBullys: Iterable;
+ rootBuilding: RootBuildingLike;
+ user: UserLike;
+ territory?: TerritoryLike;
+ fog?: FogOfWarLike;
+ cheatMode?: CheatModeLike;
+ getMonstersInRange(x: number, y: number, range: number): MonsterLike[];
+ getBullysInRange(x: number, y: number, range: number): BulletLike[];
+ getBuildingsInRange(x: number, y: number, range: number): BuildingLike[];
+ getAllBuildingArr(): BuildingLike[];
+ addMonster(monster: unknown): void;
+ removeMonster(monster: unknown): void;
+ addEffect?(effect: unknown): void;
+ addMoneyToOwner?(ownerId: string | null, amount: number): void;
+}
+
+/** WorldLike for bullet classes */
+export interface WorldLikeForBullet {
+ width: number;
+ height: number;
+ monsters: Iterable;
+ othersBullys?: BulletLike[];
+ fog?: FogOfWarLike;
+ removeBully(bully: unknown): void;
+ addBully(bully: unknown): void;
+ getMonstersInRange(x: number, y: number, range: number): MonsterLike[];
+ getBuildingsInRange(x: number, y: number, range: number): BuildingLike[];
+ getAllBuildingArr(): BuildingLike[];
+ addEffect(effect: unknown): void;
+}
+
+/** WorldLike for building classes */
+export interface WorldLikeForBuilding {
+ width: number;
+ height: number;
+ user: UserLike;
+ territory?: TerritoryLike;
+ buildings?: Set;
+ batterys?: TowerLike[];
+ addEffect?(effect: unknown): void;
+ addMoneyToOwner?(ownerId: string | null, amount: number): void;
+}
+
+/** WorldLike for CircleObject base class */
+export interface WorldLikeForCircleObject {
+ width: number;
+ height: number;
+ cheatMode?: CheatModeLike;
+}
+
+/** WorldLike for factory functions (minimal) */
+export interface WorldLikeForFactory {
+ batterys: unknown[];
+ cheatMode?: CheatModeLike;
+}
diff --git a/src/ui/components/gameEndModal.ts b/src/ui/components/gameEndModal.ts
new file mode 100644
index 0000000..9dfa2a3
--- /dev/null
+++ b/src/ui/components/gameEndModal.ts
@@ -0,0 +1,110 @@
+/**
+ * Game End Modal Component
+ * Displays game result with better UX than alert()
+ */
+
+export interface GameEndOptions {
+ isWinner: boolean;
+ message: string;
+ onReturnToLobby: () => void;
+ onPlayAgain?: () => void;
+}
+
+export class GameEndModal {
+ private modalElement: HTMLElement | null = null;
+
+ show(options: GameEndOptions): void {
+ this.hide(); // Remove existing modal if any
+
+ const modal = this.createModal(options);
+ document.body.appendChild(modal);
+ this.modalElement = modal;
+
+ // Trigger animation
+ requestAnimationFrame(() => {
+ modal.classList.add('show');
+ });
+ }
+
+ hide(): void {
+ if (this.modalElement) {
+ this.modalElement.classList.remove('show');
+ setTimeout(() => {
+ this.modalElement?.remove();
+ this.modalElement = null;
+ }, 300);
+ }
+ }
+
+ private createModal(options: GameEndOptions): HTMLElement {
+ const modal = document.createElement('div');
+ modal.className = 'game-end-modal';
+
+ const content = document.createElement('div');
+ content.className = 'game-end-content';
+
+ // Result icon
+ const icon = document.createElement('div');
+ icon.className = `game-end-icon ${options.isWinner ? 'victory' : 'defeat'}`;
+ icon.textContent = options.isWinner ? '🎉' : '💥';
+
+ // Title
+ const title = document.createElement('h2');
+ title.className = 'game-end-title';
+ title.textContent = options.isWinner ? '胜利!' : '失败';
+
+ // Message
+ const message = document.createElement('p');
+ message.className = 'game-end-message';
+ message.textContent = options.message;
+
+ // Buttons container
+ const buttons = document.createElement('div');
+ buttons.className = 'game-end-buttons';
+
+ // Return to lobby button
+ const lobbyBtn = document.createElement('button');
+ lobbyBtn.className = 'game-end-btn primary';
+ lobbyBtn.textContent = '返回大厅';
+ lobbyBtn.onclick = () => {
+ this.hide();
+ options.onReturnToLobby();
+ };
+
+ buttons.appendChild(lobbyBtn);
+
+ // Play again button (optional)
+ if (options.onPlayAgain) {
+ const playAgainBtn = document.createElement('button');
+ playAgainBtn.className = 'game-end-btn secondary';
+ playAgainBtn.textContent = '再来一局';
+ playAgainBtn.onclick = () => {
+ this.hide();
+ options.onPlayAgain!();
+ };
+ buttons.appendChild(playAgainBtn);
+ }
+
+ content.appendChild(icon);
+ content.appendChild(title);
+ content.appendChild(message);
+ content.appendChild(buttons);
+ modal.appendChild(content);
+
+ return modal;
+ }
+}
+
+// Singleton instance
+let modalInstance: GameEndModal | null = null;
+
+export function showGameEndModal(options: GameEndOptions): void {
+ if (!modalInstance) {
+ modalInstance = new GameEndModal();
+ }
+ modalInstance.show(options);
+}
+
+export function hideGameEndModal(): void {
+ modalInstance?.hide();
+}
diff --git a/src/ui/components/index.ts b/src/ui/components/index.ts
index f869b72..4d232e9 100644
--- a/src/ui/components/index.ts
+++ b/src/ui/components/index.ts
@@ -5,3 +5,4 @@
export * from './backButton';
export * from './entityCard';
+export * from './gameEndModal';
diff --git a/src/ui/interfaces/battle/cheatMode.ts b/src/ui/interfaces/battle/cheatMode.ts
index 0305f9e..ff9a82c 100644
--- a/src/ui/interfaces/battle/cheatMode.ts
+++ b/src/ui/interfaces/battle/cheatMode.ts
@@ -149,7 +149,7 @@ export class CheatModeUI {
this.moneyBtns.forEach(btn => {
btn.addEventListener("click", () => {
const amount = parseInt(btn.dataset.value!);
- this.world.user.money += amount;
+ this.world.addMoney(amount);
});
});
}
@@ -164,7 +164,7 @@ export class CheatModeUI {
this.confirmCustomMoney.addEventListener("click", () => {
const amount = parseInt(this.customMoneyInput.value);
if (amount && amount > 0) {
- this.world.user.money += amount;
+ this.world.addMoney(amount);
}
this.customMoneyDialog.style.display = "none";
});
diff --git a/src/ui/interfaces/battle/gameController.ts b/src/ui/interfaces/battle/gameController.ts
index d4d425c..ef01d43 100644
--- a/src/ui/interfaces/battle/gameController.ts
+++ b/src/ui/interfaces/battle/gameController.ts
@@ -247,7 +247,7 @@ export class GameController {
}
// Check for game failure
- if ((this.world.rootBuilding as any).isDead()) {
+ if ((this.world.getBaseBuilding() as any).isDead()) {
SaveManager.clearSave(this.mode, this.haveGroup);
this.callbacks.onFailure();
if (this.rafId !== null) {
diff --git a/src/ui/interfaces/battle/index.ts b/src/ui/interfaces/battle/index.ts
index 5295fb8..b83100a 100644
--- a/src/ui/interfaces/battle/index.ts
+++ b/src/ui/interfaces/battle/index.ts
@@ -18,6 +18,9 @@ import type { CanvasWithInputHandler, BattleModeConfig } from './types';
// Re-export types for external use
export type { BattleModeConfig, GameEntity, CanvasWithInputHandler } from './types';
+// Re-export multiplayer battle mode
+export { startMultiplayerBattleMode } from './multiplayerBattleMode';
+
/**
* Start battle mode
* @param mode - Game mode: "easy", "normal", "hard"
@@ -42,7 +45,7 @@ export function startBattleMode(mode: string, haveGroup: boolean = true, loadedS
if (!haveGroup) {
world.haveFlow = false;
if (mode === "hard") {
- world.user.money = 1000;
+ world.setMoney(1000);
}
}
diff --git a/src/ui/interfaces/battle/manualCannonPanel.ts b/src/ui/interfaces/battle/manualCannonPanel.ts
new file mode 100644
index 0000000..4566422
--- /dev/null
+++ b/src/ui/interfaces/battle/manualCannonPanel.ts
@@ -0,0 +1,403 @@
+/**
+ * ManualCannonPanel - UI panel for ManualCannon tower targeting
+ *
+ * Supports both single-player (direct cannon manipulation) and
+ * multiplayer (network message dispatch) modes.
+ */
+
+import type { NetworkClient } from '../../../network/networkClient';
+import { Vector } from '../../../core/math/vector';
+import { scalePeriod } from '../../../core/speedScale';
+
+/** Union type for cannon-like objects (real TowerManualCannon or TowerRenderProxy) */
+interface CannonLike {
+ id: string | null;
+ currentAmmo: number;
+ maxAmmo: number;
+ rangeR: number;
+ inValidTerritory: boolean;
+ semiAutoMode: boolean;
+ // Optional fields only present in single-player mode
+ explosionDamage?: number;
+ getDamageMultiplier?: () => number;
+ reloadProgress?: number;
+ reloadTime?: number;
+ // Single-player local methods
+ setMarkedTarget?: (pos: Vector, radius: number) => void;
+ clearMarkedTarget?: () => void;
+ fireAt?: (pos: Vector) => boolean;
+ isValidTargetPosition?: (pos: Vector) => boolean;
+}
+
+/**
+ * ManualCannonPanel class - Manages the manual cannon targeting UI
+ */
+export class ManualCannonPanel {
+ private panelEl: HTMLElement | null = null;
+ private currentCannon: CannonLike | null = null;
+ private refreshInterval: ReturnType | null = null;
+ private isTargetingMode: boolean = false;
+ private _semiAutoSelection: boolean = false;
+ private abortSignal: AbortSignal;
+ private networkClient: NetworkClient | undefined;
+ private onTargetCallback: ((pos: Vector) => void) | null = null;
+ private onSemiAutoCallback: ((pos: Vector, radius: number) => void) | null = null;
+
+ constructor(abortSignal: AbortSignal, networkClient?: NetworkClient) {
+ this.abortSignal = abortSignal;
+ this.networkClient = networkClient;
+ this.createPanel();
+ }
+
+ /**
+ * Create panel DOM element
+ */
+ private createPanel(): void {
+ this.panelEl = document.createElement('div');
+ this.panelEl.id = 'manualCannonPanel';
+ this.panelEl.style.display = 'none';
+
+ this.panelEl.innerHTML = `
+
+
+
+ 弹药:
+ 3/3
+
+
+ 伤害:
+ 100
+
+
+ 射程:
+ 400
+
+
+
+
+
+
+
+
+
+ `;
+
+ document.body.appendChild(this.panelEl);
+
+ // Bind close button
+ const closeBtn = this.panelEl.querySelector('.cannon-close');
+ closeBtn?.addEventListener('click', () => this.hide(), { signal: this.abortSignal });
+
+ // Bind fire button
+ const fireBtn = this.panelEl.querySelector('.cannon-fire-btn');
+ fireBtn?.addEventListener('click', () => this.enterTargetingMode(), { signal: this.abortSignal });
+
+ // Bind semi-auto button
+ const semiBtn = this.panelEl.querySelector('.cannon-semi-btn');
+ semiBtn?.addEventListener('click', () => this.enterSemiAutoMode(), { signal: this.abortSignal });
+
+ // Bind clear button
+ const clearBtn = this.panelEl.querySelector('.cannon-clear-btn');
+ clearBtn?.addEventListener('click', () => this.clearSemiAuto(), { signal: this.abortSignal });
+ }
+
+ /**
+ * Show panel for a cannon
+ */
+ show(cannon: CannonLike, screenPos: { x: number; y: number }): void {
+ if (!this.panelEl) return;
+
+ this.currentCannon = cannon;
+
+ // Position panel
+ this.panelEl.style.left = `${screenPos.x + 10}px`;
+ this.panelEl.style.top = `${screenPos.y + 10}px`;
+ this.panelEl.style.display = 'block';
+
+ // Update display
+ this.updateDisplay();
+
+ // Start refresh interval
+ this.startRefresh();
+ }
+
+ /**
+ * Hide panel
+ */
+ hide(): void {
+ if (!this.panelEl) return;
+
+ this.panelEl.style.display = 'none';
+ this.currentCannon = null;
+ this.isTargetingMode = false;
+ this.stopRefresh();
+ }
+
+ /**
+ * Check if panel is visible
+ */
+ isVisible(): boolean {
+ return this.panelEl?.style.display === 'block';
+ }
+
+ /**
+ * Check if in targeting mode
+ */
+ isInTargetingMode(): boolean {
+ return this.isTargetingMode;
+ }
+
+ /**
+ * Get current cannon
+ */
+ getCurrentCannon(): CannonLike | null {
+ return this.currentCannon;
+ }
+
+ /**
+ * Update panel display
+ */
+ private updateDisplay(): void {
+ if (!this.panelEl || !this.currentCannon) return;
+
+ const cannon = this.currentCannon;
+
+ // Update ammo
+ const ammoEl = this.panelEl.querySelector('.cannon-ammo');
+ if (ammoEl) {
+ ammoEl.textContent = `${cannon.currentAmmo}/${cannon.maxAmmo}`;
+ }
+
+ // Update damage (only available in single-player mode)
+ const damageEl = this.panelEl.querySelector('.cannon-damage');
+ if (damageEl) {
+ if (cannon.explosionDamage !== undefined && cannon.getDamageMultiplier) {
+ const actualDamage = Math.round(cannon.explosionDamage * cannon.getDamageMultiplier());
+ damageEl.textContent = `${actualDamage}`;
+ } else {
+ damageEl.textContent = '-';
+ }
+ }
+
+ // Update range
+ const rangeEl = this.panelEl.querySelector('.cannon-range');
+ if (rangeEl) {
+ rangeEl.textContent = `${cannon.rangeR}`;
+ }
+
+ // Update reload bar (only available in single-player mode)
+ const reloadBar = this.panelEl.querySelector('.cannon-reload-progress') as HTMLElement;
+ if (reloadBar) {
+ if (cannon.reloadProgress !== undefined && cannon.reloadTime !== undefined
+ && cannon.currentAmmo < cannon.maxAmmo) {
+ const progress = (cannon.reloadProgress / cannon.reloadTime) * 100;
+ reloadBar.style.width = `${progress}%`;
+ reloadBar.style.display = 'block';
+ } else {
+ reloadBar.style.width = '0%';
+ reloadBar.style.display = 'none';
+ }
+ }
+
+ // Update button states
+ const fireBtn = this.panelEl.querySelector('.cannon-fire-btn') as HTMLButtonElement;
+ const semiBtn = this.panelEl.querySelector('.cannon-semi-btn') as HTMLButtonElement;
+ const clearBtn = this.panelEl.querySelector('.cannon-clear-btn') as HTMLButtonElement;
+
+ if (fireBtn) {
+ fireBtn.disabled = cannon.currentAmmo <= 0 || !cannon.inValidTerritory;
+ fireBtn.textContent = this.isTargetingMode ? '取消瞄准' : '瞄准射击';
+ }
+
+ if (semiBtn) {
+ semiBtn.disabled = !cannon.inValidTerritory;
+ semiBtn.textContent = cannon.semiAutoMode ? '更改标记' : '标记区域';
+ }
+
+ if (clearBtn) {
+ clearBtn.style.display = cannon.semiAutoMode ? 'block' : 'none';
+ }
+
+ // Update hint
+ const hintEl = this.panelEl.querySelector('.cannon-hint');
+ if (hintEl) {
+ if (!cannon.inValidTerritory) {
+ hintEl.textContent = '领地无效,无法使用';
+ } else if (this.isTargetingMode) {
+ hintEl.textContent = '点击地图选择目标位置';
+ } else if (cannon.currentAmmo <= 0 && cannon.reloadTime !== undefined
+ && cannon.reloadProgress !== undefined) {
+ const seconds = Math.ceil((cannon.reloadTime - cannon.reloadProgress) / scalePeriod(60));
+ hintEl.textContent = `装填中... ${seconds}秒`;
+ } else if (cannon.currentAmmo <= 0) {
+ hintEl.textContent = '装填中...';
+ } else if (cannon.semiAutoMode) {
+ hintEl.textContent = '半自动模式:自动攻击标记区域';
+ } else {
+ hintEl.textContent = '点击"瞄准射击"选择目标';
+ }
+ }
+ }
+
+ /**
+ * Enter targeting mode
+ */
+ private enterTargetingMode(): void {
+ if (!this.currentCannon) return;
+
+ if (this.isTargetingMode) {
+ // Cancel targeting mode
+ this.isTargetingMode = false;
+ } else {
+ if (this.currentCannon.currentAmmo > 0 && this.currentCannon.inValidTerritory) {
+ this.isTargetingMode = true;
+ }
+ }
+ this.updateDisplay();
+ }
+
+ /**
+ * Enter semi-auto mode (mark target area)
+ */
+ private enterSemiAutoMode(): void {
+ if (!this.currentCannon || !this.currentCannon.inValidTerritory) return;
+
+ // Request semi-auto area selection from game controller
+ // This will be handled by the game controller's click handler
+ this.isTargetingMode = true;
+ const hintEl = this.panelEl?.querySelector('.cannon-hint');
+ if (hintEl) {
+ hintEl.textContent = '点击地图选择半自动目标区域';
+ }
+
+ // Store selection type for handleTargetSelected
+ this._semiAutoSelection = true;
+ }
+
+ /**
+ * Clear semi-auto mode
+ */
+ private clearSemiAuto(): void {
+ if (!this.currentCannon) return;
+
+ if (this.networkClient && this.currentCannon.id) {
+ this.networkClient.cannonClearAutoTarget(this.currentCannon.id);
+ } else if (this.currentCannon.clearMarkedTarget) {
+ this.currentCannon.clearMarkedTarget();
+ }
+ this.updateDisplay();
+ }
+
+ /**
+ * Handle target selection from game controller
+ */
+ handleTargetSelected(worldPos: Vector): boolean {
+ if (!this.currentCannon || !this.isTargetingMode) return false;
+
+ const cannon = this.currentCannon;
+ const cannonId = cannon.id;
+
+ // Check if this is semi-auto selection
+ if (this._semiAutoSelection) {
+ const radius = 100;
+ if (this.networkClient && cannonId) {
+ this.networkClient.cannonSetAutoTarget({
+ towerId: cannonId,
+ targetX: worldPos.x,
+ targetY: worldPos.y,
+ radius,
+ });
+ } else if (cannon.setMarkedTarget) {
+ cannon.setMarkedTarget(worldPos, radius);
+ }
+ this._semiAutoSelection = false;
+ this.isTargetingMode = false;
+ this.updateDisplay();
+ return true;
+ }
+
+ // Regular fire mode
+ if (this.networkClient && cannonId) {
+ this.networkClient.cannonFire({
+ towerId: cannonId,
+ targetX: worldPos.x,
+ targetY: worldPos.y,
+ });
+ // In multiplayer, assume server handles ammo
+ if (cannon.currentAmmo <= 1) {
+ this.isTargetingMode = false;
+ }
+ this.updateDisplay();
+ return true;
+ }
+
+ // Single-player: validate and fire locally
+ if (cannon.isValidTargetPosition && cannon.fireAt) {
+ if (cannon.isValidTargetPosition(worldPos)) {
+ const success = cannon.fireAt(worldPos);
+ if (success) {
+ // Stay in targeting mode if still have ammo
+ if (cannon.currentAmmo <= 0) {
+ this.isTargetingMode = false;
+ }
+ this.updateDisplay();
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Set callback for target selection
+ */
+ setOnTargetCallback(callback: (pos: Vector) => void): void {
+ this.onTargetCallback = callback;
+ }
+
+ /**
+ * Set callback for semi-auto area selection
+ */
+ setOnSemiAutoCallback(callback: (pos: Vector, radius: number) => void): void {
+ this.onSemiAutoCallback = callback;
+ }
+
+ /**
+ * Start refresh interval
+ */
+ private startRefresh(): void {
+ this.stopRefresh();
+ this.refreshInterval = setInterval(() => {
+ if (this.isVisible() && this.currentCannon) {
+ this.updateDisplay();
+ }
+ }, 100);
+ }
+
+ /**
+ * Stop refresh interval
+ */
+ private stopRefresh(): void {
+ if (this.refreshInterval) {
+ clearInterval(this.refreshInterval);
+ this.refreshInterval = null;
+ }
+ }
+
+ /**
+ * Cleanup
+ */
+ destroy(): void {
+ this.stopRefresh();
+ if (this.panelEl && this.panelEl.parentNode) {
+ this.panelEl.parentNode.removeChild(this.panelEl);
+ }
+ this.panelEl = null;
+ }
+}
diff --git a/src/ui/interfaces/battle/multiplayerBattleMode.ts b/src/ui/interfaces/battle/multiplayerBattleMode.ts
new file mode 100644
index 0000000..9537716
--- /dev/null
+++ b/src/ui/interfaces/battle/multiplayerBattleMode.ts
@@ -0,0 +1,187 @@
+/**
+ * Multiplayer Battle Mode Entry Point
+ * Initializes and manages the multiplayer game session
+ */
+
+import { getNetworkClient, NetworkEvent } from '../../../network/networkClient';
+import { NetworkWorldAdapter } from '../../../network/rendering/networkWorldAdapter';
+import { WorldRenderer } from '../../../game/rendering/worldRenderer';
+import { Sounds } from '../../../systems/sound/sounds';
+import { gotoPage } from '../../navigation/router';
+import { showGameEndModal } from '../../components';
+
+import { MultiplayerWorldFacade } from './multiplayerWorldFacade';
+import { MultiplayerGameController } from './multiplayerGameController';
+import { MultiplayerUIController } from './multiplayerUIController';
+import { PanelManager } from './panelManager';
+import { KeyboardHandler } from './keyboardHandler';
+import type { CanvasWithInputHandler } from './types';
+
+/**
+ * Start multiplayer battle mode
+ * Called when game starts from waiting room
+ */
+export function startMultiplayerBattleMode(): void {
+ const canvasEle = document.querySelector('#mainCanvas') as CanvasWithInputHandler;
+ const client = getNetworkClient();
+
+ // Validate game state
+ const gameState = client.gameState;
+ if (!gameState) {
+ console.error('[MultiplayerBattle] No game state available');
+ gotoPage('multiplayer-lobby-interface');
+ return;
+ }
+
+ console.log('[MultiplayerBattle] Starting multiplayer battle mode');
+
+ // Switch background music
+ Sounds.switchBgm('war');
+
+ // Get map dimensions from game state
+ const mapConfig = (gameState as { mapConfig?: { width: number; height: number } }).mapConfig;
+ const worldWidth = mapConfig?.width ?? 6000;
+ const worldHeight = mapConfig?.height ?? 4000;
+
+ // Create network world adapter
+ const adapter = new NetworkWorldAdapter(
+ client,
+ client.playerId,
+ canvasEle.width,
+ canvasEle.height,
+ worldWidth,
+ worldHeight
+ );
+
+ // Bind adapter to game state
+ adapter.bindGameState(gameState as any);
+ adapter.bindEventHandlers();
+
+ // Create world renderer
+ const renderer = new WorldRenderer(adapter.getRendererContext());
+
+ // Create world facade for PanelManager compatibility
+ const worldFacade = new MultiplayerWorldFacade(adapter, client);
+
+ // Generate unique session ID
+ const sessionId = `mp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
+
+ // Cleanup function
+ let isCleanedUp = false;
+ const cleanup = () => {
+ if (isCleanedUp) return;
+ isCleanedUp = true;
+
+ console.log('[MultiplayerBattle] Cleaning up...');
+
+ // Remove event listeners
+ client.events.off(NetworkEvent.GAME_ENDED, onGameEnded);
+ client.events.off(NetworkEvent.PLAYER_ELIMINATED, onPlayerEliminated);
+
+ // Cleanup controllers
+ keyboardHandler.detach();
+ panelManager.destroy();
+ uiController.destroy();
+
+ // Dispose adapter
+ adapter.dispose();
+
+ // Restore UI state
+ Sounds.switchBgm('main');
+ };
+
+ // Game end handler
+ const onGameEnded = (data: unknown) => {
+ const endData = data as { winnerId?: string; reason?: string };
+ console.log('[MultiplayerBattle] Game ended:', endData);
+
+ const isWinner = endData.winnerId === client.playerId;
+ const message = isWinner ? '恭喜你获得胜利!' : '你输了,再接再厉!';
+
+ gameController.gameEnd = true;
+ cleanup();
+
+ showGameEndModal({
+ isWinner,
+ message,
+ onReturnToLobby: () => gotoPage('multiplayer-lobby-interface')
+ });
+ };
+
+ // Player eliminated handler
+ const onPlayerEliminated = (data: unknown) => {
+ const elimData = data as { playerId?: string };
+ if (elimData.playerId === client.playerId) {
+ console.log('[MultiplayerBattle] Local player eliminated');
+ gameController.gameEnd = true;
+ cleanup();
+ showGameEndModal({
+ isWinner: false,
+ message: '你的基地被摧毁了!',
+ onReturnToLobby: () => gotoPage('multiplayer-lobby-interface')
+ });
+ }
+ };
+
+ // Create game controller
+ const gameController = new MultiplayerGameController(
+ adapter,
+ renderer,
+ canvasEle,
+ {
+ onGameEnd: cleanup,
+ updateZoomLevel: () => uiController.updateZoomLevel()
+ }
+ );
+
+ // Create UI controller
+ const uiController = new MultiplayerUIController(
+ worldFacade,
+ canvasEle,
+ gameController,
+ client,
+ {
+ onBackClick: () => {
+ gameController.gameEnd = true;
+ },
+ requestPauseRender: () => {
+ gameController.requestRender();
+ }
+ }
+ );
+ uiController.init();
+ uiController.initInputHandler();
+
+ // Create keyboard handler (limited functionality in multiplayer)
+ const keyboardHandler = new KeyboardHandler(worldFacade as any, {
+ togglePause: () => {
+ // No pause in multiplayer
+ console.log('[MultiplayerBattle] Pause not available in multiplayer');
+ },
+ getIsGamePause: () => false // Always false in multiplayer
+ });
+ keyboardHandler.attach();
+
+ // Create panel manager with network client
+ const panelManager = new PanelManager(
+ worldFacade,
+ canvasEle,
+ sessionId,
+ uiController.getEventSignal(),
+ {
+ requestPauseRender: () => gameController.requestRender(),
+ getGameEnd: () => gameController.gameEnd
+ },
+ client // Pass network client for multiplayer operations
+ );
+ panelManager.init();
+
+ // Subscribe to game events
+ client.events.on(NetworkEvent.GAME_ENDED, onGameEnded);
+ client.events.on(NetworkEvent.PLAYER_ELIMINATED, onPlayerEliminated);
+
+ // Start game loop
+ gameController.start();
+
+ console.log('[MultiplayerBattle] Game started successfully');
+}
diff --git a/src/ui/interfaces/battle/multiplayerGameController.ts b/src/ui/interfaces/battle/multiplayerGameController.ts
new file mode 100644
index 0000000..00d756b
--- /dev/null
+++ b/src/ui/interfaces/battle/multiplayerGameController.ts
@@ -0,0 +1,104 @@
+/**
+ * Multiplayer Game Controller
+ * Simplified game loop for network-based multiplayer mode
+ * Only handles rendering - game logic runs on server
+ */
+
+import type { NetworkWorldAdapter } from '../../../network/rendering/networkWorldAdapter';
+import { WorldRenderer } from '../../../game/rendering/worldRenderer';
+
+export interface MultiplayerGameCallbacks {
+ onGameEnd: () => void;
+ updateZoomLevel: () => void;
+}
+
+/**
+ * Game controller for multiplayer mode
+ * Manages the render loop without local game logic
+ */
+export class MultiplayerGameController {
+ private _gameEnd = false;
+ private _rafId: number | null = null;
+ private _lastFrameTime = 0;
+
+ private _adapter: NetworkWorldAdapter;
+ private _renderer: WorldRenderer;
+ private _canvasEle: HTMLCanvasElement;
+ private _callbacks: MultiplayerGameCallbacks;
+
+ constructor(
+ adapter: NetworkWorldAdapter,
+ renderer: WorldRenderer,
+ canvasEle: HTMLCanvasElement,
+ callbacks: MultiplayerGameCallbacks
+ ) {
+ this._adapter = adapter;
+ this._renderer = renderer;
+ this._canvasEle = canvasEle;
+ this._callbacks = callbacks;
+ }
+
+ // === Public Accessors ===
+
+ get gameEnd(): boolean {
+ return this._gameEnd;
+ }
+
+ set gameEnd(value: boolean) {
+ this._gameEnd = value;
+ }
+
+ // === Lifecycle ===
+
+ /**
+ * Start the render loop
+ */
+ start(): void {
+ this._lastFrameTime = performance.now();
+ this._rafId = requestAnimationFrame(this._mainLoop);
+ }
+
+ /**
+ * Stop the render loop and cleanup
+ */
+ stop(): void {
+ if (this._rafId !== null) {
+ cancelAnimationFrame(this._rafId);
+ this._rafId = null;
+ }
+ this._callbacks.onGameEnd();
+ }
+
+ /**
+ * Request a re-render (for UI interactions during pause)
+ */
+ requestRender(): void {
+ // In multiplayer mode, always rendering so no-op
+ }
+
+ // === Private Methods ===
+
+ /**
+ * Main render loop
+ */
+ private _mainLoop = (now: number): void => {
+ if (this._gameEnd) {
+ this.stop();
+ return;
+ }
+
+ // Calculate delta time (capped to avoid spiral of death)
+ const dt = Math.min(now - this._lastFrameTime, 200);
+ this._lastFrameTime = now;
+
+ // Update adapter (sync state, interpolation, effects)
+ this._adapter.update(dt);
+
+ // Render
+ this._renderer.render(this._canvasEle);
+ this._callbacks.updateZoomLevel();
+
+ // Schedule next frame
+ this._rafId = requestAnimationFrame(this._mainLoop);
+ };
+}
diff --git a/src/ui/interfaces/battle/multiplayerUIController.ts b/src/ui/interfaces/battle/multiplayerUIController.ts
new file mode 100644
index 0000000..02b998d
--- /dev/null
+++ b/src/ui/interfaces/battle/multiplayerUIController.ts
@@ -0,0 +1,296 @@
+/**
+ * Multiplayer UI Controller
+ * Simplified UI controller for multiplayer battle mode
+ * Hides speed controls and adds surrender button
+ */
+
+import { InputHandler } from '../../../core/input/inputHandler';
+import type { MultiplayerWorldFacade } from './multiplayerWorldFacade';
+import type { MultiplayerGameController } from './multiplayerGameController';
+import type { NetworkClient } from '../../../network/networkClient';
+import type { CanvasWithInputHandler } from './types';
+
+export interface MultiplayerUICallbacks {
+ onBackClick: () => void;
+ requestPauseRender: () => void;
+}
+
+/**
+ * UI controller for multiplayer mode
+ * Provides zoom controls and surrender functionality
+ */
+export class MultiplayerUIController {
+ private _worldFacade: MultiplayerWorldFacade;
+ private _canvasEle: CanvasWithInputHandler;
+ private _gameController: MultiplayerGameController;
+ private _networkClient: NetworkClient;
+ private _callbacks: MultiplayerUICallbacks;
+
+ private _inputHandler: InputHandler | null = null;
+ private _eventSignal: AbortSignal | null = null;
+ private _zoomLevelSpan: HTMLElement | null = null;
+ private _surrenderBtn: HTMLButtonElement | null = null;
+
+ constructor(
+ worldFacade: MultiplayerWorldFacade,
+ canvasEle: CanvasWithInputHandler,
+ gameController: MultiplayerGameController,
+ networkClient: NetworkClient,
+ callbacks: MultiplayerUICallbacks
+ ) {
+ this._worldFacade = worldFacade;
+ this._canvasEle = canvasEle;
+ this._gameController = gameController;
+ this._networkClient = networkClient;
+ this._callbacks = callbacks;
+ }
+
+ // === Initialization ===
+
+ /**
+ * Initialize UI elements
+ */
+ init(): void {
+ this._hideSpeedControls();
+ this._hidePauseButton();
+ this._hideSaveControls();
+ this._addSurrenderButton();
+ this._showChoiceButton();
+ }
+
+ /**
+ * Initialize input handler for camera controls
+ */
+ initInputHandler(): InputHandler {
+ // Cleanup old InputHandler
+ if (this._canvasEle._inputHandler) {
+ this._canvasEle._inputHandler.destroy();
+ }
+
+ // Create input handler
+ this._inputHandler = new InputHandler(
+ this._worldFacade.camera,
+ this._canvasEle
+ );
+ this._canvasEle._inputHandler = this._inputHandler;
+ this._inputHandler.onRenderRequest = () => this._callbacks.requestPauseRender();
+
+ // Setup AbortController for event cleanup
+ if (this._canvasEle._eventAbortController) {
+ this._canvasEle._eventAbortController.abort();
+ }
+ this._canvasEle._eventAbortController = new AbortController();
+ this._eventSignal = this._canvasEle._eventAbortController.signal;
+
+ // Bind zoom buttons
+ this._bindZoomButtons();
+
+ // Bind back button
+ this._bindBackButton();
+
+ return this._inputHandler;
+ }
+
+ // === Public Accessors ===
+
+ /**
+ * Get event abort signal for cleanup
+ */
+ getEventSignal(): AbortSignal {
+ if (!this._eventSignal) {
+ throw new Error('MultiplayerUIController.getEventSignal() called before initInputHandler()');
+ }
+ return this._eventSignal;
+ }
+
+ /**
+ * Get input handler
+ */
+ getInputHandler(): InputHandler | null {
+ return this._inputHandler;
+ }
+
+ /**
+ * Update zoom level display
+ */
+ updateZoomLevel(): void {
+ if (this._zoomLevelSpan) {
+ this._zoomLevelSpan.textContent = Math.round(this._worldFacade.camera.zoom * 100) + '%';
+ }
+ }
+
+ /**
+ * Cleanup UI elements
+ */
+ destroy(): void {
+ // Remove surrender button
+ if (this._surrenderBtn && this._surrenderBtn.parentNode) {
+ this._surrenderBtn.parentNode.removeChild(this._surrenderBtn);
+ this._surrenderBtn = null;
+ }
+
+ // Restore hidden controls
+ this._showSpeedControls();
+ this._showPauseButton();
+ }
+
+ // === Private Methods ===
+
+ /**
+ * Hide speed control panel (multiplayer has no speed control)
+ */
+ private _hideSpeedControls(): void {
+ const panel = document.getElementById('speedControlPanel');
+ if (panel) {
+ panel.style.display = 'none';
+ }
+
+ // Also hide individual speed buttons
+ const speedBtns = document.querySelectorAll('.speedBtn');
+ speedBtns.forEach(btn => {
+ (btn as HTMLElement).style.display = 'none';
+ });
+ }
+
+ /**
+ * Restore speed controls (for cleanup)
+ */
+ private _showSpeedControls(): void {
+ const panel = document.getElementById('speedControlPanel');
+ if (panel) {
+ panel.style.display = '';
+ }
+
+ const speedBtns = document.querySelectorAll('.speedBtn');
+ speedBtns.forEach(btn => {
+ (btn as HTMLElement).style.display = '';
+ });
+ }
+
+ /**
+ * Hide pause button (multiplayer cannot pause)
+ */
+ private _hidePauseButton(): void {
+ const pauseBtn = document.getElementById('pauseBtn');
+ if (pauseBtn) {
+ pauseBtn.style.display = 'none';
+ }
+ }
+
+ /**
+ * Restore pause button (for cleanup)
+ */
+ private _showPauseButton(): void {
+ const pauseBtn = document.getElementById('pauseBtn');
+ if (pauseBtn) {
+ pauseBtn.style.display = '';
+ }
+ }
+
+ /**
+ * Hide save/export controls (multiplayer doesn't support saves)
+ */
+ private _hideSaveControls(): void {
+ const exportBtn = document.getElementById('exportBtn');
+ if (exportBtn) {
+ exportBtn.style.display = 'none';
+ }
+ }
+
+ /**
+ * Show choice button panel
+ */
+ private _showChoiceButton(): void {
+ const choiceBtn = document.querySelector('.choiceBtn') as HTMLElement;
+ if (choiceBtn) {
+ choiceBtn.style.display = 'block';
+ }
+ }
+
+ /**
+ * Bind zoom buttons
+ */
+ private _bindZoomButtons(): void {
+ const zoomInBtn = document.getElementById('zoomInBtn');
+ const zoomOutBtn = document.getElementById('zoomOutBtn');
+ const homeBtn = document.getElementById('homeBtn');
+ this._zoomLevelSpan = document.getElementById('zoomLevel');
+
+ const signal = this._eventSignal ?? undefined;
+
+ if (zoomInBtn) {
+ zoomInBtn.addEventListener('click', () => {
+ this._inputHandler?.zoomIn();
+ }, { signal });
+ }
+
+ if (zoomOutBtn) {
+ zoomOutBtn.addEventListener('click', () => {
+ this._inputHandler?.zoomOut();
+ }, { signal });
+ }
+
+ if (homeBtn) {
+ homeBtn.addEventListener('click', () => {
+ const base = this._worldFacade.getBaseBuilding() as { pos?: { x: number; y: number } };
+ if (base?.pos) {
+ this._worldFacade.camera.centerOn(base.pos as any);
+ this._callbacks.requestPauseRender();
+ }
+ }, { signal });
+ }
+ }
+
+ /**
+ * Bind back button
+ */
+ private _bindBackButton(): void {
+ const backBtn = document.getElementById('backBtn');
+ const signal = this._eventSignal ?? undefined;
+
+ if (backBtn) {
+ backBtn.addEventListener('click', () => {
+ // In multiplayer, back means surrender
+ if (confirm('确定要投降吗?这将结束当前对局。')) {
+ this._networkClient.surrender();
+ this._callbacks.onBackClick();
+ }
+ }, { signal });
+ }
+ }
+
+ /**
+ * Add surrender button to UI
+ */
+ private _addSurrenderButton(): void {
+ // Find the control panel to add surrender button
+ const controlPanel = document.querySelector('.war-interface .controlPanel') ??
+ document.querySelector('.controlPanel');
+
+ if (controlPanel && !this._surrenderBtn) {
+ this._surrenderBtn = document.createElement('button');
+ this._surrenderBtn.id = 'surrenderBtn';
+ this._surrenderBtn.className = 'controlBtn surrenderBtn';
+ this._surrenderBtn.textContent = '投降';
+ this._surrenderBtn.style.cssText = `
+ background-color: #dc3545;
+ color: white;
+ border: none;
+ padding: 8px 16px;
+ margin: 4px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+ `;
+
+ this._surrenderBtn.addEventListener('click', () => {
+ if (confirm('确定要投降吗?这将结束当前对局。')) {
+ this._networkClient.surrender();
+ this._gameController.gameEnd = true;
+ }
+ });
+
+ controlPanel.appendChild(this._surrenderBtn);
+ }
+ }
+}
diff --git a/src/ui/interfaces/battle/multiplayerWorldFacade.ts b/src/ui/interfaces/battle/multiplayerWorldFacade.ts
new file mode 100644
index 0000000..4367aca
--- /dev/null
+++ b/src/ui/interfaces/battle/multiplayerWorldFacade.ts
@@ -0,0 +1,302 @@
+/**
+ * Multiplayer World Facade
+ * Wraps NetworkWorldAdapter to provide a World-like interface for PanelManager
+ */
+
+import type { NetworkWorldAdapter } from '../../../network/rendering/networkWorldAdapter';
+import type { NetworkClient } from '../../../network/networkClient';
+import type { Camera } from '../../../core/camera';
+import type { IEffect } from '../../../types/game';
+import type { GameEntity, PanelManagerWorldLike, PanelManagerTerritory, PanelManagerFog } from './types';
+import { getTowerCombatData } from '@shared/config/towerCombatMeta';
+import { getBuildingMeta } from '@shared/config/buildingMeta';
+
+/**
+ * Facade class that bridges NetworkWorldAdapter to World-like interface
+ * Required by PanelManager which expects a World instance
+ */
+export class MultiplayerWorldFacade implements PanelManagerWorldLike {
+ private _adapter: NetworkWorldAdapter;
+ private _networkClient: NetworkClient;
+
+ constructor(adapter: NetworkWorldAdapter, networkClient: NetworkClient) {
+ this._adapter = adapter;
+ this._networkClient = networkClient;
+ }
+
+ // === Camera Access ===
+
+ get camera(): Camera {
+ return this._adapter.camera;
+ }
+
+ // === World Dimensions ===
+
+ get width(): number {
+ return this._adapter.getRendererContext().width;
+ }
+
+ get height(): number {
+ return this._adapter.getRendererContext().height;
+ }
+
+ // === Entity Collections ===
+
+ /**
+ * Get all buildings (towers + buildings) for panel display
+ */
+ getAllBuildingArr(): GameEntity[] {
+ const ctx = this._adapter.getRendererContext();
+ // Combine towers and buildings as GameEntity-compatible objects
+ const result: GameEntity[] = [];
+
+ for (const tower of ctx.batterys) {
+ result.push(tower as unknown as GameEntity);
+ }
+
+ for (const building of ctx.buildings) {
+ result.push(building as unknown as GameEntity);
+ }
+
+ return result;
+ }
+
+ /**
+ * Get all towers
+ */
+ get batterys(): unknown[] {
+ return this._adapter.getRendererContext().batterys;
+ }
+
+ /**
+ * Get all buildings
+ */
+ get buildings(): unknown[] {
+ return this._adapter.getRendererContext().buildings;
+ }
+
+ /**
+ * Get all monsters
+ */
+ get monsters(): Set {
+ return this._adapter.getRendererContext().monsters;
+ }
+
+ // === Money Operations (Read-only in multiplayer) ===
+
+ getMoney(): number {
+ return this._adapter.getRendererContext().user.money;
+ }
+
+ /**
+ * Spend money - sends request to server, returns false (server validates)
+ */
+ spendMoney(_amount: number): boolean {
+ // In multiplayer mode, money operations are validated by server
+ // Return false to indicate local operation not permitted
+ return false;
+ }
+
+ /**
+ * Add money - no-op in multiplayer (server controls)
+ */
+ addMoney(_amount: number): void {
+ // No-op: server controls money
+ }
+
+ /**
+ * Set money - no-op in multiplayer (server controls)
+ */
+ setMoney(_amount: number): void {
+ // No-op: server controls money
+ }
+
+ // === Effects ===
+
+ /**
+ * Add local visual effect
+ * Note: In multiplayer mode, effects are handled differently through LocalEffectsManager
+ * This method is a no-op as effects are generated from server events
+ */
+ addEffect(_effect: IEffect): void {
+ // In multiplayer mode, visual effects are generated from server events
+ // via NetworkWorldAdapter.bindEventHandlers()
+ // PanelManager may call this but effects are handled differently
+ }
+
+ // === Tower Operations (via NetworkClient) ===
+
+ /**
+ * Request building a tower (sends to server with client prediction)
+ */
+ addTower(towerConfig: { towerType: string; pos: { x: number; y: number } }): void {
+ // Immediate ghost tower feedback
+ const combatMeta = getTowerCombatData(towerConfig.towerType);
+ this._adapter.prediction.predictBuild(
+ towerConfig.towerType,
+ towerConfig.pos.x,
+ towerConfig.pos.y,
+ combatMeta?.radius ?? 15,
+ combatMeta?.attackRadius ?? 200
+ );
+
+ // Send to server
+ this._networkClient.buildTower({
+ towerType: towerConfig.towerType,
+ x: towerConfig.pos.x,
+ y: towerConfig.pos.y
+ });
+ }
+
+ /**
+ * Request selling a tower (sends to server with client prediction)
+ */
+ sellTower(towerId: string): void {
+ this._adapter.prediction.predictSell(towerId);
+ this._networkClient.sellTower({ towerId });
+ }
+
+ /**
+ * Request upgrading a tower (sends to server)
+ * Note: UpgradeTowerPayload only takes towerId, upgrade type is handled differently
+ */
+ upgradeTower(towerId: string, upgradeType: string): void {
+ this._networkClient.upgradeTower({ towerId, targetType: upgradeType });
+ }
+
+ // === User State ===
+
+ /**
+ * User state object for rendering previews
+ */
+ get user() {
+ return this._adapter.getRendererContext().user;
+ }
+
+ // === Territory/Fog (disabled in multiplayer for now) ===
+
+ get territory(): PanelManagerTerritory | null {
+ const t = this._adapter.getRendererContext().territory;
+ if (!t) return null;
+ // Return a wrapper that satisfies PanelManagerTerritory
+ // In multiplayer, territory operations are no-ops
+ return {
+ isPositionInValidTerritory: () => true, // Allow building anywhere in multiplayer
+ markDirty: () => {},
+ removeBuildingIncremental: () => {},
+ addBuildingIncremental: () => {}
+ };
+ }
+
+ get fog(): PanelManagerFog | null {
+ const f = this._adapter.getRendererContext().fog;
+ if (!f) return null;
+ // Return a wrapper that satisfies PanelManagerFog
+ return {
+ markDirty: () => {}
+ };
+ }
+
+ // === Obstacles ===
+
+ get obstacles(): Array<{ intersectsCircle(circle: unknown): boolean }> {
+ return this._adapter.getRendererContext().obstacles as Array<{ intersectsCircle(circle: unknown): boolean }>;
+ }
+
+ // === Mines (not synced in network mode) ===
+
+ get mines(): Set {
+ return this._adapter.getRendererContext().mines;
+ }
+
+ // === Mine Operations (via NetworkClient) ===
+
+ upgradeMine(mineId: string): void {
+ this._networkClient.upgradeMine({ mineId });
+ }
+
+ repairMine(mineId: string): void {
+ this._networkClient.repairMine({ mineId });
+ }
+
+ downgradeMine(mineId: string): void {
+ this._networkClient.downgradeMine({ mineId });
+ }
+
+ sellMine(mineId: string): void {
+ this._networkClient.sellMine({ mineId });
+ }
+
+ // === Base Building ===
+
+ /**
+ * Get local player's base building
+ */
+ getBaseBuilding(): unknown {
+ const buildings = this._adapter.getRendererContext().buildings;
+ // Find the base building owned by local player
+ for (const building of buildings) {
+ const b = building as { gameType?: string; ownerId?: string };
+ if (b.gameType === 'Base') {
+ return building;
+ }
+ }
+ // Fallback to first building
+ return buildings[0] ?? null;
+ }
+
+ // === Game State ===
+
+ get haveFlow(): boolean {
+ return true; // Multiplayer always has waves
+ }
+
+ get mode(): string {
+ return 'multiplayer';
+ }
+
+ // === Adapter Access ===
+
+ get adapter(): NetworkWorldAdapter {
+ return this._adapter;
+ }
+
+ get networkClient(): NetworkClient {
+ return this._networkClient;
+ }
+
+ // === Building Operations ===
+
+ /**
+ * Add building - sends request to server
+ * Note: In multiplayer, building placement is validated by server
+ */
+ addBuilding(building: { buildingType: string; pos: { x: number; y: number } }): void {
+ const meta = getBuildingMeta(building.buildingType);
+ if (!meta) return;
+
+ this._adapter.prediction.predictBuild(
+ building.buildingType,
+ building.pos.x,
+ building.pos.y,
+ meta.radius,
+ 0
+ );
+
+ this._networkClient.buildBuilding({
+ buildingType: building.buildingType,
+ x: building.pos.x,
+ y: building.pos.y
+ });
+ }
+
+ // === Static Layer ===
+
+ /**
+ * Mark static layer dirty - no-op in multiplayer
+ * In multiplayer, rendering is handled by NetworkWorldAdapter
+ */
+ markStaticLayerDirty(): void {
+ // No-op: NetworkWorldAdapter handles rendering updates
+ }
+}
diff --git a/src/ui/interfaces/battle/panelManager.ts b/src/ui/interfaces/battle/panelManager.ts
index 76b32f0..f9b64be 100644
--- a/src/ui/interfaces/battle/panelManager.ts
+++ b/src/ui/interfaces/battle/panelManager.ts
@@ -2,17 +2,20 @@
* Panel manager - handles tower/building selection, level up, and mine panels
*/
-import { World } from '../../../game/world';
import { Vector } from '../../../core/math/vector';
import { Circle } from '../../../core/math/circle';
import { EffectText } from '../../../effects/effect';
-import { TowerFinallyCompat } from '../../../towers/index';
import { TOWER_IMG_WIDTH, TOWER_IMG_HEIGHT, TOWER_IMG_PRE_WIDTH, TOWER_IMG_PRE_HEIGHT } from '../../../towers/index';
import { TowerRegistry } from '../../../towers/towerRegistry';
import { getBuildingFuncArr } from '../../../buildings/index';
+import type { MonsterSpawner } from '../../../buildings/variants/monsterSpawner';
import { Mine } from '../../../systems/energy/mine';
import { VisionType } from '../../../systems/fog/visionConfig';
-import type { GameEntity, CanvasWithInputHandler } from './types';
+import { SpawnerPanel } from './spawnerPanel';
+import { ManualCannonPanel } from './manualCannonPanel';
+import type { TowerManualCannon } from '../../../towers/base/towerManualCannon';
+import type { GameEntity, CanvasWithInputHandler, PanelManagerWorldLike, PanelEntityLike, PanelCircleLike } from './types';
+import type { NetworkClient } from '../../../network/networkClient';
const BUILDING_FUNC_ARR = getBuildingFuncArr();
const LEVELUP_POOL_SIZE = 10;
@@ -38,7 +41,7 @@ export interface PanelManagerCallbacks {
}
export class PanelManager {
- private world: World;
+ private world: PanelManagerWorldLike;
private canvasEle: CanvasWithInputHandler;
private callbacks: PanelManagerCallbacks;
private sessionId: string;
@@ -84,23 +87,40 @@ export class PanelManager {
private refreshPanelInterval: ReturnType | null = null;
private freshBtnInterval: ReturnType | null = null;
+ // Spawner panel
+ private spawnerPanel: SpawnerPanel;
+
+ // Manual cannon panel
+ private manualCannonPanel: ManualCannonPanel;
+
+ // Network client for multiplayer mode (null = single-player)
+ private networkClient: NetworkClient | null = null;
+
constructor(
- world: World,
+ world: PanelManagerWorldLike,
canvasEle: CanvasWithInputHandler,
sessionId: string,
eventSignal: AbortSignal,
- callbacks: PanelManagerCallbacks
+ callbacks: PanelManagerCallbacks,
+ networkClient?: NetworkClient
) {
this.world = world;
this.canvasEle = canvasEle;
this.sessionId = sessionId;
this.eventSignal = eventSignal;
this.callbacks = callbacks;
+ this.networkClient = networkClient ?? null;
this.choiceBtn = document.querySelector(".choiceBtn") as HTMLElement;
this.smallLevelUpPanelEle = document.querySelector("#smallLevelUpPanel") as HTMLElement;
this.listEle = this.smallLevelUpPanelEle.querySelector(".levelUpItems") as HTMLElement;
this.otherItemsEle = this.smallLevelUpPanelEle.querySelector(".otherItems") as HTMLElement;
+
+ // Initialize spawner panel
+ this.spawnerPanel = new SpawnerPanel(eventSignal);
+
+ // Initialize manual cannon panel
+ this.manualCannonPanel = new ManualCannonPanel(eventSignal, this.networkClient ?? undefined);
}
/**
@@ -126,6 +146,10 @@ export class PanelManager {
clearInterval(this.freshBtnInterval);
this.freshBtnInterval = null;
}
+ // Cleanup spawner panel
+ this.spawnerPanel.destroy();
+ // Cleanup manual cannon panel
+ this.manualCannonPanel.destroy();
// 恢复默认光标
this.setMoveCursor(false);
}
@@ -245,23 +269,34 @@ export class PanelManager {
if (this.currentPanelEntity && this.currentClickPos) {
const price = parseInt(target.dataset.price!);
const towerName = target.dataset.towerName!;
- if (this.world.user.money >= price) {
- const pos = this.currentPanelEntity.pos.copy();
- this.world.user.money -= price;
- const newThing = TowerRegistry.create(towerName, this.world) as GameEntity;
- newThing.towerLevel = this.currentPanelEntity.towerLevel + 1;
- newThing.pos = pos;
- // Preserve vision attributes
- newThing.visionType = this.currentPanelEntity.visionType;
- newThing.visionLevel = this.currentPanelEntity.visionLevel;
- newThing.radarAngle = this.currentPanelEntity.radarAngle;
- this.world.addTower(newThing as any);
- this.currentPanelEntity.remove();
- this.showSmallLevelUpPanel(newThing, this.currentClickPos);
- } else {
- const et = new EffectText("钱不够!");
- et.pos = this.currentClickPos;
- this.world.addEffect(et as any);
+ const isMultiplayer = this.networkClient !== null;
+
+ if (isMultiplayer && this.currentPanelEntity.id) {
+ // Multiplayer: send upgrade request to server
+ this.networkClient!.upgradeTower({
+ towerId: this.currentPanelEntity.id,
+ targetType: towerName
+ });
+ this.hideLevelUpPanel();
+ } else if (!isMultiplayer) {
+ // Single-player: local handling
+ if (this.world.spendMoney(price)) {
+ const pos = this.currentPanelEntity.pos.copy();
+ const newThing = TowerRegistry.create(towerName, this.world) as GameEntity;
+ newThing.towerLevel = this.currentPanelEntity.towerLevel + 1;
+ newThing.pos = pos;
+ // Preserve vision attributes
+ newThing.visionType = this.currentPanelEntity.visionType;
+ newThing.visionLevel = this.currentPanelEntity.visionLevel;
+ newThing.radarAngle = this.currentPanelEntity.radarAngle;
+ this.world.addTower(newThing);
+ this.currentPanelEntity.remove();
+ this.showSmallLevelUpPanel(newThing, this.currentClickPos);
+ } else {
+ const et = new EffectText("钱不够!");
+ et.pos = this.currentClickPos;
+ this.world.addEffect(et);
+ }
}
this.callbacks.requestPauseRender();
return;
@@ -271,28 +306,44 @@ export class PanelManager {
if (this.currentPanelMine && this.currentMineScreenPos) {
const action = target.dataset.mineAction;
const price = parseInt(target.dataset.price || "0");
+ const isMultiplayer = this.networkClient !== null;
+
if (action === "upgrade" || action === "repair") {
- // Re-check territory validity (may have changed since panel opened)
- if (!this.currentPanelMine.inValidTerritory) {
- const et = new EffectText("不在有效领地,无法操作");
- et.pos = this.currentPanelMine.pos.copy();
- this.world.addEffect(et as any);
+ if (isMultiplayer && this.currentPanelMine.id) {
+ // Multiplayer: send mine action to server
+ const facade = this.world as unknown as {
+ upgradeMine?: (id: string) => void;
+ repairMine?: (id: string) => void;
+ };
+ if (action === "upgrade" && facade.upgradeMine) {
+ facade.upgradeMine(this.currentPanelMine.id);
+ } else if (action === "repair" && facade.repairMine) {
+ facade.repairMine(this.currentPanelMine.id);
+ }
this.hideLevelUpPanel();
- this.callbacks.requestPauseRender();
- return;
- }
- if (this.world.user.money >= price) {
- this.world.user.money -= price;
- if (action === "upgrade") {
- this.currentPanelMine.upgrade();
+ } else if (!isMultiplayer) {
+ // Single-player: local handling
+ // Re-check territory validity (may have changed since panel opened)
+ if (!this.currentPanelMine.inValidTerritory) {
+ const et = new EffectText("不在有效领地,无法操作");
+ et.pos = this.currentPanelMine.pos.copy();
+ this.world.addEffect(et);
+ this.hideLevelUpPanel();
+ this.callbacks.requestPauseRender();
+ return;
+ }
+ if (this.world.spendMoney(price)) {
+ if (action === "upgrade") {
+ this.currentPanelMine.upgrade();
+ } else {
+ this.currentPanelMine.startRepair();
+ }
+ this.showMinePanel(this.currentPanelMine, this.currentMineScreenPos);
} else {
- this.currentPanelMine.startRepair();
+ const et = new EffectText("钱不够!");
+ et.pos = this.currentPanelMine.pos.copy();
+ this.world.addEffect(et);
}
- this.showMinePanel(this.currentPanelMine, this.currentMineScreenPos);
- } else {
- const et = new EffectText("钱不够!");
- et.pos = this.currentPanelMine.pos.copy();
- this.world.addEffect(et as any);
}
}
this.callbacks.requestPauseRender();
@@ -306,44 +357,73 @@ export class PanelManager {
// Handle tower operations
if (this.currentPanelEntity && this.currentClickPos) {
+ const isMultiplayer = this.networkClient !== null;
+
if (target.classList.contains("levelDown")) {
- const towerName = this.currentPanelEntity.levelDownGetter as string | null;
- if (towerName === null) {
- const et = new EffectText("无法降级!");
+ if (isMultiplayer) {
+ // Multiplayer: level down not supported yet
+ const et = new EffectText("多人模式暂不支持降级");
et.pos = this.currentClickPos;
- this.world.addEffect(et as any);
+ this.world.addEffect(et);
} else {
- const downObj = TowerRegistry.create(towerName, this.world) as GameEntity;
- downObj.towerLevel = Math.max(1, this.currentPanelEntity.towerLevel - 1);
- // Preserve vision attributes
- downObj.visionType = this.currentPanelEntity.visionType;
- downObj.visionLevel = this.currentPanelEntity.visionLevel;
- downObj.radarAngle = this.currentPanelEntity.radarAngle;
- this.world.user.money += this.currentPanelEntity.price / 4;
- const newPos = this.currentPanelEntity.pos.copy();
- this.currentPanelEntity.remove();
- downObj.pos = newPos;
- this.world.addTower(downObj as any);
- this.showSmallLevelUpPanel(downObj, this.currentClickPos);
+ // Single-player: local handling
+ const towerName = this.currentPanelEntity.levelDownGetter as string | null;
+ if (towerName === null) {
+ const et = new EffectText("无法降级!");
+ et.pos = this.currentClickPos;
+ this.world.addEffect(et);
+ } else {
+ const downObj = TowerRegistry.create(towerName, this.world) as GameEntity;
+ downObj.towerLevel = Math.max(1, this.currentPanelEntity.towerLevel - 1);
+ // Preserve vision attributes
+ downObj.visionType = this.currentPanelEntity.visionType;
+ downObj.visionLevel = this.currentPanelEntity.visionLevel;
+ downObj.radarAngle = this.currentPanelEntity.radarAngle;
+ this.world.addMoney(this.currentPanelEntity.price / 4);
+ const newPos = this.currentPanelEntity.pos.copy();
+ this.currentPanelEntity.remove();
+ downObj.pos = newPos;
+ this.world.addTower(downObj);
+ this.showSmallLevelUpPanel(downObj, this.currentClickPos);
+ }
}
} else if (target.classList.contains("sell")) {
- const refund = this.currentPanelEntity.getSellRefund?.() ?? Math.floor(this.currentPanelEntity.price / 2);
- this.world.user.money += refund;
- this.currentPanelEntity.remove();
- this.hideLevelUpPanel();
+ if (isMultiplayer && this.currentPanelEntity.id) {
+ // Multiplayer: route through facade for client prediction
+ if (this.world.sellTower) {
+ this.world.sellTower(this.currentPanelEntity.id);
+ } else {
+ this.networkClient!.sellTower({
+ towerId: this.currentPanelEntity.id
+ });
+ }
+ this.hideLevelUpPanel();
+ } else if (!isMultiplayer) {
+ // Single-player: local handling
+ const refund = this.currentPanelEntity.getSellRefund?.() ?? Math.floor(this.currentPanelEntity.price / 2);
+ this.world.addMoney(refund);
+ this.currentPanelEntity.remove();
+ this.hideLevelUpPanel();
+ }
} else if (target.classList.contains("visionUpgrade")) {
const visionType = (target.dataset.visionType || VisionType.NONE) as VisionType;
- if (this.currentPanelEntity.canUpgradeVision?.(visionType)) {
+ if (this.networkClient && this.currentPanelEntity.id) {
+ // Multiplayer: send to server
+ this.networkClient.sendUpgradeVision({
+ towerId: this.currentPanelEntity.id,
+ visionType: visionType as 'observer' | 'radar',
+ });
+ } else if (this.currentPanelEntity.canUpgradeVision?.(visionType)) {
+ // Single-player: local handling
const price = this.currentPanelEntity.getVisionUpgradePrice?.(visionType) ?? 0;
- if (this.world.user.money >= price) {
+ if (this.world.spendMoney(price)) {
this.currentPanelEntity.upgradeVision?.(visionType);
- this.world.user.money -= price;
- this.world.fog.markDirty();
+ this.world.fog?.markDirty?.();
this.showSmallLevelUpPanel(this.currentPanelEntity, this.currentClickPos);
} else {
const et = new EffectText("金币不足!");
et.pos = this.currentPanelEntity.pos.copy();
- this.world.addEffect(et as any);
+ this.world.addEffect(et);
}
}
}
@@ -353,16 +433,32 @@ export class PanelManager {
// Handle mine operations
if (this.currentPanelMine && this.currentMineScreenPos) {
+ const isMultiplayer = this.networkClient !== null;
+ const facade = this.world as unknown as {
+ downgradeMine?: (id: string) => void;
+ sellMine?: (id: string) => void;
+ };
+
if (target.classList.contains("levelDown")) {
- const refund = parseInt(target.dataset.refund || "0");
- this.world.user.money += refund;
- this.currentPanelMine.downgrade();
- this.showMinePanel(this.currentPanelMine, this.currentMineScreenPos);
+ if (isMultiplayer && this.currentPanelMine.id && facade.downgradeMine) {
+ facade.downgradeMine(this.currentPanelMine.id);
+ this.hideLevelUpPanel();
+ } else if (!isMultiplayer) {
+ const refund = parseInt(target.dataset.refund || "0");
+ this.world.addMoney(refund);
+ this.currentPanelMine.downgrade();
+ this.showMinePanel(this.currentPanelMine, this.currentMineScreenPos);
+ }
} else if (target.classList.contains("sell")) {
- const sellPrice = parseInt(target.dataset.sellPrice || "0");
- this.world.user.money += sellPrice;
- this.currentPanelMine.destroy();
- this.hideLevelUpPanel();
+ if (isMultiplayer && this.currentPanelMine.id && facade.sellMine) {
+ facade.sellMine(this.currentPanelMine.id);
+ this.hideLevelUpPanel();
+ } else if (!isMultiplayer) {
+ const sellPrice = parseInt(target.dataset.sellPrice || "0");
+ this.world.addMoney(sellPrice);
+ this.currentPanelMine.destroy();
+ this.hideLevelUpPanel();
+ }
}
this.callbacks.requestPauseRender();
}
@@ -381,7 +477,7 @@ export class PanelManager {
panelEle.innerHTML = "";
panelEle.dataset.sessionId = this.sessionId;
const thingsFuncArr: ((world: unknown) => GameEntity)[] = [];
- thingsFuncArr.push((TowerFinallyCompat as any).BasicCannon as (world: unknown) => GameEntity);
+ thingsFuncArr.push(TowerRegistry.getCreator('BasicCannon') as (world: unknown) => GameEntity);
for (const bF of BUILDING_FUNC_ARR) {
thingsFuncArr.push(bF as (world: unknown) => GameEntity);
}
@@ -481,7 +577,7 @@ export class PanelManager {
if (thing.inValidTerritory === false) {
const et = new EffectText("无效领地内无法操作!");
et.pos = thing.pos.copy();
- this.world.addEffect(et as any);
+ this.world.addEffect(et);
this.callbacks.requestPauseRender();
return;
}
@@ -582,7 +678,7 @@ export class PanelManager {
if (!mine.inValidTerritory) {
const et = new EffectText("不在有效领地,无法操作");
et.pos = mine.pos.copy();
- this.world.addEffect(et as any);
+ this.world.addEffect(et);
this.callbacks.requestPauseRender();
return;
}
@@ -696,29 +792,49 @@ export class PanelManager {
return;
}
+ // Manual cannon targeting mode handling
+ if (this.manualCannonPanel.isInTargetingMode() && this.manualCannonPanel.getCurrentCannon()) {
+ const handled = this.manualCannonPanel.handleTargetSelected(clickPos);
+ if (handled) {
+ this.callbacks.requestPauseRender();
+ return;
+ }
+ }
+
if (this.addedThingFunc === null) {
for (const item of this.world.getAllBuildingArr()) {
- if ((item as any).getBodyCircle().pointIn(clickPos.x, clickPos.y)) {
- if ((item as any).gameType === "Mine") {
+ if (item.getBodyCircle().pointIn(clickPos.x, clickPos.y)) {
+ if (item.gameType === "Mine") {
this.showMinePanel(item as Mine, screenPos);
return;
}
+ // Check for MonsterSpawner
+ if (item.canSpawnMonsters) {
+ this.spawnerPanel.show(item as unknown as MonsterSpawner, screenPos);
+ return;
+ }
+ // Check for ManualCannon tower
+ if (item.canAttackBuildings && item.gameType === "Tower") {
+ this.manualCannonPanel.show(item as unknown as TowerManualCannon, screenPos);
+ item.selected = true;
+ return;
+ }
this.showSmallLevelUpPanel(item as unknown as GameEntity, screenPos);
- (item as any).selected = true;
+ item.selected = true;
return;
}
}
- for (const mine of this.world.mines) {
- if ((mine as any).getBodyCircle().pointIn(clickPos.x, clickPos.y)) {
- this.showMinePanel(mine as Mine, screenPos);
+ for (const mine of this.world.mines as Iterable) {
+ if (mine.getBodyCircle().pointIn(clickPos.x, clickPos.y)) {
+ this.showMinePanel(mine as unknown as Mine, screenPos);
return;
}
}
- for (const item of this.world.monsters) {
- if ((item as any).getBodyCircle().pointIn(clickPos.x, clickPos.y)) {
+ for (const item of this.world.monsters as Iterable) {
+ if (item.getBodyCircle().pointIn(clickPos.x, clickPos.y)) {
this.selectedThing = item as unknown as GameEntity;
this.showSelectedPanel(true);
- (item as any).selected = true;
+ item.selected = true;
return;
}
}
@@ -726,42 +842,42 @@ export class PanelManager {
this.hideLevelUpPanel();
} else {
const addedThing = this.addedThingFunc(this.world);
- if (this.world.user.money < addedThing.price) {
+ if (this.world.getMoney() < addedThing.price) {
const et = new EffectText("钱不够了!");
et.pos = clickPos.copy();
- this.world.addEffect(et as any);
+ this.world.addEffect(et);
return;
}
addedThing.pos = clickPos;
for (const item of this.world.getAllBuildingArr()) {
- if (addedThing.getBodyCircle().impact((item as any).getBodyCircle())) {
+ if (addedThing.getBodyCircle().impact(item.getBodyCircle())) {
const et = new EffectText("这里不能放建筑,换一个地方点一下");
et.pos = addedThing.pos.copy();
- this.world.addEffect(et as any);
+ this.world.addEffect(et);
return;
}
}
for (const obs of this.world.obstacles) {
- if (obs.intersectsCircle(addedThing.getBodyCircle() as any)) {
+ if (obs.intersectsCircle(addedThing.getBodyCircle())) {
const et = new EffectText("这里有障碍物,不能放置建筑");
et.pos = addedThing.pos.copy();
- this.world.addEffect(et as any);
+ this.world.addEffect(et);
return;
}
}
- if (this.world.territory && !this.world.territory.isPositionInValidTerritory(addedThing.pos)) {
+ if (this.world.territory?.isPositionInValidTerritory && !this.world.territory.isPositionInValidTerritory(addedThing.pos)) {
const et = new EffectText("只能在有效领地内放置建筑");
et.pos = addedThing.pos.copy();
- this.world.addEffect(et as any);
+ this.world.addEffect(et);
return;
}
- this.world.user.money -= addedThing.price;
+ this.world.spendMoney(addedThing.price);
switch (addedThing.gameType) {
case "Tower":
- this.world.addTower(addedThing as any);
+ this.world.addTower(addedThing);
break;
case "Building":
- this.world.addBuilding(addedThing as any);
+ this.world.addBuilding(addedThing);
break;
}
}
@@ -769,7 +885,7 @@ export class PanelManager {
console.error("Canvas click error:", error);
const et = new EffectText("放置出错!请刷新浏览器重试");
et.pos = new Vector(this.world.width / 2, this.world.height / 2);
- this.world.addEffect(et as any);
+ this.world.addEffect(et);
}
this.callbacks.requestPauseRender();
}, { signal: this.eventSignal });
@@ -785,7 +901,7 @@ export class PanelManager {
this.cachedBuilding = this.addedThingFunc(this.world);
this.lastAddedFunc = this.addedThingFunc;
}
- this.world.user.putLoc.building = this.cachedBuilding as any;
+ this.world.user.putLoc.building = this.cachedBuilding;
const rect = this.canvasEle.getBoundingClientRect();
const screenPos = new Vector(e.clientX - rect.left, e.clientY - rect.top);
@@ -805,26 +921,26 @@ export class PanelManager {
if (this.moveTarget === null) {
// 检查是否点击了建筑(不包括矿井)
for (const item of this.world.getAllBuildingArr()) {
- if ((item as any).getBodyCircle().pointIn(clickPos.x, clickPos.y)) {
+ if (item.getBodyCircle().pointIn(clickPos.x, clickPos.y)) {
// 矿井不能移动
- if ((item as any).gameType === "Mine") {
+ if (item.gameType === "Mine") {
const et = new EffectText("矿井不能移动!");
et.pos = clickPos.copy();
- this.world.addEffect(et as any);
+ this.world.addEffect(et);
return;
}
// 检查是否在有效领地内(直接使用建筑的 inValidTerritory 属性)
- if ((item as any).inValidTerritory === false) {
+ if (item.inValidTerritory === false) {
const et = new EffectText("只能移动有效领地内的建筑!");
et.pos = clickPos.copy();
- this.world.addEffect(et as any);
+ this.world.addEffect(et);
return;
}
// 选中建筑
this.moveTarget = item as unknown as GameEntity;
- (item as any).selected = true;
+ item.selected = true;
// 设置渲染器用的移动目标信息
- const bodyCircle = (item as any).getBodyCircle();
+ const bodyCircle = item.getBodyCircle();
this.world.user.moveTarget = {
x: bodyCircle.x,
y: bodyCircle.y,
@@ -832,7 +948,7 @@ export class PanelManager {
};
const et = new EffectText("已选中,点击目标位置移动");
et.pos = clickPos.copy();
- this.world.addEffect(et as any);
+ this.world.addEffect(et);
return;
}
}
@@ -849,75 +965,75 @@ export class PanelManager {
if (distance > MOVE_MAX_DISTANCE) {
const et = new EffectText(`距离太远!最大${MOVE_MAX_DISTANCE}像素`);
et.pos = clickPos.copy();
- this.world.addEffect(et as any);
+ this.world.addEffect(et);
return;
}
// 检查目标位置是否在有效领地内
- if (this.world.territory && !this.world.territory.isPositionInValidTerritory(clickPos)) {
+ if (this.world.territory?.isPositionInValidTerritory && !this.world.territory.isPositionInValidTerritory(clickPos)) {
const et = new EffectText("目标位置不在有效领地内!");
et.pos = clickPos.copy();
- this.world.addEffect(et as any);
+ this.world.addEffect(et);
return;
}
// 检查碰撞:其他建筑
const originalCircle = this.moveTarget.getBodyCircle();
- const targetCircle = new Circle(clickPos.x, clickPos.y, (originalCircle as any).r ?? this.moveTarget.r ?? 20);
+ const targetCircle = new Circle(clickPos.x, clickPos.y, originalCircle.r ?? this.moveTarget.r ?? 20);
for (const item of this.world.getAllBuildingArr()) {
if ((item as unknown) === (this.moveTarget as unknown)) continue; // 跳过自己
- if (targetCircle.impact((item as any).getBodyCircle())) {
+ if (targetCircle.impact(item.getBodyCircle())) {
const et = new EffectText("目标位置有其他建筑!");
et.pos = clickPos.copy();
- this.world.addEffect(et as any);
+ this.world.addEffect(et);
return;
}
}
// 检查碰撞:障碍物
for (const obs of this.world.obstacles) {
- if (obs.intersectsCircle(targetCircle as any)) {
+ if (obs.intersectsCircle(targetCircle)) {
const et = new EffectText("目标位置有障碍物!");
et.pos = clickPos.copy();
- this.world.addEffect(et as any);
+ this.world.addEffect(et);
return;
}
}
// 检查金币
- if (this.world.user.money < MOVE_COST) {
+ if (this.world.getMoney() < MOVE_COST) {
const et = new EffectText(`金币不足!需要${MOVE_COST}元`);
et.pos = clickPos.copy();
- this.world.addEffect(et as any);
+ this.world.addEffect(et);
return;
}
// 执行移动
// 1. 从领地系统移除
- this.world.territory?.removeBuildingIncremental(this.moveTarget as any);
-
+ this.world.territory?.removeBuildingIncremental?.(this.moveTarget);
+
// 2. 更新位置
this.moveTarget.pos = clickPos.copy();
-
+
// 3. 重新添加到领地系统
- this.world.territory?.addBuildingIncremental(this.moveTarget as any);
-
+ this.world.territory?.addBuildingIncremental?.(this.moveTarget);
+
// 4. 标记迷雾需要更新
- this.world.fog?.markDirty();
+ this.world.fog?.markDirty?.();
// 5. 标记静态层需要重建(建筑位置已改变)
this.world.markStaticLayerDirty();
// 6. 扣除金币
- this.world.user.money -= MOVE_COST;
+ this.world.spendMoney(MOVE_COST);
// 7. 取消选中状态
- (this.moveTarget as any).selected = false;
+ this.moveTarget.selected = false;
// 8. 显示成功提示
const et = new EffectText("移动成功!");
et.pos = clickPos.copy();
- this.world.addEffect(et as any);
+ this.world.addEffect(et);
// 9. 退出移动模式
this.moveMode = false;
@@ -958,14 +1074,17 @@ export class PanelManager {
this.freshBtnInterval = setInterval(() => {
const towerBtnArr = document.getElementsByClassName("towerBtn");
- const basicC = ((TowerFinallyCompat as any).BasicCannon as (world: unknown) => GameEntity)(this.world);
- towerBtnArr[0]?.setAttribute("data-price", basicC.price.toString());
- if (towerBtnArr[0]) {
- towerBtnArr[0].innerHTML = basicC.name + `
${basicC.price}¥`;
+ const basicCCreator = TowerRegistry.getCreator('BasicCannon') as ((world: unknown) => GameEntity) | undefined;
+ const basicC = basicCCreator?.(this.world);
+ if (basicC) {
+ towerBtnArr[0]?.setAttribute("data-price", basicC.price.toString());
+ if (towerBtnArr[0]) {
+ towerBtnArr[0].innerHTML = basicC.name + `
${basicC.price}¥`;
+ }
}
for (let i = 0; i < towerBtnArr.length; i++) {
const btn = towerBtnArr[i] as HTMLElement;
- if (parseInt(btn.dataset.price!) <= this.world.user.money) {
+ if (parseInt(btn.dataset.price!) <= this.world.getMoney()) {
btn.removeAttribute("disabled");
} else {
btn.setAttribute("disabled", "disabled");
@@ -982,7 +1101,7 @@ export class PanelManager {
const itemArr = this.smallLevelUpPanelEle.getElementsByClassName("levelUpItem");
for (let i = 0; i < itemArr.length; i++) {
const itemEle = itemArr[i] as HTMLElement;
- if (parseInt(itemEle.dataset.price!) <= this.world.user.money) {
+ if (parseInt(itemEle.dataset.price!) <= this.world.getMoney()) {
itemEle.removeAttribute("disabled");
itemEle.style.opacity = "1";
} else {
diff --git a/src/ui/interfaces/battle/spawnerPanel.ts b/src/ui/interfaces/battle/spawnerPanel.ts
new file mode 100644
index 0000000..f559763
--- /dev/null
+++ b/src/ui/interfaces/battle/spawnerPanel.ts
@@ -0,0 +1,283 @@
+/**
+ * SpawnerPanel - UI panel for MonsterSpawner building
+ *
+ * Displays available monsters and allows spawning them against enemy players.
+ */
+
+import type { MonsterSpawner } from '../../../buildings/variants/monsterSpawner';
+import { SPAWNABLE_MONSTERS, SpawnableMonster } from '../../../buildings/spawnerConfig';
+import { scalePeriod } from '../../../core/speedScale';
+
+/**
+ * SpawnerPanel class - Manages the monster spawner UI
+ */
+export class SpawnerPanel {
+ private panelEl: HTMLElement | null = null;
+ private currentSpawner: MonsterSpawner | null = null;
+ private refreshInterval: ReturnType | null = null;
+ private selectedTargetId: string = '';
+ private abortSignal: AbortSignal;
+
+ constructor(abortSignal: AbortSignal) {
+ this.abortSignal = abortSignal;
+ this.createPanel();
+ }
+
+ /**
+ * Create panel DOM element
+ */
+ private createPanel(): void {
+ // Create panel container
+ this.panelEl = document.createElement('div');
+ this.panelEl.id = 'spawnerPanel';
+ this.panelEl.style.display = 'none';
+
+ this.panelEl.innerHTML = `
+
+
+
+
+
+
+
+ `;
+
+ document.body.appendChild(this.panelEl);
+
+ // Bind close button
+ const closeBtn = this.panelEl.querySelector('.spawner-close');
+ closeBtn?.addEventListener('click', () => this.hide(), { signal: this.abortSignal });
+
+ // Bind target select
+ const targetSelect = this.panelEl.querySelector('.spawner-target-select') as HTMLSelectElement;
+ targetSelect?.addEventListener('change', (e) => {
+ this.selectedTargetId = (e.target as HTMLSelectElement).value;
+ this.updateMonsterList();
+ }, { signal: this.abortSignal });
+
+ // Hide when mouse leaves
+ this.panelEl.addEventListener('mouseleave', () => {
+ this.hide();
+ }, { signal: this.abortSignal });
+ }
+
+ /**
+ * Show panel for a spawner
+ */
+ show(spawner: MonsterSpawner, screenPos: { x: number; y: number }): void {
+ if (!this.panelEl) return;
+
+ this.currentSpawner = spawner;
+ this.selectedTargetId = '';
+
+ // Position panel
+ this.panelEl.style.left = `${screenPos.x + 10}px`;
+ this.panelEl.style.top = `${screenPos.y + 10}px`;
+ this.panelEl.style.display = 'block';
+
+ // Check multiplayer mode
+ if (!spawner.isMultiplayerMode()) {
+ this.showSinglePlayerMessage();
+ return;
+ }
+
+ // Update target player dropdown
+ this.updateTargetSelect();
+
+ // Update monster list
+ this.updateMonsterList();
+
+ // Start refresh interval (200ms)
+ this.startRefresh();
+ }
+
+ /**
+ * Hide panel
+ */
+ hide(): void {
+ if (!this.panelEl) return;
+
+ this.panelEl.style.display = 'none';
+ this.currentSpawner = null;
+ this.stopRefresh();
+ }
+
+ /**
+ * Check if panel is visible
+ */
+ isVisible(): boolean {
+ return this.panelEl?.style.display === 'block';
+ }
+
+ /**
+ * Show single player mode message
+ */
+ private showSinglePlayerMessage(): void {
+ if (!this.panelEl) return;
+
+ const targetSection = this.panelEl.querySelector('.spawner-target-section') as HTMLElement;
+ const monsterList = this.panelEl.querySelector('.spawner-monster-list') as HTMLElement;
+ const hintEl = this.panelEl.querySelector('.spawner-hint') as HTMLElement;
+
+ if (targetSection) targetSection.style.display = 'none';
+ if (monsterList) monsterList.innerHTML = '';
+ if (hintEl) {
+ hintEl.textContent = '仅在多人模式可用';
+ hintEl.style.display = 'block';
+ }
+ }
+
+ /**
+ * Update target player dropdown
+ */
+ private updateTargetSelect(): void {
+ if (!this.panelEl || !this.currentSpawner) return;
+
+ const targetSection = this.panelEl.querySelector('.spawner-target-section') as HTMLElement;
+ const selectEl = this.panelEl.querySelector('.spawner-target-select') as HTMLSelectElement;
+ const hintEl = this.panelEl.querySelector('.spawner-hint') as HTMLElement;
+
+ if (targetSection) targetSection.style.display = 'block';
+
+ // Get enemy players
+ const enemies = this.currentSpawner.getEnemyPlayers();
+
+ if (enemies.length === 0) {
+ if (hintEl) {
+ hintEl.textContent = '没有可攻击的敌方玩家';
+ hintEl.style.display = 'block';
+ }
+ if (selectEl) selectEl.innerHTML = '';
+ return;
+ }
+
+ if (hintEl) hintEl.style.display = 'none';
+
+ // Build options
+ let html = '';
+ for (const enemy of enemies) {
+ html += ``;
+ }
+ if (selectEl) {
+ selectEl.innerHTML = html;
+ selectEl.value = this.selectedTargetId;
+ }
+ }
+
+ /**
+ * Update monster list display
+ */
+ private updateMonsterList(): void {
+ if (!this.panelEl || !this.currentSpawner) return;
+
+ const listEl = this.panelEl.querySelector('.spawner-monster-list') as HTMLElement;
+ if (!listEl) return;
+
+ const spawner = this.currentSpawner;
+ const currentWave = spawner.getCurrentWave();
+
+ let html = '';
+ for (const config of SPAWNABLE_MONSTERS) {
+ const isUnlocked = currentWave >= config.unlockWave;
+ const cooldown = spawner.getCooldown(config.monsterId);
+ const isOnCooldown = cooldown > 0;
+ const canAfford = spawner.canAfford(config);
+ const hasTarget = this.selectedTargetId !== '';
+ const inValidTerritory = spawner.inValidTerritory;
+
+ const isAvailable = isUnlocked && !isOnCooldown && canAfford && hasTarget && inValidTerritory;
+
+ let statusText = '';
+ if (!isUnlocked) {
+ statusText = `第${config.unlockWave}波解锁`;
+ } else if (!inValidTerritory) {
+ statusText = '领地无效';
+ } else if (isOnCooldown) {
+ // Convert ticks to seconds (approx)
+ const seconds = Math.ceil(cooldown / scalePeriod(60));
+ statusText = `冷却中 ${seconds}s`;
+ } else if (!canAfford) {
+ statusText = '金币不足';
+ } else if (!hasTarget) {
+ statusText = '请选择目标';
+ }
+
+ const itemClass = isAvailable ? 'spawner-monster-item available' : 'spawner-monster-item disabled';
+
+ html += `
+
+
${config.name}
+
${config.cost}元
+
${statusText}
+
+ `;
+ }
+
+ listEl.innerHTML = html;
+
+ // Bind click handlers
+ const items = listEl.querySelectorAll('.spawner-monster-item.available');
+ items.forEach((item) => {
+ item.addEventListener('click', (e) => {
+ const monsterId = (e.currentTarget as HTMLElement).dataset.monsterId;
+ if (monsterId) {
+ this.spawnMonster(monsterId);
+ }
+ }, { signal: this.abortSignal });
+ });
+ }
+
+ /**
+ * Spawn a monster
+ */
+ private spawnMonster(monsterId: string): void {
+ if (!this.currentSpawner || !this.selectedTargetId) return;
+
+ const config = SPAWNABLE_MONSTERS.find(c => c.monsterId === monsterId);
+ if (!config) return;
+
+ const success = this.currentSpawner.spawnMonster(config, this.selectedTargetId);
+ if (success) {
+ // Refresh immediately to show cooldown
+ this.updateMonsterList();
+ }
+ }
+
+ /**
+ * Start refresh interval
+ */
+ private startRefresh(): void {
+ this.stopRefresh();
+ this.refreshInterval = setInterval(() => {
+ if (this.isVisible() && this.currentSpawner) {
+ this.updateMonsterList();
+ }
+ }, 200);
+ }
+
+ /**
+ * Stop refresh interval
+ */
+ private stopRefresh(): void {
+ if (this.refreshInterval) {
+ clearInterval(this.refreshInterval);
+ this.refreshInterval = null;
+ }
+ }
+
+ /**
+ * Cleanup
+ */
+ destroy(): void {
+ this.stopRefresh();
+ if (this.panelEl && this.panelEl.parentNode) {
+ this.panelEl.parentNode.removeChild(this.panelEl);
+ }
+ this.panelEl = null;
+ }
+}
diff --git a/src/ui/interfaces/battle/types.ts b/src/ui/interfaces/battle/types.ts
index 657125e..493ac17 100644
--- a/src/ui/interfaces/battle/types.ts
+++ b/src/ui/interfaces/battle/types.ts
@@ -5,11 +5,42 @@
import { Vector } from '../../../core/math/vector';
import { VisionType } from '../../../systems/fog/visionConfig';
import { InputHandler } from '../../../core/input/inputHandler';
+import type { IEffect } from '../../../types/game';
+
+/**
+ * Circle interface for collision detection in PanelManager
+ */
+export interface PanelCircleLike {
+ x: number;
+ y: number;
+ r: number;
+ pointIn(x: number, y: number): boolean;
+ impact(other: PanelCircleLike): boolean;
+}
+
+/**
+ * Minimal entity interface for PanelManager iteration
+ * Used by mines, monsters, towers, and buildings
+ */
+export interface PanelEntityLike {
+ x?: number;
+ y?: number;
+ r?: number;
+ pos?: Vector;
+ id?: string | null;
+ gameType?: string;
+ selected?: boolean;
+ inValidTerritory?: boolean;
+ canSpawnMonsters?: boolean;
+ canAttackBuildings?: boolean;
+ getBodyCircle(): PanelCircleLike;
+}
/**
* Game entity interface for towers/buildings
*/
export interface GameEntity {
+ id?: string | null;
pos: Vector;
name: string;
price: number;
@@ -23,7 +54,7 @@ export interface GameEntity {
imgIndex: number;
inValidTerritory: boolean;
comment?: string;
- getBodyCircle: () => { pointIn: (x: number, y: number) => boolean; impact: (other: unknown) => boolean };
+ getBodyCircle: () => PanelCircleLike;
getImgStartPosByIndex: (index: number) => { x: number; y: number };
remove: () => void;
// Vision system methods
@@ -88,3 +119,94 @@ export interface LoopGuardState {
* Degradation parameters: [maxStepsMultiplier, stepMultiplier]
*/
export type DegradationParams = [number, number][];
+
+/**
+ * Camera interface for PanelManager
+ */
+export interface PanelManagerCamera {
+ screenToWorld(screenPos: Vector): Vector;
+ centerOn?(pos: Vector): void;
+ zoom?: number;
+}
+
+/**
+ * Territory system interface for PanelManager
+ */
+export interface PanelManagerTerritory {
+ isPositionInValidTerritory?(pos: Vector): boolean;
+ markDirty?(): void;
+ removeBuildingIncremental?(building: unknown): void;
+ addBuildingIncremental?(building: unknown): void;
+}
+
+/**
+ * Fog system interface for PanelManager
+ */
+export interface PanelManagerFog {
+ markDirty?(): void;
+}
+
+/**
+ * Minimal building shape for placement preview
+ */
+export interface PanelBuildingPreview {
+ r?: number;
+ rangeR?: number;
+}
+
+/**
+ * User state interface for PanelManager
+ */
+export interface PanelManagerUser {
+ putLoc: {
+ x: number;
+ y: number;
+ building: PanelBuildingPreview | PanelEntityLike | null;
+ };
+ moveTarget: { x: number; y: number; r: number } | null;
+}
+
+/**
+ * World-like interface for PanelManager
+ * Allows both World and MultiplayerWorldFacade to be used
+ */
+export interface PanelManagerWorldLike {
+ // Dimensions
+ width: number;
+ height: number;
+
+ // Camera
+ camera: PanelManagerCamera;
+
+ // User state
+ user: PanelManagerUser;
+
+ // Entity collections - use Iterable for maximum compatibility
+ mines: Iterable;
+ monsters: Iterable;
+ obstacles: Array<{ intersectsCircle(circle: PanelCircleLike): boolean }>;
+
+ // Systems (optional for multiplayer)
+ territory?: PanelManagerTerritory | null;
+ fog?: PanelManagerFog | null;
+
+ // Query methods
+ getAllBuildingArr(): PanelEntityLike[];
+
+ // Money operations
+ getMoney(): number;
+ spendMoney(amount: number): boolean;
+ addMoney(amount: number): void;
+
+ // Entity management - accept unknown to support different implementations
+ addEffect(effect: IEffect): void;
+ addTower(tower: unknown): void;
+ addBuilding(building: unknown): void;
+
+ // Tower sell (optional, used by multiplayer facade for client prediction)
+ sellTower?(towerId: string): void;
+
+ // Static layer
+ markStaticLayerDirty(): void;
+}
+
diff --git a/src/ui/interfaces/battle/uiController.ts b/src/ui/interfaces/battle/uiController.ts
index 0a00f20..7d5ce74 100644
--- a/src/ui/interfaces/battle/uiController.ts
+++ b/src/ui/interfaces/battle/uiController.ts
@@ -219,7 +219,7 @@ export class UIController {
}
if (homeBtn) {
homeBtn.addEventListener("click", () => {
- this.world.camera.centerOn(this.world.rootBuilding.pos);
+ this.world.camera.centerOn(this.world.getBaseBuilding().pos);
this.callbacks.requestPauseRender();
});
}
diff --git a/src/ui/interfaces/index.ts b/src/ui/interfaces/index.ts
index 4f87191..367179e 100644
--- a/src/ui/interfaces/index.ts
+++ b/src/ui/interfaces/index.ts
@@ -9,3 +9,4 @@ export { wikiInterface } from './wikiInterface';
export { cannonInterface } from './cannonInterface';
export { monstersInterface } from './monstersInterface';
export { endlessMode, startBattleMode } from './endlessMode';
+export * from './multiplayer';
diff --git a/src/ui/interfaces/mainInterface.ts b/src/ui/interfaces/mainInterface.ts
index f85eaef..96022eb 100644
--- a/src/ui/interfaces/mainInterface.ts
+++ b/src/ui/interfaces/mainInterface.ts
@@ -7,12 +7,14 @@ import { gotoPage } from '../navigation/router';
import { choiceInterface } from './choiceInterface';
import { wikiInterface } from './wikiInterface';
import { helpInterface } from './helpInterface';
+import { connectInterface } from './multiplayer';
/**
* Main menu interface logic
*/
export function mainInterface(): void {
let startBtn = document.querySelector(".startGame") as HTMLElement;
+ let multiplayerBtn = document.querySelector(".multiplayerMode") as HTMLElement;
let wikiBtn = document.querySelector(".wiki") as HTMLElement;
let helpBtn = document.querySelector(".help") as HTMLElement;
@@ -24,6 +26,11 @@ export function mainInterface(): void {
choiceInterface();
});
+ multiplayerBtn.addEventListener("click", () => {
+ gotoPage("multiplayer-connect-interface");
+ connectInterface();
+ });
+
wikiBtn.addEventListener("click", () => {
gotoPage("wiki-interface");
wikiInterface();
diff --git a/src/ui/interfaces/multiplayer/connectInterface.ts b/src/ui/interfaces/multiplayer/connectInterface.ts
new file mode 100644
index 0000000..8ec868d
--- /dev/null
+++ b/src/ui/interfaces/multiplayer/connectInterface.ts
@@ -0,0 +1,122 @@
+/**
+ * Connect Interface
+ * Handles player name input and server connection
+ */
+
+import { gotoPage } from '../../navigation/router';
+import { setupBackButton } from '../../components/backButton';
+import { getNetworkClient, NetworkEvent } from '@/network/networkClient';
+import { NETWORK_CONFIG } from '@/network/config';
+import { lobbyInterface } from './lobbyInterface';
+
+// LocalStorage keys
+const STORAGE_KEY_PLAYER_NAME = 'multiplayer_playerName';
+const STORAGE_KEY_SERVER_URL = 'multiplayer_serverUrl';
+
+/**
+ * Initialize connect interface
+ */
+export function connectInterface(): void {
+ const container = document.querySelector('.multiplayer-connect-interface') as HTMLElement;
+ if (!container) return;
+
+ // Setup back button
+ setupBackButton(container, 'main-interface');
+
+ // Get elements
+ const playerNameInput = container.querySelector('#playerNameInput') as HTMLInputElement;
+ const serverUrlInput = container.querySelector('#serverUrlInput') as HTMLInputElement;
+ const connectBtn = container.querySelector('#connectBtn') as HTMLButtonElement;
+ const connectionStatus = container.querySelector('#connectionStatus') as HTMLElement;
+
+ // Load saved values
+ const savedName = localStorage.getItem(STORAGE_KEY_PLAYER_NAME) || '';
+ const savedUrl = localStorage.getItem(STORAGE_KEY_SERVER_URL) || NETWORK_CONFIG.serverUrl;
+
+ playerNameInput.value = savedName;
+ serverUrlInput.value = savedUrl;
+
+ // Update status display
+ const updateStatus = (text: string, type: 'info' | 'success' | 'error' = 'info') => {
+ connectionStatus.textContent = text;
+ connectionStatus.className = 'connection-status ' + type;
+ };
+
+ // Handle connect
+ const handleConnect = async () => {
+ const playerName = playerNameInput.value.trim();
+ const serverUrl = serverUrlInput.value.trim();
+
+ if (!playerName) {
+ updateStatus('请输入玩家名称', 'error');
+ playerNameInput.focus();
+ return;
+ }
+
+ if (playerName.length > 20) {
+ updateStatus('玩家名称不能超过20个字符', 'error');
+ return;
+ }
+
+ // Save to localStorage
+ localStorage.setItem(STORAGE_KEY_PLAYER_NAME, playerName);
+ localStorage.setItem(STORAGE_KEY_SERVER_URL, serverUrl);
+
+ const client = getNetworkClient();
+
+ // Update server URL if changed
+ if (serverUrl !== NETWORK_CONFIG.serverUrl) {
+ try {
+ client.setServerUrl(serverUrl);
+ } catch (error) {
+ updateStatus('无法更改服务器地址: ' + (error instanceof Error ? error.message : '未知错误'), 'error');
+ return;
+ }
+ }
+
+ // Disable button and show connecting status
+ connectBtn.disabled = true;
+ updateStatus('正在连接...', 'info');
+
+ try {
+ // Setup event listeners
+ const onError = (error: unknown) => {
+ console.error('[ConnectInterface] Connection error:', error);
+ updateStatus('连接失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error');
+ connectBtn.disabled = false;
+ };
+
+ const onConnected = () => {
+ updateStatus('连接成功!', 'success');
+ // Navigate to lobby
+ gotoPage('multiplayer-lobby-interface');
+ lobbyInterface();
+ // Cleanup listeners
+ client.events.off(NetworkEvent.ERROR, onError);
+ client.events.off(NetworkEvent.CONNECTED, onConnected);
+ };
+
+ client.events.on(NetworkEvent.ERROR, onError);
+ client.events.on(NetworkEvent.CONNECTED, onConnected);
+
+ await client.connectToLobby(playerName);
+ } catch (error) {
+ updateStatus('连接失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error');
+ connectBtn.disabled = false;
+ }
+ };
+
+ // Event listeners
+ connectBtn.addEventListener('click', handleConnect);
+
+ // Allow pressing Enter to connect
+ playerNameInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') handleConnect();
+ });
+ serverUrlInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') handleConnect();
+ });
+
+ // Initial status
+ updateStatus('未连接', 'info');
+}
diff --git a/src/ui/interfaces/multiplayer/index.ts b/src/ui/interfaces/multiplayer/index.ts
new file mode 100644
index 0000000..fbb3b0a
--- /dev/null
+++ b/src/ui/interfaces/multiplayer/index.ts
@@ -0,0 +1,8 @@
+/**
+ * Multiplayer UI Module
+ */
+
+export { connectInterface } from './connectInterface';
+export { lobbyInterface } from './lobbyInterface';
+export { waitingRoomInterface } from './waitingRoomInterface';
+export * from './types';
diff --git a/src/ui/interfaces/multiplayer/lobbyInterface.ts b/src/ui/interfaces/multiplayer/lobbyInterface.ts
new file mode 100644
index 0000000..b1a9e86
--- /dev/null
+++ b/src/ui/interfaces/multiplayer/lobbyInterface.ts
@@ -0,0 +1,308 @@
+/**
+ * Lobby Interface
+ * Displays room list and provides room creation/joining functionality
+ */
+
+import { gotoPage } from '../../navigation/router';
+import { setupBackButton } from '../../components/backButton';
+import {
+ getNetworkClient,
+ NetworkEvent,
+ ConnectionState,
+} from '@/network/networkClient';
+import type { RoomInfo } from '@/network/messages';
+import { waitingRoomInterface } from './waitingRoomInterface';
+
+// Room list refresh interval (ms)
+const REFRESH_INTERVAL = 5000;
+
+let refreshIntervalId: number | null = null;
+let isSearching = false;
+
+/**
+ * Initialize lobby interface
+ */
+export function lobbyInterface(): void {
+ const container = document.querySelector(
+ '.multiplayer-lobby-interface'
+ ) as HTMLElement;
+ if (!container) return;
+
+ const client = getNetworkClient();
+
+ // Reset module-level state to prevent stale data on re-entry
+ if (refreshIntervalId !== null) {
+ clearInterval(refreshIntervalId);
+ refreshIntervalId = null;
+ }
+ isSearching = false;
+
+ // Setup back button (disconnect and return)
+ setupBackButton(container, 'multiplayer-connect-interface', {
+ beforeNavigate: () => {
+ cleanup();
+ client.disconnect();
+ },
+ });
+
+ // Get elements
+ const playerNameDisplay = container.querySelector(
+ '#lobbyPlayerName'
+ ) as HTMLElement;
+ const roomListContainer = container.querySelector(
+ '#roomListContainer'
+ ) as HTMLElement;
+ const refreshBtn = container.querySelector('#refreshRoomsBtn') as HTMLButtonElement;
+ const createRoomBtn = container.querySelector(
+ '#createRoomBtn'
+ ) as HTMLButtonElement;
+ const quickMatchBtn = container.querySelector(
+ '#quickMatchBtn'
+ ) as HTMLButtonElement;
+ const matchStatus = container.querySelector('#matchStatus') as HTMLElement;
+
+ // Create room dialog elements
+ const createRoomDialog = container.querySelector(
+ '#createRoomDialog'
+ ) as HTMLElement;
+ const roomNameInput = container.querySelector(
+ '#roomNameInput'
+ ) as HTMLInputElement;
+ const mapSizeRadios = container.querySelectorAll(
+ 'input[name="mapSize"]'
+ ) as NodeListOf