世界、玩家登录、加入广播。

This commit is contained in:
janing
2025-12-14 22:36:05 +08:00
commit 4dc5fc6cca
34 changed files with 2965 additions and 0 deletions

View 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
- 开发过程中会根据实际情况调整任务优先级和细节

View File

@@ -0,0 +1,4 @@
---
applyTo: '**'
---
这是TSRPC的服务端项目请使用中文回答

3
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.DS_STORE

11
server/.mocharc.js Normal file
View 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
View 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
View File

@@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules\\typescript\\lib"
}

30
server/Dockerfile Normal file
View 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
View 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
View 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"
}
}

View 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
View 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
View 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
})
}

View 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
View 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();

View 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();

View 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();

View 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;
}
}

View 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;
}

View 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()
};
}

View File

@@ -0,0 +1,7 @@
// This is a demo code file
// Feel free to delete it
export interface MsgChat {
content: string,
time: Date
}

View File

@@ -0,0 +1,22 @@
import { Position } from './base';
/**
* 玩家加入游戏广播消息
* 当有新玩家登录或加入游戏时,广播给所有在线玩家
*/
export interface MsgPlayerJoin {
/** 加入的玩家ID */
playerId: string;
/** 玩家昵称 */
playerName: string;
/** 玩家位置 */
position: Position;
/** 是否新玩家 */
isNewPlayer: boolean;
/** 加入时间戳 */
timestamp: number;
}

View File

@@ -0,0 +1,19 @@
import { Position } from './base';
/**
* 玩家移动广播消息
* 当有玩家移动时,广播给所有在线玩家
*/
export interface MsgPlayerMove {
/** 移动的玩家ID */
playerId: string;
/** 玩家昵称 */
playerName: string;
/** 移动后的位置 */
position: Position;
/** 移动时间戳 */
timestamp: number;
}

View 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;
}

View 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;
}

View 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
}

View File

@@ -0,0 +1,23 @@
export interface BaseRequest {
}
export interface BaseResponse {
}
export interface BaseConf {
}
export interface BaseMessage {
}
/**
* 位置坐标
*/
export interface Position {
x: number;
y: number;
}

View 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"
}
}
]
}
}
};

View 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
View 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));
}

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff