airplane basic server

This commit is contained in:
k8w 2021-12-28 21:57:18 +08:00
parent 52618125e1
commit b9b6be0c7f
25 changed files with 1899 additions and 123 deletions

View File

@ -0,0 +1,17 @@
import { ApiCall, ApiCallWs } from "tsrpc";
import { ReqLogin, ResLogin } from "../shared/protocols/PtlLogin";
let nextPlayerId = 1;
export async function ApiLogin(call: ApiCallWs<ReqLogin, ResLogin>) {
let playerId = nextPlayerId++;
call.conn.currentUser = {
id: playerId,
nickname: call.req.nickname
}
call.succ({
playerId: playerId
})
}

View File

@ -1,26 +0,0 @@
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 async function ApiSend(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,7 @@
import { ApiCall } from "tsrpc";
import { ReqCreateRoom, ResCreateRoom } from "../../shared/protocols/room/PtlCreateRoom";
export async function ApiCreateRoom(call: ApiCall<ReqCreateRoom, ResCreateRoom>) {
// TODO
call.error('API Not Implemented');
}

View File

@ -0,0 +1,7 @@
import { ApiCall } from "tsrpc";
import { ReqGetRoomList, ResGetRoomList } from "../../shared/protocols/room/PtlGetRoomList";
export async function ApiGetRoomList(call: ApiCall<ReqGetRoomList, ResGetRoomList>) {
// TODO
call.error('API Not Implemented');
}

View File

@ -0,0 +1,15 @@
import { ApiCallWs } from "tsrpc";
import { Room } from "../../models/Room";
import { ReqJoinRoom, ResJoinRoom } from "../../shared/protocols/room/PtlJoinRoom";
// TEST
let room = new Room(123);
export async function ApiJoinRoom(call: ApiCallWs<ReqJoinRoom, ResJoinRoom>) {
let op = room.join(call.conn);
if (!op.isSucc) {
return call.error(op.errMsg);
}
call.succ({});
}

View File

@ -0,0 +1,11 @@
import { ApiCall } from "tsrpc";
import { ReqSetReady, ResSetReady } from "../../shared/protocols/room/PtlSetReady";
export async function ApiSetReady(call: ApiCall<ReqSetReady, ResSetReady>) {
if (!call.conn.room) {
return call.error('您还未加入房间')
}
call.conn.room.setReady(call.conn.currentUser.id, call.req.isReady);
call.succ({})
}

View File

@ -0,0 +1,210 @@
import seedrandom from "seedrandom";
import { MsgCallWs, uint, WsConnection } from "tsrpc";
import { server } from "..";
import { gameConfig } from "../shared/game/gameConfig";
import { GameSystemInput } from "../shared/game/GameSystemInput";
import { GameSystemState } from "../shared/game/GameSystemState";
import { MsgGameInput } from "../shared/protocols/game/client/MsgGameInput";
import { ServiceType } from "../shared/protocols/serviceProto";
import { CurrentUser } from "../shared/types/CurrentUser";
const MAX_ROOM_USER = 2;
export interface RoomState {
id: uint;
players: { id: uint, nickname: string, isReady: boolean }[];
status: 'wait' | 'ready' | 'start';
}
/**
* - -
*/
export class Room {
// 房间信息
state: RoomState;
conns: WsConnection<ServiceType>[] = [];
constructor(roomId: uint) {
this.state = {
id: roomId,
players: [],
status: 'wait'
}
}
// #region Room Control
// 加入房间
join(conn: WsConnection<ServiceType>): { isSucc: true } | { isSucc: false, errMsg: string } {
if (this.state.status !== 'wait') {
return { isSucc: false, errMsg: '该房间游戏已开始' }
}
if (this.conns.length >= MAX_ROOM_USER) {
return { isSucc: false, errMsg: '房间已满员' }
}
this.conns.push(conn);
this.state.players.push({ ...conn.currentUser, isReady: false });
conn.room = this;
this._syncRoomState();
return { isSucc: true };
}
private _timerStartGame?: ReturnType<typeof setTimeout>;
setReady(playerId: number, isReady: boolean) {
if (this.state.status !== 'wait') {
return;
}
let player = this.state.players.find(v => v.id === playerId);
if (player) {
player.isReady = isReady;
}
// 如果人满了且所有人都准备了5 秒后开始游戏
if (this.state.players.length >= MAX_ROOM_USER && this.state.players.every(v => v.isReady)) {
this.state.status = 'ready';
this._syncRoomState();
this._timerStartGame = setTimeout(() => {
this._timerStartGame = undefined;
this.startGame();
}, 5000);
}
}
// 离开房间
leave(conn: WsConnection<ServiceType>) {
conn.room = undefined;
this.conns.removeOne(v => v === conn);
this.state.players.removeOne(v => v.id === conn.currentUser.id);
// 由于有人秒退,游戏无法正常开始
if (this._timerStartGame && this.conns.length < MAX_ROOM_USER) {
this.state.status = 'wait';
clearTimeout(this._timerStartGame);
this._timerStartGame = undefined;
}
this._syncRoomState();
}
private _syncRoomState() {
server.broadcastMsg('room/server/UpdateRoomState', {
state: this.state
}, this.conns)
}
// #endregion
// #region Game
private _lastSn: { [playerId: number]: number | undefined } = {};
private _lastFrameIndex: number = 0;
private _lastSyncTime!: number;
private _gameOveredUids: number[] = [];
async startGame() {
this.conns.forEach(v => {
v.listenMsg('game/client/GameInput', (call: MsgCallWs<MsgGameInput, ServiceType>) => {
this._lastSn[call.conn.currentUser.id] = call.msg.sn;
this._syncInputs(call.msg.inputs.map(v => ({ ...v, playerId: call.conn.currentUser.id })))
})
});
// 更新房间状态
this.state.status = 'start';
this._syncRoomState();
// 生成游戏初始状态
let seed = '' + Math.random();
let prng = seedrandom(seed, { state: true });
let initGameState: GameSystemState = {
now: 0,
players: this.conns.map(v => ({
id: v.currentUser.id,
nickname: v.currentUser.nickname,
score: 0,
life: gameConfig.player.totalLife,
currentBulletType: 'M',
pos: { x: 0, y: 100 },
bullets: [],
nextId: {
bullet: 0
}
})),
enemies: [],
fightIcons: [],
// ID 生成器
nextId: {
enemy: 0,
fightIcon: 0
},
// 上次创建敌机的时间
lastCreateEnemyTime: 3000,
// 伪随机数发生器状态
random: {
seed: '' + Math.random(),
state: prng.state()
}
};
this._lastSn = {};
this._lastSyncTime = Date.now();
this._lastFrameIndex = 0;
// 广播游戏初始状态
server.broadcastMsg('game/server/GameStart', {
frameIndex: this._lastFrameIndex,
gameState: initGameState
}, this.conns);
}
// 收到输入,立即同步给所有人
private _syncInputs(inputs: GameSystemInput[]) {
// 增加时间流逝
let now = process.uptime() * 1000;
inputs.unshift({
type: 'TimePast',
dt: now - this._lastSyncTime
});
this._lastSyncTime = now;
// 把输入广播给所有用户
this.conns.forEach(v => {
v.sendMsg('game/server/ServerFrame', {
frameIndex: this._lastFrameIndex,
inputs: inputs,
lastSn: this._lastSn[v.currentUser.id!]
})
});
}
async gameOver(uid: number) {
if (this.state.status !== 'start' || this._gameOveredUids.includes(uid)) {
return;
}
this._gameOveredUids.push(uid);
// 所有人都 GameOverGame 真的 Over
if (this.conns.every(v => this._gameOveredUids.includes(v.currentUser.id))) {
this.conns.forEach(v => {
v.unlistenMsgAll('game/client/GameInput');
});
this.state.status = 'wait';
this._syncRoomState();
}
}
// #endregion
}
declare module 'tsrpc' {
export interface BaseConnection {
currentUser: CurrentUser,
room?: Room,
}
}

View File

@ -38,6 +38,7 @@ export interface GameSystemState {
// 玩家 // 玩家
export interface PlayerState { export interface PlayerState {
id: uint, id: uint,
nickname: string,
// 得分 // 得分
score: number, score: number,
// 生命值 // 生命值

View File

@ -1,8 +1,6 @@
import { EnemyType } from "./GameSystemState"; import { EnemyType } from "./GameSystemState";
export const gameConfig = { export const gameConfig = {
syncRate: 10,
enemy: { enemy: {
bulletSpeed: 20, bulletSpeed: 20,
// 第一次发射子弹的延迟时间 // 第一次发射子弹的延迟时间
@ -22,5 +20,6 @@ export const gameConfig = {
player: { player: {
bulletSpeed: 10, bulletSpeed: 10,
totalLife: 10,
}, },
} }

View File

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

View File

@ -0,0 +1,14 @@
import { uint } from "tsrpc";
import { BaseConf, BaseRequest, BaseResponse } from "./base";
export interface ReqLogin extends BaseRequest {
nickname: string
}
export interface ResLogin extends BaseResponse {
playerId: uint
}
export const conf: BaseConf = {
}

View File

@ -1,10 +0,0 @@
// 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,13 @@
import { BulletHit, PlayerHitEnemy, PlayerHitFightIcon, PlayerHurt, PlayerMove } from "../../../game/GameSystemInput";
/** 发送自己的输入 */
export interface MsgGameInput {
sn: number,
inputs: ClientInput[]
}
export type ClientInput = Omit<PlayerMove, 'playerId'>
| Omit<PlayerHitEnemy, 'playerId'>
| Omit<PlayerHitFightIcon, 'playerId'>
| Omit<PlayerHurt, 'playerId'>
| Omit<BulletHit, 'playerId'>;

View File

@ -0,0 +1,5 @@
export interface MsgGameOver {
}
// export const conf = {}

View File

@ -0,0 +1,7 @@
import { uint } from "tsrpc";
import { GameSystemState } from "../../../game/GameSystemState";
export interface MsgGameStart {
frameIndex: uint,
gameState: GameSystemState
}

View File

@ -0,0 +1,13 @@
import { uint } from "tsrpc";
import { GameSystemInput } from "../../../game/GameSystemInput";
/**
* 广
*
*/
export interface MsgServerFrame {
frameIndex: uint,
inputs: GameSystemInput[],
/** 当前用户提交的,经服务端确认的最后一条输入的 SN */
lastSn?: number
}

View File

@ -0,0 +1,14 @@
import { uint } from "tsrpc-proto";
import { BaseConf, BaseRequest, BaseResponse } from "../base";
export interface ReqCreateRoom extends BaseRequest {
}
export interface ResCreateRoom extends BaseResponse {
roomId: uint
}
export const conf: BaseConf = {
}

View File

@ -0,0 +1,13 @@
import { BaseRequest, BaseResponse, BaseConf } from "../base";
export interface ReqGetRoomList extends BaseRequest {
}
export interface ResGetRoomList extends BaseResponse {
}
export const conf: BaseConf = {
}

View File

@ -0,0 +1,14 @@
import { uint } from "tsrpc-proto";
import { BaseConf, BaseRequest, BaseResponse } from "../base";
export interface ReqJoinRoom extends BaseRequest {
roomId: uint;
}
export interface ResJoinRoom extends BaseResponse {
}
export const conf: BaseConf = {
}

View File

@ -0,0 +1,14 @@
import { BaseConf, BaseRequest, BaseResponse } from "../base";
/** 准备 */
export interface ReqSetReady extends BaseRequest {
isReady: boolean
}
export interface ResSetReady extends BaseResponse {
}
export const conf: BaseConf = {
}

View File

@ -0,0 +1,7 @@
import { RoomState } from "../../../../models/Room";
export interface MsgUpdateRoomState {
state: RoomState
}
// export const conf = {}

View File

@ -0,0 +1,6 @@
import { uint } from "tsrpc";
export interface CurrentUser {
id: uint,
nickname: string
}

View File

@ -0,0 +1,133 @@
import { WsClient } from "tsrpc-browser";
import { GameSystem, GameSystemState } from "../shared/game/GameSystem";
import { ClientInput, MsgClientInput } from "../shared/protocols/client/MsgClientInput";
import { MsgFrame } from "../shared/protocols/server/MsgFrame";
import { serviceProto, ServiceType } from "../shared/protocols/serviceProto";
/**
*
*
*/
export class StateManager {
client: WsClient<ServiceType>;
gameSystem = new GameSystem();
lastServerState: GameSystemState = this.gameSystem.state;
lastRecvSetverStateTime = 0;
selfPlayerId: number = -1;
lastSN = 0;
get state() {
return this.gameSystem.state;
}
constructor() {
let client = this.client = new WsClient(serviceProto, {
server: `ws://${location.hostname}:3000`,
json: true,
// logger: console
});;
client.listenMsg('server/Frame', msg => { this._onServerSync(msg) });
// 模拟网络延迟 可通过 URL 参数 ?lag=200 设置延迟
let networkLag = parseInt(new URLSearchParams(location.search).get('lag') || '0') || 0;
if (networkLag) {
client.flows.preRecvDataFlow.push(async v => {
await new Promise(rs => { setTimeout(rs, networkLag) })
return v;
});
client.flows.preSendDataFlow.push(async v => {
await new Promise(rs => { setTimeout(rs, networkLag) })
return v;
});
}
(window as any).gm = this;
}
async join(): Promise<void> {
if (!this.client.isConnected) {
let resConnect = await this.client.connect();
if (!resConnect.isSucc) {
await new Promise(rs => { setTimeout(rs, 2000) })
return this.join();
}
}
let ret = await this.client.callApi('Join', {});
if (!ret.isSucc) {
if (confirm(`加入房间失败\n${ret.err.message}\n是否重试 ?`)) {
return this.join();
}
else {
return;
}
}
this.gameSystem.reset(ret.res.gameState);
this.lastServerState = Object.merge(ret.res.gameState);
this.lastRecvSetverStateTime = Date.now();
this.selfPlayerId = ret.res.playerId;
}
private _onServerSync(frame: MsgFrame) {
// 回滚至上一次的权威状态
this.gameSystem.reset(this.lastServerState);
// 计算最新的权威状态
for (let input of frame.inputs) {
this.gameSystem.applyInput(input);
}
this.lastServerState = Object.merge({}, this.gameSystem.state);
this.lastRecvSetverStateTime = Date.now();
// 和解 = 权威状态 + 本地输入 (最新的本地预测状态)
let lastSn = frame.lastSn ?? -1;
this.pendingInputMsgs.remove(v => v.sn <= lastSn);
this.pendingInputMsgs.forEach(m => {
m.inputs.forEach(v => {
this.gameSystem.applyInput({
...v,
playerId: this.selfPlayerId
});
})
})
}
pendingInputMsgs: MsgClientInput[] = [];
sendClientInput(input: ClientInput) {
// 已掉线或暂未加入,忽略本地输入
if (!this.selfPlayerId || !this.client.isConnected) {
return;
}
// 构造消息
let msg: MsgClientInput = {
sn: ++this.lastSN,
inputs: [input]
}
// 向服务端发送输入
this.pendingInputMsgs.push(msg);
this.client.sendMsg('client/ClientInput', msg);
// 预测状态:本地立即应用输入
this.gameSystem.applyInput({
...input,
playerId: this.selfPlayerId
});
}
// 本地时间流逝(会被下一次服务器状态覆盖)
localTimePast() {
this.gameSystem.applyInput({
type: 'TimePast',
dt: Date.now() - this.lastRecvSetverStateTime
});
this.lastRecvSetverStateTime = Date.now();
}
}

View File

@ -5,5 +5,8 @@
"name": "airplane", "name": "airplane",
"type": "3d", "type": "3d",
"uuid": "c794458c-05f6-4c9f-909b-20d54897d219", "uuid": "c794458c-05f6-4c9f-909b-20d54897d219",
"version": "3.4.0" "version": "3.4.0",
"dependencies": {
"tsrpc-browser": "^3.1.4"
}
} }