世界、玩家登录、加入广播。
This commit is contained in:
98
server/.github/instructions/development-tasks.md
vendored
Normal file
98
server/.github/instructions/development-tasks.md
vendored
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# 开发任务清单
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
Roguelike 游戏服务端开发任务追踪
|
||||||
|
|
||||||
|
## 当前项目规划
|
||||||
|
|
||||||
|
### 1. 世界系统初始化 ✅
|
||||||
|
- [x] 服务器启动时初始化游戏世界
|
||||||
|
- [x] 世界配置参数
|
||||||
|
- 世界尺寸: 800x800
|
||||||
|
- 出生区域: 距离中心点 200 范围内
|
||||||
|
- [x] 世界数据结构设计
|
||||||
|
- 地图管理
|
||||||
|
- 坐标系统
|
||||||
|
|
||||||
|
### 2. 玩家系统 ✅
|
||||||
|
- [x] 玩家登录流程(统一接口)
|
||||||
|
- [x] 查找已有角色
|
||||||
|
- [x] 若无角色,服务端自动注册
|
||||||
|
- [x] 服务端自动创建角色
|
||||||
|
- [x] 服务端自动分配随机出生点(出生区域内)
|
||||||
|
- [x] 返回角色信息给客户端
|
||||||
|
- [x] 玩家数据结构
|
||||||
|
- [x] 角色ID
|
||||||
|
- [x] 位置坐标
|
||||||
|
- [x] 生命值(初始10点)
|
||||||
|
- [x] 出生点记录
|
||||||
|
|
||||||
|
### 3. 游戏机制
|
||||||
|
- [x] 移动系统 ✅
|
||||||
|
- [x] 移动指令协议
|
||||||
|
- [x] 位置验证
|
||||||
|
- [x] 位置同步广播
|
||||||
|
- [ ] 战斗系统
|
||||||
|
- [ ] 攻击指令协议
|
||||||
|
- [ ] 伤害计算
|
||||||
|
- [ ] 生命值管理
|
||||||
|
- [ ] 死亡判定
|
||||||
|
- [ ] 复活系统
|
||||||
|
- [ ] 死亡处理
|
||||||
|
- [ ] 出生点复活
|
||||||
|
- [ ] 生命值重置
|
||||||
|
|
||||||
|
### 4. 协议定义
|
||||||
|
- [x] PtlLogin - 登录协议(服务端自动处理注册和创角) ✅
|
||||||
|
- [x] PtlMove - 移动协议 ✅
|
||||||
|
- [x] MsgPlayerJoin - 玩家加入广播 ✅
|
||||||
|
- [x] MsgPlayerMove - 玩家移动广播 ✅
|
||||||
|
- [ ] PtlAttack - 攻击协议
|
||||||
|
- [ ] MsgPlayerDeath - 玩家死亡广播
|
||||||
|
- [ ] MsgPlayerRespawn - 玩家复活广播
|
||||||
|
- [ ] MsgPlayerAttack - 玩家攻击广播
|
||||||
|
|
||||||
|
## 开发进度
|
||||||
|
|
||||||
|
### 已完成
|
||||||
|
- [x] 项目初始化
|
||||||
|
- [x] 基础消息发送 (ApiSend.ts)
|
||||||
|
- [x] **世界系统初始化** (2025-12-14)
|
||||||
|
- [x] 世界配置文件 (world.config.ts)
|
||||||
|
- [x] 位置坐标模型 (Position.ts)
|
||||||
|
- [x] 世界数据模型 (World.ts)
|
||||||
|
- [x] 数学工具函数 (math.ts)
|
||||||
|
- [x] 世界管理器 (WorldManager.ts)
|
||||||
|
- [x] 服务器启动时初始化世界
|
||||||
|
- [x] **玩家系统** (2025-12-14)
|
||||||
|
- [x] 玩家数据模型 (Player.ts)
|
||||||
|
- [x] 玩家管理器 (PlayerManager.ts)
|
||||||
|
- [x] 登录协议定义 (PtlLogin.ts)
|
||||||
|
- [x] 登录API实现 (ApiLogin.ts)
|
||||||
|
- [x] 自动注册和创角流程
|
||||||
|
- [x] 出生点分配逻辑
|
||||||
|
- [x] 在线玩家管理
|
||||||
|
- [x] **移动系统** (2025-12-14)
|
||||||
|
- [x] 移动协议定义 (PtlMove.ts)
|
||||||
|
- [x] 移动广播协议 (MsgPlayerMove.ts)
|
||||||
|
- [x] 玩家加入广播协议 (MsgPlayerJoin.ts)
|
||||||
|
- [x] 广播工具函数 (broadcast.ts)
|
||||||
|
- [x] 移动API实现 (ApiMove.ts)
|
||||||
|
- [x] 位置验证和边界检查
|
||||||
|
- [x] 登录时广播玩家加入
|
||||||
|
|
||||||
|
### 进行中
|
||||||
|
- 等待下一阶段开发...
|
||||||
|
|
||||||
|
### 待办事项
|
||||||
|
- 按照上述规划顺序实施开发
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
- TSRPC 框架
|
||||||
|
- TypeScript
|
||||||
|
- Node.js
|
||||||
|
|
||||||
|
## 备注
|
||||||
|
- 本文档由 AI 助手维护
|
||||||
|
- 更新日期: 2025-12-14
|
||||||
|
- 开发过程中会根据实际情况调整任务优先级和细节
|
||||||
4
server/.github/instructions/workspace.instructions.md
vendored
Normal file
4
server/.github/instructions/workspace.instructions.md
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
applyTo: '**'
|
||||||
|
---
|
||||||
|
这是TSRPC的服务端项目,请使用中文回答
|
||||||
3
server/.gitignore
vendored
Normal file
3
server/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_STORE
|
||||||
11
server/.mocharc.js
Normal file
11
server/.mocharc.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
module.exports = {
|
||||||
|
require: [
|
||||||
|
'ts-node/register',
|
||||||
|
],
|
||||||
|
timeout: 999999,
|
||||||
|
exit: true,
|
||||||
|
spec: [
|
||||||
|
'./test/**/*.test.ts'
|
||||||
|
],
|
||||||
|
'preserve-symlinks': true
|
||||||
|
}
|
||||||
30
server/.vscode/launch.json
vendored
Normal file
30
server/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "mocha current file",
|
||||||
|
"program": "${workspaceFolder}/node_modules/mocha/bin/_mocha",
|
||||||
|
"args": [
|
||||||
|
"${file}"
|
||||||
|
],
|
||||||
|
"internalConsoleOptions": "openOnSessionStart",
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "ts-node current file",
|
||||||
|
"protocol": "inspector",
|
||||||
|
"args": [
|
||||||
|
"${relativeFile}"
|
||||||
|
],
|
||||||
|
"cwd": "${workspaceRoot}",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"-r",
|
||||||
|
"ts-node/register"
|
||||||
|
],
|
||||||
|
"internalConsoleOptions": "openOnSessionStart"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
3
server/.vscode/settings.json
vendored
Normal file
3
server/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"typescript.tsdk": "node_modules\\typescript\\lib"
|
||||||
|
}
|
||||||
30
server/Dockerfile
Normal file
30
server/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
FROM node
|
||||||
|
|
||||||
|
# 使用淘宝 NPM 镜像(国内机器构建推荐启用)
|
||||||
|
# RUN npm config set registry https://registry.npm.taobao.org/
|
||||||
|
|
||||||
|
# npm install
|
||||||
|
ADD package*.json /src/
|
||||||
|
WORKDIR /src
|
||||||
|
RUN npm i
|
||||||
|
|
||||||
|
# build
|
||||||
|
ADD . /src
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# clean
|
||||||
|
RUN npm prune --production
|
||||||
|
|
||||||
|
# move
|
||||||
|
RUN rm -rf /app \
|
||||||
|
&& mv dist /app \
|
||||||
|
&& mv node_modules /app/ \
|
||||||
|
&& rm -rf /src
|
||||||
|
|
||||||
|
# ENV
|
||||||
|
ENV NODE_ENV production
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
CMD node index.js
|
||||||
31
server/README.md
Normal file
31
server/README.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# TSRPC Server
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
### Local dev server
|
||||||
|
|
||||||
|
Dev server would restart automatically when code changed.
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build
|
||||||
|
```
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate API document
|
||||||
|
|
||||||
|
Generate API document in swagger/openapi and markdown format.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm run doc
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run unit Test
|
||||||
|
Execute `npm run dev` first, then execute:
|
||||||
|
```
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
27
server/package.json
Normal file
27
server/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "rougelike-demo-.",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsrpc-cli dev",
|
||||||
|
"build": "tsrpc-cli build",
|
||||||
|
"doc": "tsrpc-cli doc",
|
||||||
|
"test": "mocha test/**/*.test.ts",
|
||||||
|
"proto": "tsrpc-cli proto",
|
||||||
|
"sync": "tsrpc-cli sync",
|
||||||
|
"api": "tsrpc-cli api"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/mocha": "^8.2.3",
|
||||||
|
"@types/node": "^15.14.9",
|
||||||
|
"mocha": "^9.2.2",
|
||||||
|
"onchange": "^7.1.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsrpc-cli": "^2.4.5",
|
||||||
|
"typescript": "^4.9.5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"tsrpc": "^3.4.21"
|
||||||
|
}
|
||||||
|
}
|
||||||
74
server/src/api/ApiLogin.ts
Normal file
74
server/src/api/ApiLogin.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { ApiCall } from "tsrpc";
|
||||||
|
import { ReqLogin, ResLogin, PlayerInfo } from "../shared/protocols/PtlLogin";
|
||||||
|
import { playerManager } from "../managers/PlayerManager";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录API
|
||||||
|
* 处理玩家登录流程:
|
||||||
|
* 1. 查找已有角色
|
||||||
|
* 2. 若无角色,自动注册并创建角色
|
||||||
|
* 3. 分配出生点
|
||||||
|
* 4. 返回角色信息
|
||||||
|
*/
|
||||||
|
export default async function (call: ApiCall<ReqLogin, ResLogin>) {
|
||||||
|
const { playerId, playerName } = call.req;
|
||||||
|
|
||||||
|
// 验证玩家ID
|
||||||
|
if (!playerId || playerId.trim().length === 0) {
|
||||||
|
call.error('玩家ID不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查玩家是否已存在
|
||||||
|
const isNewPlayer = !playerManager.hasPlayer(playerId);
|
||||||
|
|
||||||
|
// 获取或创建玩家(自动处理注册和创角)
|
||||||
|
const player = playerManager.getOrCreatePlayer(playerId, playerName);
|
||||||
|
|
||||||
|
// 设置玩家在线状态
|
||||||
|
playerManager.setPlayerOnline(playerId, call.conn.id);
|
||||||
|
|
||||||
|
// 保存玩家ID到连接对象,供其他API使用
|
||||||
|
(call.conn as any).playerId = playerId;
|
||||||
|
|
||||||
|
// 转换为协议格式
|
||||||
|
const playerInfo: PlayerInfo = {
|
||||||
|
id: player.id,
|
||||||
|
name: player.name,
|
||||||
|
position: { ...player.position },
|
||||||
|
spawnPoint: { ...player.spawnPoint },
|
||||||
|
hp: player.hp,
|
||||||
|
maxHp: player.maxHp,
|
||||||
|
isAlive: player.isAlive,
|
||||||
|
createdAt: player.createdAt,
|
||||||
|
lastLoginAt: player.lastLoginAt
|
||||||
|
};
|
||||||
|
|
||||||
|
// 广播玩家加入消息给其他在线玩家
|
||||||
|
const { broadcastToAll } = await import('../utils/broadcast');
|
||||||
|
|
||||||
|
broadcastToAll('PlayerJoin', {
|
||||||
|
playerId: player.id,
|
||||||
|
playerName: player.name,
|
||||||
|
position: { ...player.position },
|
||||||
|
isNewPlayer,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}, call.conn.id);
|
||||||
|
|
||||||
|
// 返回成功结果
|
||||||
|
call.succ({
|
||||||
|
success: true,
|
||||||
|
message: isNewPlayer ? '创建角色成功,欢迎来到游戏世界!' : '登录成功,欢迎回来!',
|
||||||
|
player: playerInfo,
|
||||||
|
isNewPlayer
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`玩家 ${player.name} (${playerId}) ${isNewPlayer ? '首次登录' : '登录成功'}`);
|
||||||
|
console.log(` 在线玩家数: ${playerManager.getOnlinePlayerCount()}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('登录失败:', error);
|
||||||
|
call.error(`登录失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
server/src/api/ApiMove.ts
Normal file
86
server/src/api/ApiMove.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { ApiCall } from "tsrpc";
|
||||||
|
import { ReqMove, ResMove } from "../shared/protocols/PtlMove";
|
||||||
|
import { playerManager } from "../managers/PlayerManager";
|
||||||
|
import { broadcastToAll } from "../utils/broadcast";
|
||||||
|
import { MsgPlayerMove } from "../shared/protocols/MsgPlayerMove";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动API
|
||||||
|
* 处理玩家移动请求,验证位置合法性,更新玩家位置并广播给其他玩家
|
||||||
|
*/
|
||||||
|
export default async function (call: ApiCall<ReqMove, ResMove>) {
|
||||||
|
const { x, y } = call.req;
|
||||||
|
|
||||||
|
// 从连接中获取玩家ID(需要在登录时保存)
|
||||||
|
const playerId = (call.conn as any).playerId;
|
||||||
|
|
||||||
|
if (!playerId) {
|
||||||
|
call.error('未登录,请先登录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取玩家信息
|
||||||
|
const player = playerManager.getPlayer(playerId);
|
||||||
|
|
||||||
|
if (!player) {
|
||||||
|
call.error('玩家不存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查玩家是否存活
|
||||||
|
if (!player.isAlive) {
|
||||||
|
call.error('玩家已死亡,无法移动');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证坐标是否为数字
|
||||||
|
if (typeof x !== 'number' || typeof y !== 'number') {
|
||||||
|
call.error('坐标格式错误');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证坐标是否为整数
|
||||||
|
if (!Number.isInteger(x) || !Number.isInteger(y)) {
|
||||||
|
call.error('坐标必须为整数');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 尝试更新玩家位置
|
||||||
|
const success = playerManager.updatePlayerPosition(playerId, x, y);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
call.succ({
|
||||||
|
success: false,
|
||||||
|
message: '移动失败,位置无效或超出世界边界'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取更新后的位置
|
||||||
|
const newPosition = { x, y };
|
||||||
|
|
||||||
|
// 广播移动消息给所有其他玩家
|
||||||
|
const moveMsg: MsgPlayerMove = {
|
||||||
|
playerId: player.id,
|
||||||
|
playerName: player.name,
|
||||||
|
position: newPosition,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
broadcastToAll('MsgPlayerMove', moveMsg, call.conn.id);
|
||||||
|
|
||||||
|
// 返回成功结果
|
||||||
|
call.succ({
|
||||||
|
success: true,
|
||||||
|
message: '移动成功',
|
||||||
|
position: newPosition
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`玩家 ${player.name} 移动到 (${x}, ${y})`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('移动失败:', error);
|
||||||
|
call.error(`移动失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
server/src/api/ApiSend.ts
Normal file
26
server/src/api/ApiSend.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { ApiCall } from "tsrpc";
|
||||||
|
import { server } from "..";
|
||||||
|
import { ReqSend, ResSend } from "../shared/protocols/PtlSend";
|
||||||
|
|
||||||
|
// This is a demo code file
|
||||||
|
// Feel free to delete it
|
||||||
|
|
||||||
|
export default async function (call: ApiCall<ReqSend, ResSend>) {
|
||||||
|
// Error
|
||||||
|
if (call.req.content.length === 0) {
|
||||||
|
call.error('Content is empty')
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
let time = new Date();
|
||||||
|
call.succ({
|
||||||
|
time: time
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast
|
||||||
|
server.broadcastMsg('Chat', {
|
||||||
|
content: call.req.content,
|
||||||
|
time: time
|
||||||
|
})
|
||||||
|
}
|
||||||
23
server/src/config/world.config.ts
Normal file
23
server/src/config/world.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* 世界配置参数
|
||||||
|
*/
|
||||||
|
export const WorldConfig = {
|
||||||
|
/** 世界宽度 */
|
||||||
|
WORLD_WIDTH: 800,
|
||||||
|
|
||||||
|
/** 世界高度 */
|
||||||
|
WORLD_HEIGHT: 800,
|
||||||
|
|
||||||
|
/** 出生区域半径(距离中心点) */
|
||||||
|
SPAWN_RADIUS: 200,
|
||||||
|
|
||||||
|
/** 世界中心点 X 坐标 */
|
||||||
|
get CENTER_X() {
|
||||||
|
return this.WORLD_WIDTH / 2;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 世界中心点 Y 坐标 */
|
||||||
|
get CENTER_Y() {
|
||||||
|
return this.WORLD_HEIGHT / 2;
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
28
server/src/index.ts
Normal file
28
server/src/index.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import * as path from "path";
|
||||||
|
import { WsServer } from "tsrpc";
|
||||||
|
import { serviceProto } from './shared/protocols/serviceProto';
|
||||||
|
import { worldManager } from './managers/WorldManager';
|
||||||
|
|
||||||
|
// Create the Server
|
||||||
|
export const server = new WsServer(serviceProto, {
|
||||||
|
port: 3000,
|
||||||
|
// Remove this to use binary mode (remove from the client too)
|
||||||
|
json: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize before server start
|
||||||
|
async function init() {
|
||||||
|
await server.autoImplementApi(path.resolve(__dirname, 'api'));
|
||||||
|
|
||||||
|
// 初始化游戏世界
|
||||||
|
console.log('正在初始化游戏世界...');
|
||||||
|
worldManager.initialize();
|
||||||
|
console.log('游戏世界初始化完成');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Entry function
|
||||||
|
async function main() {
|
||||||
|
await init();
|
||||||
|
await server.start();
|
||||||
|
}
|
||||||
|
main();
|
||||||
149
server/src/managers/PlayerManager.ts
Normal file
149
server/src/managers/PlayerManager.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { Player, createPlayer } from '../models/Player';
|
||||||
|
import { worldManager } from './WorldManager';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 玩家管理器
|
||||||
|
* 负责玩家数据的存储、查询和管理
|
||||||
|
*/
|
||||||
|
class PlayerManager {
|
||||||
|
/** 玩家数据存储 (key: playerId) */
|
||||||
|
private players: Map<string, Player> = new Map();
|
||||||
|
|
||||||
|
/** 在线玩家连接 (key: playerId, value: connectionId) */
|
||||||
|
private onlinePlayers: Map<string, string> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据玩家ID查找玩家
|
||||||
|
*/
|
||||||
|
getPlayer(playerId: string): Player | undefined {
|
||||||
|
return this.players.get(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查玩家是否存在
|
||||||
|
*/
|
||||||
|
hasPlayer(playerId: string): boolean {
|
||||||
|
return this.players.has(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新玩家
|
||||||
|
*/
|
||||||
|
createPlayer(playerId: string, playerName?: string): Player {
|
||||||
|
if (this.players.has(playerId)) {
|
||||||
|
throw new Error(`玩家 ${playerId} 已存在`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成出生点
|
||||||
|
const spawnPoint = worldManager.generateSpawnPosition();
|
||||||
|
|
||||||
|
// 创建玩家
|
||||||
|
const name = playerName || `玩家${playerId.substring(0, 6)}`;
|
||||||
|
const player = createPlayer(playerId, name, spawnPoint);
|
||||||
|
|
||||||
|
// 保存玩家数据
|
||||||
|
this.players.set(playerId, player);
|
||||||
|
|
||||||
|
console.log(`创建新玩家: ${player.name} (${playerId})`);
|
||||||
|
console.log(` 出生点: (${spawnPoint.x}, ${spawnPoint.y})`);
|
||||||
|
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取或创建玩家(登录流程)
|
||||||
|
*/
|
||||||
|
getOrCreatePlayer(playerId: string, playerName?: string): Player {
|
||||||
|
let player = this.players.get(playerId);
|
||||||
|
|
||||||
|
if (player) {
|
||||||
|
// 玩家已存在,更新最后登录时间
|
||||||
|
player.lastLoginAt = Date.now();
|
||||||
|
console.log(`玩家登录: ${player.name} (${playerId})`);
|
||||||
|
} else {
|
||||||
|
// 新玩家,创建角色
|
||||||
|
player = this.createPlayer(playerId, playerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置玩家在线状态
|
||||||
|
*/
|
||||||
|
setPlayerOnline(playerId: string, connectionId: string): void {
|
||||||
|
this.onlinePlayers.set(playerId, connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置玩家离线
|
||||||
|
*/
|
||||||
|
setPlayerOffline(playerId: string): void {
|
||||||
|
this.onlinePlayers.delete(playerId);
|
||||||
|
console.log(`玩家离线: ${playerId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查玩家是否在线
|
||||||
|
*/
|
||||||
|
isPlayerOnline(playerId: string): boolean {
|
||||||
|
return this.onlinePlayers.has(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取玩家连接ID
|
||||||
|
*/
|
||||||
|
getPlayerConnectionId(playerId: string): string | undefined {
|
||||||
|
return this.onlinePlayers.get(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新玩家位置
|
||||||
|
*/
|
||||||
|
updatePlayerPosition(playerId: string, x: number, y: number): boolean {
|
||||||
|
const player = this.players.get(playerId);
|
||||||
|
if (!player || !player.isAlive) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证位置是否合法
|
||||||
|
if (!worldManager.isValidPosition({ x, y })) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
player.position.x = x;
|
||||||
|
player.position.y = y;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有在线玩家
|
||||||
|
*/
|
||||||
|
getOnlinePlayers(): Player[] {
|
||||||
|
const players: Player[] = [];
|
||||||
|
for (const playerId of this.onlinePlayers.keys()) {
|
||||||
|
const player = this.players.get(playerId);
|
||||||
|
if (player) {
|
||||||
|
players.push(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return players;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取玩家总数
|
||||||
|
*/
|
||||||
|
getTotalPlayers(): number {
|
||||||
|
return this.players.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取在线玩家数
|
||||||
|
*/
|
||||||
|
getOnlinePlayerCount(): number {
|
||||||
|
return this.onlinePlayers.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
export const playerManager = new PlayerManager();
|
||||||
79
server/src/managers/WorldManager.ts
Normal file
79
server/src/managers/WorldManager.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { World, createWorld } from '../models/World';
|
||||||
|
import { Position } from '../models/Position';
|
||||||
|
import { WorldConfig } from '../config/world.config';
|
||||||
|
import { randomPositionInCircle } from '../utils/math';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 世界管理器
|
||||||
|
* 负责世界初始化和地图管理
|
||||||
|
*/
|
||||||
|
class WorldManager {
|
||||||
|
private world: World | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化世界
|
||||||
|
*/
|
||||||
|
initialize(): void {
|
||||||
|
if (this.world) {
|
||||||
|
console.log('世界已经初始化,跳过重复初始化');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.world = createWorld(
|
||||||
|
WorldConfig.WORLD_WIDTH,
|
||||||
|
WorldConfig.WORLD_HEIGHT,
|
||||||
|
WorldConfig.SPAWN_RADIUS
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`世界初始化完成:`);
|
||||||
|
console.log(` 尺寸: ${this.world.width}x${this.world.height}`);
|
||||||
|
console.log(` 中心点: (${this.world.center.x}, ${this.world.center.y})`);
|
||||||
|
console.log(` 出生区域半径: ${this.world.spawnRadius}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取世界实例
|
||||||
|
*/
|
||||||
|
getWorld(): World {
|
||||||
|
if (!this.world) {
|
||||||
|
throw new Error('世界尚未初始化');
|
||||||
|
}
|
||||||
|
return this.world;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成出生点位置
|
||||||
|
*/
|
||||||
|
generateSpawnPosition(): Position {
|
||||||
|
if (!this.world) {
|
||||||
|
throw new Error('世界尚未初始化');
|
||||||
|
}
|
||||||
|
|
||||||
|
return randomPositionInCircle(this.world.center, this.world.spawnRadius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查位置是否在世界范围内
|
||||||
|
*/
|
||||||
|
isValidPosition(pos: Position): boolean {
|
||||||
|
if (!this.world) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pos.x >= 0 && pos.x < this.world.width &&
|
||||||
|
pos.y >= 0 && pos.y < this.world.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取世界中心点
|
||||||
|
*/
|
||||||
|
getCenter(): Position {
|
||||||
|
if (!this.world) {
|
||||||
|
throw new Error('世界尚未初始化');
|
||||||
|
}
|
||||||
|
return this.world.center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
export const worldManager = new WorldManager();
|
||||||
71
server/src/models/Player.ts
Normal file
71
server/src/models/Player.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Position } from './Position';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 玩家角色数据
|
||||||
|
*/
|
||||||
|
export interface Player {
|
||||||
|
/** 玩家唯一ID */
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** 玩家昵称 */
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** 当前位置 */
|
||||||
|
position: Position;
|
||||||
|
|
||||||
|
/** 出生点位置 */
|
||||||
|
spawnPoint: Position;
|
||||||
|
|
||||||
|
/** 当前生命值 */
|
||||||
|
hp: number;
|
||||||
|
|
||||||
|
/** 最大生命值 */
|
||||||
|
maxHp: number;
|
||||||
|
|
||||||
|
/** 是否存活 */
|
||||||
|
isAlive: boolean;
|
||||||
|
|
||||||
|
/** 创建时间 */
|
||||||
|
createdAt: number;
|
||||||
|
|
||||||
|
/** 最后登录时间 */
|
||||||
|
lastLoginAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新玩家
|
||||||
|
*/
|
||||||
|
export function createPlayer(id: string, name: string, spawnPoint: Position): Player {
|
||||||
|
const maxHp = 10; // 初始最大生命值10点
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
position: { ...spawnPoint }, // 初始位置在出生点
|
||||||
|
spawnPoint: { ...spawnPoint },
|
||||||
|
hp: maxHp,
|
||||||
|
maxHp,
|
||||||
|
isAlive: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
lastLoginAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 玩家复活
|
||||||
|
*/
|
||||||
|
export function respawnPlayer(player: Player): void {
|
||||||
|
player.position = { ...player.spawnPoint };
|
||||||
|
player.hp = player.maxHp;
|
||||||
|
player.isAlive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 玩家受伤
|
||||||
|
*/
|
||||||
|
export function damagePlayer(player: Player, damage: number): void {
|
||||||
|
player.hp = Math.max(0, player.hp - damage);
|
||||||
|
if (player.hp <= 0) {
|
||||||
|
player.isAlive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
server/src/models/Position.ts
Normal file
37
server/src/models/Position.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 位置坐标接口
|
||||||
|
*/
|
||||||
|
export interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建位置对象
|
||||||
|
*/
|
||||||
|
export function createPosition(x: number, y: number): Position {
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两点之间的距离
|
||||||
|
*/
|
||||||
|
export function getDistance(pos1: Position, pos2: Position): number {
|
||||||
|
const dx = pos1.x - pos2.x;
|
||||||
|
const dy = pos1.y - pos2.y;
|
||||||
|
return Math.sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查位置是否在范围内
|
||||||
|
*/
|
||||||
|
export function isInBounds(pos: Position, width: number, height: number): boolean {
|
||||||
|
return pos.x >= 0 && pos.x < width && pos.y >= 0 && pos.y < height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查位置是否在圆形区域内
|
||||||
|
*/
|
||||||
|
export function isInCircle(pos: Position, center: Position, radius: number): boolean {
|
||||||
|
return getDistance(pos, center) <= radius;
|
||||||
|
}
|
||||||
37
server/src/models/World.ts
Normal file
37
server/src/models/World.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Position } from './Position';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 世界数据结构
|
||||||
|
*/
|
||||||
|
export interface World {
|
||||||
|
/** 世界宽度 */
|
||||||
|
width: number;
|
||||||
|
|
||||||
|
/** 世界高度 */
|
||||||
|
height: number;
|
||||||
|
|
||||||
|
/** 出生区域半径 */
|
||||||
|
spawnRadius: number;
|
||||||
|
|
||||||
|
/** 世界中心点 */
|
||||||
|
center: Position;
|
||||||
|
|
||||||
|
/** 世界创建时间 */
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建世界实例
|
||||||
|
*/
|
||||||
|
export function createWorld(width: number, height: number, spawnRadius: number): World {
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
spawnRadius,
|
||||||
|
center: {
|
||||||
|
x: width / 2,
|
||||||
|
y: height / 2
|
||||||
|
},
|
||||||
|
createdAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
7
server/src/shared/protocols/MsgChat.ts
Normal file
7
server/src/shared/protocols/MsgChat.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// This is a demo code file
|
||||||
|
// Feel free to delete it
|
||||||
|
|
||||||
|
export interface MsgChat {
|
||||||
|
content: string,
|
||||||
|
time: Date
|
||||||
|
}
|
||||||
22
server/src/shared/protocols/MsgPlayerJoin.ts
Normal file
22
server/src/shared/protocols/MsgPlayerJoin.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Position } from './base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 玩家加入游戏广播消息
|
||||||
|
* 当有新玩家登录或加入游戏时,广播给所有在线玩家
|
||||||
|
*/
|
||||||
|
export interface MsgPlayerJoin {
|
||||||
|
/** 加入的玩家ID */
|
||||||
|
playerId: string;
|
||||||
|
|
||||||
|
/** 玩家昵称 */
|
||||||
|
playerName: string;
|
||||||
|
|
||||||
|
/** 玩家位置 */
|
||||||
|
position: Position;
|
||||||
|
|
||||||
|
/** 是否新玩家 */
|
||||||
|
isNewPlayer: boolean;
|
||||||
|
|
||||||
|
/** 加入时间戳 */
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
19
server/src/shared/protocols/MsgPlayerMove.ts
Normal file
19
server/src/shared/protocols/MsgPlayerMove.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Position } from './base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 玩家移动广播消息
|
||||||
|
* 当有玩家移动时,广播给所有在线玩家
|
||||||
|
*/
|
||||||
|
export interface MsgPlayerMove {
|
||||||
|
/** 移动的玩家ID */
|
||||||
|
playerId: string;
|
||||||
|
|
||||||
|
/** 玩家昵称 */
|
||||||
|
playerName: string;
|
||||||
|
|
||||||
|
/** 移动后的位置 */
|
||||||
|
position: Position;
|
||||||
|
|
||||||
|
/** 移动时间戳 */
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
61
server/src/shared/protocols/PtlLogin.ts
Normal file
61
server/src/shared/protocols/PtlLogin.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Position } from './base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录请求
|
||||||
|
*/
|
||||||
|
export interface ReqLogin {
|
||||||
|
/** 玩家ID(用于识别玩家) */
|
||||||
|
playerId: string;
|
||||||
|
|
||||||
|
/** 玩家昵称(可选,新玩家时使用) */
|
||||||
|
playerName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 玩家角色信息
|
||||||
|
*/
|
||||||
|
export interface PlayerInfo {
|
||||||
|
/** 玩家ID */
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** 玩家昵称 */
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** 当前位置 */
|
||||||
|
position: Position;
|
||||||
|
|
||||||
|
/** 出生点 */
|
||||||
|
spawnPoint: Position;
|
||||||
|
|
||||||
|
/** 当前生命值 */
|
||||||
|
hp: number;
|
||||||
|
|
||||||
|
/** 最大生命值 */
|
||||||
|
maxHp: number;
|
||||||
|
|
||||||
|
/** 是否存活 */
|
||||||
|
isAlive: boolean;
|
||||||
|
|
||||||
|
/** 创建时间 */
|
||||||
|
createdAt: number;
|
||||||
|
|
||||||
|
/** 最后登录时间 */
|
||||||
|
lastLoginAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录响应
|
||||||
|
*/
|
||||||
|
export interface ResLogin {
|
||||||
|
/** 是否成功 */
|
||||||
|
success: boolean;
|
||||||
|
|
||||||
|
/** 消息 */
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
/** 玩家信息 */
|
||||||
|
player?: PlayerInfo;
|
||||||
|
|
||||||
|
/** 是否新玩家 */
|
||||||
|
isNewPlayer?: boolean;
|
||||||
|
}
|
||||||
26
server/src/shared/protocols/PtlMove.ts
Normal file
26
server/src/shared/protocols/PtlMove.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Position } from './base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动请求
|
||||||
|
*/
|
||||||
|
export interface ReqMove {
|
||||||
|
/** 目标位置 X 坐标 */
|
||||||
|
x: number;
|
||||||
|
|
||||||
|
/** 目标位置 Y 坐标 */
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动响应
|
||||||
|
*/
|
||||||
|
export interface ResMove {
|
||||||
|
/** 是否成功 */
|
||||||
|
success: boolean;
|
||||||
|
|
||||||
|
/** 消息 */
|
||||||
|
message?: string;
|
||||||
|
|
||||||
|
/** 实际移动后的位置 */
|
||||||
|
position?: Position;
|
||||||
|
}
|
||||||
10
server/src/shared/protocols/PtlSend.ts
Normal file
10
server/src/shared/protocols/PtlSend.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// This is a demo code file
|
||||||
|
// Feel free to delete it
|
||||||
|
|
||||||
|
export interface ReqSend {
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResSend {
|
||||||
|
time: Date
|
||||||
|
}
|
||||||
23
server/src/shared/protocols/base.ts
Normal file
23
server/src/shared/protocols/base.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export interface BaseRequest {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseResponse {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseConf {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseMessage {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 位置坐标
|
||||||
|
*/
|
||||||
|
export interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
378
server/src/shared/protocols/serviceProto.ts
Normal file
378
server/src/shared/protocols/serviceProto.ts
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
import { ServiceProto } from 'tsrpc-proto';
|
||||||
|
import { MsgChat } from './MsgChat';
|
||||||
|
import { MsgPlayerJoin } from './MsgPlayerJoin';
|
||||||
|
import { MsgPlayerMove } from './MsgPlayerMove';
|
||||||
|
import { ReqLogin, ResLogin } from './PtlLogin';
|
||||||
|
import { ReqMove, ResMove } from './PtlMove';
|
||||||
|
import { ReqSend, ResSend } from './PtlSend';
|
||||||
|
|
||||||
|
export interface ServiceType {
|
||||||
|
api: {
|
||||||
|
"Login": {
|
||||||
|
req: ReqLogin,
|
||||||
|
res: ResLogin
|
||||||
|
},
|
||||||
|
"Move": {
|
||||||
|
req: ReqMove,
|
||||||
|
res: ResMove
|
||||||
|
},
|
||||||
|
"Send": {
|
||||||
|
req: ReqSend,
|
||||||
|
res: ResSend
|
||||||
|
}
|
||||||
|
},
|
||||||
|
msg: {
|
||||||
|
"Chat": MsgChat,
|
||||||
|
"PlayerJoin": MsgPlayerJoin,
|
||||||
|
"PlayerMove": MsgPlayerMove
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serviceProto: ServiceProto<ServiceType> = {
|
||||||
|
"version": 3,
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"name": "Chat",
|
||||||
|
"type": "msg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "PlayerJoin",
|
||||||
|
"type": "msg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"name": "PlayerMove",
|
||||||
|
"type": "msg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "Login",
|
||||||
|
"type": "api"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"name": "Move",
|
||||||
|
"type": "api"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Send",
|
||||||
|
"type": "api"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"types": {
|
||||||
|
"MsgChat/MsgChat": {
|
||||||
|
"type": "Interface",
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"name": "content",
|
||||||
|
"type": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "time",
|
||||||
|
"type": {
|
||||||
|
"type": "Date"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"MsgPlayerJoin/MsgPlayerJoin": {
|
||||||
|
"type": "Interface",
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"name": "playerId",
|
||||||
|
"type": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "playerName",
|
||||||
|
"type": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "position",
|
||||||
|
"type": {
|
||||||
|
"type": "Reference",
|
||||||
|
"target": "base/Position"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "isNewPlayer",
|
||||||
|
"type": {
|
||||||
|
"type": "Boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"name": "timestamp",
|
||||||
|
"type": {
|
||||||
|
"type": "Number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"base/Position": {
|
||||||
|
"type": "Interface",
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"name": "x",
|
||||||
|
"type": {
|
||||||
|
"type": "Number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "y",
|
||||||
|
"type": {
|
||||||
|
"type": "Number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"MsgPlayerMove/MsgPlayerMove": {
|
||||||
|
"type": "Interface",
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"name": "playerId",
|
||||||
|
"type": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "playerName",
|
||||||
|
"type": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "position",
|
||||||
|
"type": {
|
||||||
|
"type": "Reference",
|
||||||
|
"target": "base/Position"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "timestamp",
|
||||||
|
"type": {
|
||||||
|
"type": "Number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PtlLogin/ReqLogin": {
|
||||||
|
"type": "Interface",
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "playerId",
|
||||||
|
"type": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "playerName",
|
||||||
|
"type": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PtlLogin/ResLogin": {
|
||||||
|
"type": "Interface",
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"name": "success",
|
||||||
|
"type": {
|
||||||
|
"type": "Boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "message",
|
||||||
|
"type": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"name": "player",
|
||||||
|
"type": {
|
||||||
|
"type": "Reference",
|
||||||
|
"target": "PtlLogin/PlayerInfo"
|
||||||
|
},
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"name": "isNewPlayer",
|
||||||
|
"type": {
|
||||||
|
"type": "Boolean"
|
||||||
|
},
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PtlLogin/PlayerInfo": {
|
||||||
|
"type": "Interface",
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "name",
|
||||||
|
"type": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "position",
|
||||||
|
"type": {
|
||||||
|
"type": "Reference",
|
||||||
|
"target": "base/Position"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "spawnPoint",
|
||||||
|
"type": {
|
||||||
|
"type": "Reference",
|
||||||
|
"target": "base/Position"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"name": "hp",
|
||||||
|
"type": {
|
||||||
|
"type": "Number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"name": "maxHp",
|
||||||
|
"type": {
|
||||||
|
"type": "Number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"name": "isAlive",
|
||||||
|
"type": {
|
||||||
|
"type": "Boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": {
|
||||||
|
"type": "Number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"name": "lastLoginAt",
|
||||||
|
"type": {
|
||||||
|
"type": "Number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PtlMove/ReqMove": {
|
||||||
|
"type": "Interface",
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"name": "x",
|
||||||
|
"type": {
|
||||||
|
"type": "Number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "y",
|
||||||
|
"type": {
|
||||||
|
"type": "Number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PtlMove/ResMove": {
|
||||||
|
"type": "Interface",
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"name": "success",
|
||||||
|
"type": {
|
||||||
|
"type": "Boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "message",
|
||||||
|
"type": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "position",
|
||||||
|
"type": {
|
||||||
|
"type": "Reference",
|
||||||
|
"target": "base/Position"
|
||||||
|
},
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PtlSend/ReqSend": {
|
||||||
|
"type": "Interface",
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"name": "content",
|
||||||
|
"type": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PtlSend/ResSend": {
|
||||||
|
"type": "Interface",
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"name": "time",
|
||||||
|
"type": {
|
||||||
|
"type": "Date"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
67
server/src/utils/broadcast.ts
Normal file
67
server/src/utils/broadcast.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { server } from '../index';
|
||||||
|
import { playerManager } from '../managers/PlayerManager';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广播消息给所有在线玩家
|
||||||
|
* @param msgName 消息名称
|
||||||
|
* @param msg 消息内容
|
||||||
|
* @param excludeConnectionId 排除的连接ID(可选,用于排除发送者自己)
|
||||||
|
*/
|
||||||
|
export function broadcastToAll<T>(msgName: string, msg: T, excludeConnectionId?: string): void {
|
||||||
|
const onlinePlayers = playerManager.getOnlinePlayers();
|
||||||
|
const targetConns = [];
|
||||||
|
|
||||||
|
for (const player of onlinePlayers) {
|
||||||
|
const connectionId = playerManager.getPlayerConnectionId(player.id);
|
||||||
|
|
||||||
|
if (!connectionId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果指定了排除的连接ID,跳过该连接
|
||||||
|
if (excludeConnectionId && connectionId === excludeConnectionId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn = server.connections.find(c => c.id === connectionId);
|
||||||
|
if (conn) {
|
||||||
|
targetConns.push(conn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 broadcastMsg 批量发送,只执行一次序列化
|
||||||
|
if (targetConns.length > 0) {
|
||||||
|
(server as any).broadcastMsg(msgName, msg, targetConns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广播消息给指定玩家
|
||||||
|
* @param playerId 玩家ID
|
||||||
|
* @param msgName 消息名称
|
||||||
|
* @param msg 消息内容
|
||||||
|
*/
|
||||||
|
export function broadcastToPlayer<T>(playerId: string, msgName: string, msg: T): void {
|
||||||
|
const connectionId = playerManager.getPlayerConnectionId(playerId);
|
||||||
|
|
||||||
|
if (!connectionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn = server.connections.find(c => c.id === connectionId);
|
||||||
|
if (conn) {
|
||||||
|
(conn as any).sendMsg(msgName, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广播消息给多个玩家
|
||||||
|
* @param playerIds 玩家ID列表
|
||||||
|
* @param msgName 消息名称
|
||||||
|
* @param msg 消息内容
|
||||||
|
*/
|
||||||
|
export function broadcastToPlayers<T>(playerIds: string[], msgName: string, msg: T): void {
|
||||||
|
for (const playerId of playerIds) {
|
||||||
|
broadcastToPlayer(playerId, msgName, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
server/src/utils/math.ts
Normal file
54
server/src/utils/math.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Position } from '../models/Position';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成指定范围内的随机整数
|
||||||
|
*/
|
||||||
|
export function randomInt(min: number, max: number): number {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成随机浮点数
|
||||||
|
*/
|
||||||
|
export function randomFloat(min: number, max: number): number {
|
||||||
|
return Math.random() * (max - min) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在圆形区域内生成随机位置
|
||||||
|
* @param center 圆心
|
||||||
|
* @param radius 半径
|
||||||
|
*/
|
||||||
|
export function randomPositionInCircle(center: Position, radius: number): Position {
|
||||||
|
// 使用极坐标转换,确保均匀分布
|
||||||
|
const angle = Math.random() * 2 * Math.PI;
|
||||||
|
const r = Math.sqrt(Math.random()) * radius;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.round(center.x + r * Math.cos(angle)),
|
||||||
|
y: Math.round(center.y + r * Math.sin(angle))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两点之间的欧几里得距离
|
||||||
|
*/
|
||||||
|
export function distance(pos1: Position, pos2: Position): number {
|
||||||
|
const dx = pos1.x - pos2.x;
|
||||||
|
const dy = pos1.y - pos2.y;
|
||||||
|
return Math.sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两点之间的曼哈顿距离
|
||||||
|
*/
|
||||||
|
export function manhattanDistance(pos1: Position, pos2: Position): number {
|
||||||
|
return Math.abs(pos1.x - pos2.x) + Math.abs(pos1.y - pos2.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 限制数值在指定范围内
|
||||||
|
*/
|
||||||
|
export function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
40
server/test/api/ApiSend.test.ts
Normal file
40
server/test/api/ApiSend.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import assert from 'assert';
|
||||||
|
import { TsrpcError, WsClient } from 'tsrpc';
|
||||||
|
import { serviceProto } from '../../src/shared/protocols/serviceProto';
|
||||||
|
|
||||||
|
// 1. EXECUTE `npm run dev` TO START A LOCAL DEV SERVER
|
||||||
|
// 2. EXECUTE `npm test` TO START UNIT TEST
|
||||||
|
|
||||||
|
describe('ApiSend', function () {
|
||||||
|
let client = new WsClient(serviceProto, {
|
||||||
|
server: 'ws://127.0.0.1:3000',
|
||||||
|
json: true,
|
||||||
|
logger: console
|
||||||
|
});
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
let res = await client.connect();
|
||||||
|
assert.strictEqual(res.isSucc, true, 'Failed to connect to server, have you executed `npm run dev` already?');
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Success', async function () {
|
||||||
|
let ret = await client.callApi('Send', {
|
||||||
|
content: 'Test'
|
||||||
|
});
|
||||||
|
assert.ok(ret.isSucc)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Check content is empty', async function () {
|
||||||
|
let ret = await client.callApi('Send', {
|
||||||
|
content: ''
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(ret, {
|
||||||
|
isSucc: false,
|
||||||
|
err: new TsrpcError('Content is empty')
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async function () {
|
||||||
|
await client.disconnect();
|
||||||
|
})
|
||||||
|
})
|
||||||
19
server/test/tsconfig.json
Normal file
19
server/test/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": [
|
||||||
|
"es2018"
|
||||||
|
],
|
||||||
|
"module": "commonjs",
|
||||||
|
"target": "es2018",
|
||||||
|
"outDir": "dist",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"moduleResolution": "node"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
".",
|
||||||
|
"../src"
|
||||||
|
]
|
||||||
|
}
|
||||||
18
server/tsconfig.json
Normal file
18
server/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": [
|
||||||
|
"es2018"
|
||||||
|
],
|
||||||
|
"module": "commonjs",
|
||||||
|
"target": "es2018",
|
||||||
|
"outDir": "dist",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"moduleResolution": "node"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
38
server/tsrpc.config.ts
Normal file
38
server/tsrpc.config.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { TsrpcConfig } from 'tsrpc-cli';
|
||||||
|
|
||||||
|
export default <TsrpcConfig>{
|
||||||
|
// Generate ServiceProto
|
||||||
|
proto: [
|
||||||
|
{
|
||||||
|
ptlDir: 'src/shared/protocols', // Protocol dir
|
||||||
|
output: 'src/shared/protocols/serviceProto.ts', // Path for generated ServiceProto
|
||||||
|
apiDir: 'src/api', // API dir
|
||||||
|
docDir: 'docs', // API documents dir
|
||||||
|
ptlTemplate: { baseFile: 'src/shared/protocols/base.ts' },
|
||||||
|
// msgTemplate: { baseFile: 'src/shared/protocols/base.ts' },
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// Sync shared code
|
||||||
|
sync: [
|
||||||
|
// {
|
||||||
|
// from: 'src/shared',
|
||||||
|
// to: '../frontend/src/shared',
|
||||||
|
// type: 'symlink' // Change this to 'copy' if your environment not support symlink
|
||||||
|
// }
|
||||||
|
],
|
||||||
|
// Dev server
|
||||||
|
dev: {
|
||||||
|
autoProto: true, // Auto regenerate proto
|
||||||
|
autoSync: true, // Auto sync when file changed
|
||||||
|
autoApi: true, // Auto create API when ServiceProto updated
|
||||||
|
watch: 'src', // Restart dev server when these files changed
|
||||||
|
entry: 'src/index.ts', // Dev server command: node -r ts-node/register {entry}
|
||||||
|
},
|
||||||
|
// Build config
|
||||||
|
build: {
|
||||||
|
autoProto: true, // Auto generate proto before build
|
||||||
|
autoSync: true, // Auto sync before build
|
||||||
|
autoApi: true, // Auto generate API before build
|
||||||
|
outDir: 'dist', // Clean this dir before build
|
||||||
|
}
|
||||||
|
}
|
||||||
1336
server/yarn.lock
Normal file
1336
server/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user