diff --git a/examples/cocos-creator-airplane/backend/src/api/ApiLogin.ts b/examples/cocos-creator-airplane/backend/src/api/ApiLogin.ts new file mode 100644 index 0000000..fc69659 --- /dev/null +++ b/examples/cocos-creator-airplane/backend/src/api/ApiLogin.ts @@ -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) { + let playerId = nextPlayerId++; + + call.conn.currentUser = { + id: playerId, + nickname: call.req.nickname + } + + call.succ({ + playerId: playerId + }) +} \ No newline at end of file diff --git a/examples/cocos-creator-airplane/backend/src/api/ApiSend.ts b/examples/cocos-creator-airplane/backend/src/api/ApiSend.ts deleted file mode 100644 index 1782b35..0000000 --- a/examples/cocos-creator-airplane/backend/src/api/ApiSend.ts +++ /dev/null @@ -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) { - // 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 - }) -} \ No newline at end of file diff --git a/examples/cocos-creator-airplane/backend/src/api/room/ApiCreateRoom.ts b/examples/cocos-creator-airplane/backend/src/api/room/ApiCreateRoom.ts new file mode 100644 index 0000000..d8a497d --- /dev/null +++ b/examples/cocos-creator-airplane/backend/src/api/room/ApiCreateRoom.ts @@ -0,0 +1,7 @@ +import { ApiCall } from "tsrpc"; +import { ReqCreateRoom, ResCreateRoom } from "../../shared/protocols/room/PtlCreateRoom"; + +export async function ApiCreateRoom(call: ApiCall) { + // TODO + call.error('API Not Implemented'); +} \ No newline at end of file diff --git a/examples/cocos-creator-airplane/backend/src/api/room/ApiGetRoomList.ts b/examples/cocos-creator-airplane/backend/src/api/room/ApiGetRoomList.ts new file mode 100644 index 0000000..6c37753 --- /dev/null +++ b/examples/cocos-creator-airplane/backend/src/api/room/ApiGetRoomList.ts @@ -0,0 +1,7 @@ +import { ApiCall } from "tsrpc"; +import { ReqGetRoomList, ResGetRoomList } from "../../shared/protocols/room/PtlGetRoomList"; + +export async function ApiGetRoomList(call: ApiCall) { + // TODO + call.error('API Not Implemented'); +} \ No newline at end of file diff --git a/examples/cocos-creator-airplane/backend/src/api/room/ApiJoinRoom.ts b/examples/cocos-creator-airplane/backend/src/api/room/ApiJoinRoom.ts new file mode 100644 index 0000000..6c3977a --- /dev/null +++ b/examples/cocos-creator-airplane/backend/src/api/room/ApiJoinRoom.ts @@ -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) { + let op = room.join(call.conn); + if (!op.isSucc) { + return call.error(op.errMsg); + } + + call.succ({}); +} \ No newline at end of file diff --git a/examples/cocos-creator-airplane/backend/src/api/room/ApiSetReady.ts b/examples/cocos-creator-airplane/backend/src/api/room/ApiSetReady.ts new file mode 100644 index 0000000..a32b555 --- /dev/null +++ b/examples/cocos-creator-airplane/backend/src/api/room/ApiSetReady.ts @@ -0,0 +1,11 @@ +import { ApiCall } from "tsrpc"; +import { ReqSetReady, ResSetReady } from "../../shared/protocols/room/PtlSetReady"; + +export async function ApiSetReady(call: ApiCall) { + if (!call.conn.room) { + return call.error('您还未加入房间') + } + + call.conn.room.setReady(call.conn.currentUser.id, call.req.isReady); + call.succ({}) +} \ No newline at end of file diff --git a/examples/cocos-creator-airplane/backend/src/models/Room.ts b/examples/cocos-creator-airplane/backend/src/models/Room.ts new file mode 100644 index 0000000..e5196e2 --- /dev/null +++ b/examples/cocos-creator-airplane/backend/src/models/Room.ts @@ -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[] = []; + + constructor(roomId: uint) { + this.state = { + id: roomId, + players: [], + status: 'wait' + } + } + + // #region Room Control + // 加入房间 + join(conn: WsConnection): { 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; + + 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) { + 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) => { + 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); + + // 所有人都 GameOver,Game 真的 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, + } +} \ No newline at end of file diff --git a/examples/cocos-creator-airplane/backend/src/shared/game/GameSystemState.ts b/examples/cocos-creator-airplane/backend/src/shared/game/GameSystemState.ts index 6649912..1aaa73c 100644 --- a/examples/cocos-creator-airplane/backend/src/shared/game/GameSystemState.ts +++ b/examples/cocos-creator-airplane/backend/src/shared/game/GameSystemState.ts @@ -38,6 +38,7 @@ export interface GameSystemState { // 玩家 export interface PlayerState { id: uint, + nickname: string, // 得分 score: number, // 生命值 diff --git a/examples/cocos-creator-airplane/backend/src/shared/game/gameConfig.ts b/examples/cocos-creator-airplane/backend/src/shared/game/gameConfig.ts index 2a50284..c096351 100644 --- a/examples/cocos-creator-airplane/backend/src/shared/game/gameConfig.ts +++ b/examples/cocos-creator-airplane/backend/src/shared/game/gameConfig.ts @@ -1,8 +1,6 @@ import { EnemyType } from "./GameSystemState"; export const gameConfig = { - syncRate: 10, - enemy: { bulletSpeed: 20, // 第一次发射子弹的延迟时间 @@ -22,5 +20,6 @@ export const gameConfig = { player: { bulletSpeed: 10, + totalLife: 10, }, } \ No newline at end of file diff --git a/examples/cocos-creator-airplane/backend/src/shared/protocols/MsgChat.ts b/examples/cocos-creator-airplane/backend/src/shared/protocols/MsgChat.ts deleted file mode 100644 index c879648..0000000 --- a/examples/cocos-creator-airplane/backend/src/shared/protocols/MsgChat.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This is a demo code file -// Feel free to delete it - -export interface MsgChat { - content: string, - time: Date -} \ No newline at end of file diff --git a/examples/cocos-creator-airplane/backend/src/shared/protocols/PtlLogin.ts b/examples/cocos-creator-airplane/backend/src/shared/protocols/PtlLogin.ts new file mode 100644 index 0000000..6b13b6f --- /dev/null +++ b/examples/cocos-creator-airplane/backend/src/shared/protocols/PtlLogin.ts @@ -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 = { + +} \ No newline at end of file diff --git a/examples/cocos-creator-airplane/backend/src/shared/protocols/PtlSend.ts b/examples/cocos-creator-airplane/backend/src/shared/protocols/PtlSend.ts deleted file mode 100644 index 6a5d9f6..0000000 --- a/examples/cocos-creator-airplane/backend/src/shared/protocols/PtlSend.ts +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/examples/cocos-creator-airplane/backend/src/shared/protocols/game/client/MsgGameInput.ts b/examples/cocos-creator-airplane/backend/src/shared/protocols/game/client/MsgGameInput.ts new file mode 100644 index 0000000..d834861 --- /dev/null +++ b/examples/cocos-creator-airplane/backend/src/shared/protocols/game/client/MsgGameInput.ts @@ -0,0 +1,13 @@ +import { BulletHit, PlayerHitEnemy, PlayerHitFightIcon, PlayerHurt, PlayerMove } from "../../../game/GameSystemInput"; + +/** 发送自己的输入 */ +export interface MsgGameInput { + sn: number, + inputs: ClientInput[] +} + +export type ClientInput = Omit + | Omit + | Omit + | Omit + | Omit; diff --git a/examples/cocos-creator-airplane/backend/src/shared/protocols/game/client/MsgGameOver.ts b/examples/cocos-creator-airplane/backend/src/shared/protocols/game/client/MsgGameOver.ts new file mode 100644 index 0000000..2c1342f --- /dev/null +++ b/examples/cocos-creator-airplane/backend/src/shared/protocols/game/client/MsgGameOver.ts @@ -0,0 +1,5 @@ +export interface MsgGameOver { + +} + +// export const conf = {} \ No newline at end of file diff --git a/examples/cocos-creator-airplane/backend/src/shared/protocols/game/server/MsgGameStart.ts b/examples/cocos-creator-airplane/backend/src/shared/protocols/game/server/MsgGameStart.ts new file mode 100644 index 0000000..6986fda --- /dev/null +++ b/examples/cocos-creator-airplane/backend/src/shared/protocols/game/server/MsgGameStart.ts @@ -0,0 +1,7 @@ +import { uint } from "tsrpc"; +import { GameSystemState } from "../../../game/GameSystemState"; + +export interface MsgGameStart { + frameIndex: uint, + gameState: GameSystemState +} \ No newline at end of file diff --git a/examples/cocos-creator-airplane/backend/src/shared/protocols/game/server/MsgServerFrame.ts b/examples/cocos-creator-airplane/backend/src/shared/protocols/game/server/MsgServerFrame.ts new file mode 100644 index 0000000..ecbd37a --- /dev/null +++ b/examples/cocos-creator-airplane/backend/src/shared/protocols/game/server/MsgServerFrame.ts @@ -0,0 +1,13 @@ +import { uint } from "tsrpc"; +import { GameSystemInput } from "../../../game/GameSystemInput"; + +/** + * 服务端定期广播的同步帧 + * 包含了这一段期间所有输入 + */ +export interface MsgServerFrame { + frameIndex: uint, + inputs: GameSystemInput[], + /** 当前用户提交的,经服务端确认的最后一条输入的 SN */ + lastSn?: number +} \ No newline at end of file diff --git a/examples/cocos-creator-airplane/backend/src/shared/protocols/room/PtlCreateRoom.ts b/examples/cocos-creator-airplane/backend/src/shared/protocols/room/PtlCreateRoom.ts new file mode 100644 index 0000000..776c80d --- /dev/null +++ b/examples/cocos-creator-airplane/backend/src/shared/protocols/room/PtlCreateRoom.ts @@ -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 = { + +} \ No newline at end of file diff --git a/examples/cocos-creator-airplane/backend/src/shared/protocols/room/PtlGetRoomList.ts b/examples/cocos-creator-airplane/backend/src/shared/protocols/room/PtlGetRoomList.ts new file mode 100644 index 0000000..3f1762b --- /dev/null +++ b/examples/cocos-creator-airplane/backend/src/shared/protocols/room/PtlGetRoomList.ts @@ -0,0 +1,13 @@ +import { BaseRequest, BaseResponse, BaseConf } from "../base"; + +export interface ReqGetRoomList extends BaseRequest { + +} + +export interface ResGetRoomList extends BaseResponse { + +} + +export const conf: BaseConf = { + +} \ No newline at end of file diff --git a/examples/cocos-creator-airplane/backend/src/shared/protocols/room/PtlJoinRoom.ts b/examples/cocos-creator-airplane/backend/src/shared/protocols/room/PtlJoinRoom.ts new file mode 100644 index 0000000..2be24b1 --- /dev/null +++ b/examples/cocos-creator-airplane/backend/src/shared/protocols/room/PtlJoinRoom.ts @@ -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 = { + +} \ No newline at end of file diff --git a/examples/cocos-creator-airplane/backend/src/shared/protocols/room/PtlSetReady.ts b/examples/cocos-creator-airplane/backend/src/shared/protocols/room/PtlSetReady.ts new file mode 100644 index 0000000..cdf80df --- /dev/null +++ b/examples/cocos-creator-airplane/backend/src/shared/protocols/room/PtlSetReady.ts @@ -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 = { + +} \ No newline at end of file diff --git a/examples/cocos-creator-airplane/backend/src/shared/protocols/room/server/MsgUpdateRoomState.ts b/examples/cocos-creator-airplane/backend/src/shared/protocols/room/server/MsgUpdateRoomState.ts new file mode 100644 index 0000000..ec6368e --- /dev/null +++ b/examples/cocos-creator-airplane/backend/src/shared/protocols/room/server/MsgUpdateRoomState.ts @@ -0,0 +1,7 @@ +import { RoomState } from "../../../../models/Room"; + +export interface MsgUpdateRoomState { + state: RoomState +} + +// export const conf = {} \ No newline at end of file diff --git a/examples/cocos-creator-airplane/backend/src/shared/protocols/serviceProto.ts b/examples/cocos-creator-airplane/backend/src/shared/protocols/serviceProto.ts index 854cea5..4110e3e 100644 --- a/examples/cocos-creator-airplane/backend/src/shared/protocols/serviceProto.ts +++ b/examples/cocos-creator-airplane/backend/src/shared/protocols/serviceProto.ts @@ -1,78 +1,1374 @@ -import { ServiceProto } from 'tsrpc-proto'; -import { MsgChat } from './MsgChat'; -import { ReqSend, ResSend } from './PtlSend'; - -// This is a demo service proto file (auto generated) -// Feel free to delete it - -export interface ServiceType { - api: { - "Send": { - req: ReqSend, - res: ResSend - } - }, - msg: { - "Chat": MsgChat - } -} - -export const serviceProto: ServiceProto = { - "services": [ - { - "id": 0, - "name": "Chat", - "type": "msg" - }, - { - "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" - } - } - ] - }, - "PtlSend/ReqSend": { - "type": "Interface", - "properties": [ - { - "id": 0, - "name": "content", - "type": { - "type": "String" - } - } - ] - }, - "PtlSend/ResSend": { - "type": "Interface", - "properties": [ - { - "id": 0, - "name": "time", - "type": { - "type": "Date" - } - } - ] - } - } +import { ServiceProto } from 'tsrpc-proto'; +import { MsgGameInput } from './game/client/MsgGameInput'; +import { MsgGameOver } from './game/client/MsgGameOver'; +import { MsgGameStart } from './game/server/MsgGameStart'; +import { MsgServerFrame } from './game/server/MsgServerFrame'; +import { ReqLogin, ResLogin } from './PtlLogin'; +import { ReqCreateRoom, ResCreateRoom } from './room/PtlCreateRoom'; +import { ReqGetRoomList, ResGetRoomList } from './room/PtlGetRoomList'; +import { ReqJoinRoom, ResJoinRoom } from './room/PtlJoinRoom'; +import { ReqSetReady, ResSetReady } from './room/PtlSetReady'; +import { MsgUpdateRoomState } from './room/server/MsgUpdateRoomState'; + +export interface ServiceType { + api: { + "Login": { + req: ReqLogin, + res: ResLogin + }, + "room/CreateRoom": { + req: ReqCreateRoom, + res: ResCreateRoom + }, + "room/GetRoomList": { + req: ReqGetRoomList, + res: ResGetRoomList + }, + "room/JoinRoom": { + req: ReqJoinRoom, + res: ResJoinRoom + }, + "room/SetReady": { + req: ReqSetReady, + res: ResSetReady + } + }, + msg: { + "game/client/GameInput": MsgGameInput, + "game/client/GameOver": MsgGameOver, + "game/server/GameStart": MsgGameStart, + "game/server/ServerFrame": MsgServerFrame, + "room/server/UpdateRoomState": MsgUpdateRoomState + } +} + +export const serviceProto: ServiceProto = { + "version": 3, + "services": [ + { + "id": 11, + "name": "game/client/GameInput", + "type": "msg" + }, + { + "id": 12, + "name": "game/client/GameOver", + "type": "msg" + }, + { + "id": 13, + "name": "game/server/GameStart", + "type": "msg" + }, + { + "id": 14, + "name": "game/server/ServerFrame", + "type": "msg" + }, + { + "id": 15, + "name": "Login", + "type": "api", + "conf": {} + }, + { + "id": 7, + "name": "room/CreateRoom", + "type": "api", + "conf": {} + }, + { + "id": 16, + "name": "room/GetRoomList", + "type": "api", + "conf": {} + }, + { + "id": 8, + "name": "room/JoinRoom", + "type": "api", + "conf": {} + }, + { + "id": 19, + "name": "room/SetReady", + "type": "api", + "conf": {} + }, + { + "id": 18, + "name": "room/server/UpdateRoomState", + "type": "msg" + } + ], + "types": { + "game/client/MsgGameInput/MsgGameInput": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "sn", + "type": { + "type": "Number" + } + }, + { + "id": 1, + "name": "inputs", + "type": { + "type": "Array", + "elementType": { + "type": "Reference", + "target": "game/client/MsgGameInput/ClientInput" + } + } + } + ] + }, + "game/client/MsgGameInput/ClientInput": { + "type": "Union", + "members": [ + { + "id": 0, + "type": { + "target": { + "type": "Reference", + "target": "../game/GameSystemInput/PlayerMove" + }, + "keys": [ + "playerId" + ], + "type": "Omit" + } + }, + { + "id": 1, + "type": { + "target": { + "type": "Reference", + "target": "../game/GameSystemInput/PlayerHitEnemy" + }, + "keys": [ + "playerId" + ], + "type": "Omit" + } + }, + { + "id": 2, + "type": { + "target": { + "type": "Reference", + "target": "../game/GameSystemInput/PlayerHitFightIcon" + }, + "keys": [ + "playerId" + ], + "type": "Omit" + } + }, + { + "id": 3, + "type": { + "target": { + "type": "Reference", + "target": "../game/GameSystemInput/PlayerHurt" + }, + "keys": [ + "playerId" + ], + "type": "Omit" + } + }, + { + "id": 4, + "type": { + "target": { + "type": "Reference", + "target": "../game/GameSystemInput/BulletHit" + }, + "keys": [ + "playerId" + ], + "type": "Omit" + } + } + ] + }, + "../game/GameSystemInput/PlayerMove": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "type", + "type": { + "type": "Literal", + "literal": "PlayerMove" + } + }, + { + "id": 1, + "name": "playerId", + "type": { + "type": "Number", + "scalarType": "uint" + } + }, + { + "id": 2, + "name": "offset", + "type": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "x", + "type": { + "type": "Number" + } + }, + { + "id": 1, + "name": "y", + "type": { + "type": "Number" + } + } + ] + } + }, + { + "id": 3, + "name": "createBullets", + "type": { + "type": "IndexedAccess", + "index": "bullets", + "objectType": { + "type": "Reference", + "target": "../game/GameSystemState/PlayerState" + } + } + } + ] + }, + "../game/GameSystemState/PlayerState": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "id", + "type": { + "type": "Number", + "scalarType": "uint" + } + }, + { + "id": 7, + "name": "nickname", + "type": { + "type": "String" + } + }, + { + "id": 1, + "name": "score", + "type": { + "type": "Number" + } + }, + { + "id": 2, + "name": "life", + "type": { + "type": "Number" + } + }, + { + "id": 3, + "name": "currentBulletType", + "type": { + "type": "Reference", + "target": "../game/GameSystemState/PlayerBulletType" + } + }, + { + "id": 4, + "name": "pos", + "type": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "x", + "type": { + "type": "Number" + } + }, + { + "id": 1, + "name": "y", + "type": { + "type": "Number" + } + } + ] + } + }, + { + "id": 5, + "name": "bullets", + "type": { + "type": "Array", + "elementType": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "id", + "type": { + "type": "Number", + "scalarType": "uint" + } + }, + { + "id": 1, + "name": "type", + "type": { + "type": "Reference", + "target": "../game/GameSystemState/PlayerBulletType" + } + }, + { + "id": 2, + "name": "pos", + "type": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "x", + "type": { + "type": "Number" + } + }, + { + "id": 1, + "name": "y", + "type": { + "type": "Number" + } + } + ] + } + }, + { + "id": 3, + "name": "init", + "type": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "time", + "type": { + "type": "Number" + } + }, + { + "id": 1, + "name": "pos", + "type": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "x", + "type": { + "type": "Number" + } + }, + { + "id": 1, + "name": "y", + "type": { + "type": "Number" + } + } + ] + } + }, + { + "id": 2, + "name": "direction", + "type": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "x", + "type": { + "type": "Number" + } + }, + { + "id": 1, + "name": "y", + "type": { + "type": "Number" + } + } + ] + } + } + ] + } + } + ] + } + } + }, + { + "id": 6, + "name": "nextId", + "type": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "bullet", + "type": { + "type": "Number", + "scalarType": "uint" + } + } + ] + } + } + ] + }, + "../game/GameSystemState/PlayerBulletType": { + "type": "Union", + "members": [ + { + "id": 0, + "type": { + "type": "Literal", + "literal": "M" + } + }, + { + "id": 1, + "type": { + "type": "Literal", + "literal": "H" + } + }, + { + "id": 2, + "type": { + "type": "Literal", + "literal": "S" + } + } + ] + }, + "../game/GameSystemInput/PlayerHitEnemy": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "type", + "type": { + "type": "Literal", + "literal": "PlayerHitEnemy" + } + }, + { + "id": 1, + "name": "playerId", + "type": { + "type": "Number", + "scalarType": "uint" + } + }, + { + "id": 2, + "name": "enemyId", + "type": { + "type": "Number", + "scalarType": "uint" + } + } + ] + }, + "../game/GameSystemInput/PlayerHitFightIcon": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "type", + "type": { + "type": "Literal", + "literal": "PlayerHitFightIcon" + } + }, + { + "id": 1, + "name": "playerId", + "type": { + "type": "Number", + "scalarType": "uint" + } + }, + { + "id": 2, + "name": "fightIconId", + "type": { + "type": "Number", + "scalarType": "uint" + } + } + ] + }, + "../game/GameSystemInput/PlayerHurt": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "type", + "type": { + "type": "Literal", + "literal": "PlayerHurt" + } + }, + { + "id": 1, + "name": "playerId", + "type": { + "type": "Number", + "scalarType": "uint" + } + }, + { + "id": 2, + "name": "hurtLife", + "type": { + "type": "Number" + } + } + ] + }, + "../game/GameSystemInput/BulletHit": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "type", + "type": { + "type": "Literal", + "literal": "BulletHit" + } + }, + { + "id": 1, + "name": "player", + "type": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "id", + "type": { + "type": "Number", + "scalarType": "uint" + } + }, + { + "id": 1, + "name": "bulletId", + "type": { + "type": "Number", + "scalarType": "uint" + } + } + ] + } + }, + { + "id": 2, + "name": "enemy", + "type": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "id", + "type": { + "type": "Number", + "scalarType": "uint" + } + }, + { + "id": 1, + "name": "bulletId", + "type": { + "type": "Number", + "scalarType": "uint" + } + } + ] + } + } + ] + }, + "game/client/MsgGameOver/MsgGameOver": { + "type": "Interface" + }, + "game/server/MsgGameStart/MsgGameStart": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "frameIndex", + "type": { + "type": "Number", + "scalarType": "uint" + } + }, + { + "id": 1, + "name": "gameState", + "type": { + "type": "Reference", + "target": "../game/GameSystemState/GameSystemState" + } + } + ] + }, + "../game/GameSystemState/GameSystemState": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "now", + "type": { + "type": "Number" + } + }, + { + "id": 1, + "name": "players", + "type": { + "type": "Array", + "elementType": { + "type": "Reference", + "target": "../game/GameSystemState/PlayerState" + } + } + }, + { + "id": 2, + "name": "enemies", + "type": { + "type": "Array", + "elementType": { + "type": "Reference", + "target": "../game/GameSystemState/EnemyState" + } + } + }, + { + "id": 3, + "name": "fightIcons", + "type": { + "type": "Array", + "elementType": { + "type": "Reference", + "target": "../game/GameSystemState/FightIconState" + } + } + }, + { + "id": 4, + "name": "nextId", + "type": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "enemy", + "type": { + "type": "Number", + "scalarType": "uint" + } + }, + { + "id": 1, + "name": "fightIcon", + "type": { + "type": "Number", + "scalarType": "uint" + } + } + ] + } + }, + { + "id": 5, + "name": "lastCreateEnemyTime", + "type": { + "type": "Number" + } + }, + { + "id": 6, + "name": "random", + "type": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "seed", + "type": { + "type": "String" + } + }, + { + "id": 1, + "name": "state", + "type": { + "type": "Object" + } + } + ] + } + } + ] + }, + "../game/GameSystemState/EnemyState": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "id", + "type": { + "type": "Number", + "scalarType": "uint" + } + }, + { + "id": 1, + "name": "type", + "type": { + "type": "Reference", + "target": "../game/GameSystemState/EnemyType" + } + }, + { + "id": 2, + "name": "pos", + "type": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "x", + "type": { + "type": "Number" + } + }, + { + "id": 1, + "name": "y", + "type": { + "type": "Number" + } + } + ] + } + }, + { + "id": 3, + "name": "init", + "type": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "time", + "type": { + "type": "Number" + } + }, + { + "id": 1, + "name": "pos", + "type": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "x", + "type": { + "type": "Number" + } + }, + { + "id": 1, + "name": "y", + "type": { + "type": "Number" + } + } + ] + } + } + ] + } + }, + { + "id": 4, + "name": "bullets", + "type": { + "type": "Array", + "elementType": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "id", + "type": { + "type": "Number", + "scalarType": "uint" + } + }, + { + "id": 1, + "name": "pos", + "type": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "x", + "type": { + "type": "Number" + } + }, + { + "id": 1, + "name": "y", + "type": { + "type": "Number" + } + } + ] + } + }, + { + "id": 2, + "name": "init", + "type": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "time", + "type": { + "type": "Number" + } + }, + { + "id": 1, + "name": "pos", + "type": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "x", + "type": { + "type": "Number" + } + }, + { + "id": 1, + "name": "y", + "type": { + "type": "Number" + } + } + ] + } + } + ] + } + } + ] + } + } + }, + { + "id": 5, + "name": "lastBulletTime", + "type": { + "type": "Number" + } + }, + { + "id": 6, + "name": "nextId", + "type": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "bullet", + "type": { + "type": "Number", + "scalarType": "uint" + } + } + ] + } + } + ] + }, + "../game/GameSystemState/EnemyType": { + "type": "Enum", + "members": [ + { + "id": 0, + "value": 0 + }, + { + "id": 1, + "value": 1 + } + ] + }, + "../game/GameSystemState/FightIconState": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "id", + "type": { + "type": "Number", + "scalarType": "uint" + } + }, + { + "id": 1, + "name": "type", + "type": { + "type": "Reference", + "target": "../game/GameSystemState/PlayerBulletType" + } + }, + { + "id": 2, + "name": "init", + "type": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "time", + "type": { + "type": "Number" + } + }, + { + "id": 1, + "name": "pos", + "type": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "x", + "type": { + "type": "Number" + } + }, + { + "id": 1, + "name": "y", + "type": { + "type": "Number" + } + } + ] + } + } + ] + } + } + ] + }, + "game/server/MsgServerFrame/MsgServerFrame": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "frameIndex", + "type": { + "type": "Number", + "scalarType": "uint" + } + }, + { + "id": 1, + "name": "inputs", + "type": { + "type": "Array", + "elementType": { + "type": "Reference", + "target": "../game/GameSystemInput/GameSystemInput" + } + } + }, + { + "id": 2, + "name": "lastSn", + "type": { + "type": "Number" + }, + "optional": true + } + ] + }, + "../game/GameSystemInput/GameSystemInput": { + "type": "Union", + "members": [ + { + "id": 0, + "type": { + "type": "Reference", + "target": "../game/GameSystemInput/PlayerMove" + } + }, + { + "id": 1, + "type": { + "type": "Reference", + "target": "../game/GameSystemInput/PlayerHitEnemy" + } + }, + { + "id": 2, + "type": { + "type": "Reference", + "target": "../game/GameSystemInput/PlayerHitFightIcon" + } + }, + { + "id": 3, + "type": { + "type": "Reference", + "target": "../game/GameSystemInput/PlayerHurt" + } + }, + { + "id": 4, + "type": { + "type": "Reference", + "target": "../game/GameSystemInput/BulletHit" + } + }, + { + "id": 5, + "type": { + "type": "Reference", + "target": "../game/GameSystemInput/TimePast" + } + } + ] + }, + "../game/GameSystemInput/TimePast": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "type", + "type": { + "type": "Literal", + "literal": "TimePast" + } + }, + { + "id": 1, + "name": "dt", + "type": { + "type": "Number" + } + } + ] + }, + "PtlLogin/ReqLogin": { + "type": "Interface", + "extends": [ + { + "id": 0, + "type": { + "type": "Reference", + "target": "base/BaseRequest" + } + } + ], + "properties": [ + { + "id": 0, + "name": "nickname", + "type": { + "type": "String" + } + } + ] + }, + "base/BaseRequest": { + "type": "Interface" + }, + "PtlLogin/ResLogin": { + "type": "Interface", + "extends": [ + { + "id": 0, + "type": { + "type": "Reference", + "target": "base/BaseResponse" + } + } + ], + "properties": [ + { + "id": 0, + "name": "playerId", + "type": { + "type": "Number", + "scalarType": "uint" + } + } + ] + }, + "base/BaseResponse": { + "type": "Interface" + }, + "room/PtlCreateRoom/ReqCreateRoom": { + "type": "Interface", + "extends": [ + { + "id": 0, + "type": { + "type": "Reference", + "target": "base/BaseRequest" + } + } + ] + }, + "room/PtlCreateRoom/ResCreateRoom": { + "type": "Interface", + "extends": [ + { + "id": 0, + "type": { + "type": "Reference", + "target": "base/BaseResponse" + } + } + ], + "properties": [ + { + "id": 0, + "name": "roomId", + "type": { + "type": "Number", + "scalarType": "uint" + } + } + ] + }, + "room/PtlGetRoomList/ReqGetRoomList": { + "type": "Interface", + "extends": [ + { + "id": 0, + "type": { + "type": "Reference", + "target": "base/BaseRequest" + } + } + ] + }, + "room/PtlGetRoomList/ResGetRoomList": { + "type": "Interface", + "extends": [ + { + "id": 0, + "type": { + "type": "Reference", + "target": "base/BaseResponse" + } + } + ] + }, + "room/PtlJoinRoom/ReqJoinRoom": { + "type": "Interface", + "extends": [ + { + "id": 0, + "type": { + "type": "Reference", + "target": "base/BaseRequest" + } + } + ], + "properties": [ + { + "id": 0, + "name": "roomId", + "type": { + "type": "Number", + "scalarType": "uint" + } + } + ] + }, + "room/PtlJoinRoom/ResJoinRoom": { + "type": "Interface", + "extends": [ + { + "id": 0, + "type": { + "type": "Reference", + "target": "base/BaseResponse" + } + } + ] + }, + "room/PtlSetReady/ReqSetReady": { + "type": "Interface", + "extends": [ + { + "id": 0, + "type": { + "type": "Reference", + "target": "base/BaseRequest" + } + } + ], + "properties": [ + { + "id": 0, + "name": "isReady", + "type": { + "type": "Boolean" + } + } + ] + }, + "room/PtlSetReady/ResSetReady": { + "type": "Interface", + "extends": [ + { + "id": 0, + "type": { + "type": "Reference", + "target": "base/BaseResponse" + } + } + ] + }, + "room/server/MsgUpdateRoomState/MsgUpdateRoomState": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "state", + "type": { + "type": "Reference", + "target": "../../models/Room/RoomState" + } + } + ] + }, + "../../models/Room/RoomState": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "id", + "type": { + "type": "Number", + "scalarType": "uint" + } + }, + { + "id": 3, + "name": "players", + "type": { + "type": "Array", + "elementType": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "id", + "type": { + "type": "Number", + "scalarType": "uint" + } + }, + { + "id": 1, + "name": "nickname", + "type": { + "type": "String" + } + }, + { + "id": 2, + "name": "isReady", + "type": { + "type": "Boolean" + } + } + ] + } + } + }, + { + "id": 2, + "name": "status", + "type": { + "type": "Union", + "members": [ + { + "id": 0, + "type": { + "type": "Literal", + "literal": "wait" + } + }, + { + "id": 1, + "type": { + "type": "Literal", + "literal": "ready" + } + }, + { + "id": 2, + "type": { + "type": "Literal", + "literal": "start" + } + } + ] + } + } + ] + } + } }; \ No newline at end of file diff --git a/examples/cocos-creator-airplane/backend/src/shared/types/CurrentUser.ts b/examples/cocos-creator-airplane/backend/src/shared/types/CurrentUser.ts new file mode 100644 index 0000000..77a977f --- /dev/null +++ b/examples/cocos-creator-airplane/backend/src/shared/types/CurrentUser.ts @@ -0,0 +1,6 @@ +import { uint } from "tsrpc"; + +export interface CurrentUser { + id: uint, + nickname: string +} \ No newline at end of file diff --git a/examples/cocos-creator-airplane/frontend/assets/script/StateManager.ts b/examples/cocos-creator-airplane/frontend/assets/script/StateManager.ts new file mode 100644 index 0000000..881918e --- /dev/null +++ b/examples/cocos-creator-airplane/frontend/assets/script/StateManager.ts @@ -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; + + 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 { + 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(); + } + +} \ No newline at end of file diff --git a/examples/cocos-creator-airplane/frontend/package.json b/examples/cocos-creator-airplane/frontend/package.json index 1a35885..bc4a62b 100755 --- a/examples/cocos-creator-airplane/frontend/package.json +++ b/examples/cocos-creator-airplane/frontend/package.json @@ -5,5 +5,8 @@ "name": "airplane", "type": "3d", "uuid": "c794458c-05f6-4c9f-909b-20d54897d219", - "version": "3.4.0" + "version": "3.4.0", + "dependencies": { + "tsrpc-browser": "^3.1.4" + } }