Преимущества онлайн-игр не буду перечислять, основные недостатки связаны с реализацией и качеством. Что касается онлайн-игр, мне больше нравятся онлайн-игры в реальном времени (IO), которые отличаются коротким, простым и быстрым игровым процессом или пошаговым соревновательным форматом. Игры в реальном времени лучше соответствуют сценариям использования мобильных устройств.
Простые реализации для многопользовательской игры и удобство преобразования однопользовательских игр в многопользовательские — вот главные причины, по которым я выбрал синхронизацию кадров в качестве решения для многопользовательского режима. Также необходимо чётко понимать недостатки синхронизации кадров:
Далее я представлю серию курсов, в которых поделюсь своими идеями о синхронизации кадров.
В этой главе мы быстро создадим демонстрацию синхронизации кадров, чтобы новички могли легко войти в курс дела и получить успешный пример!
Открытый игровой онлайн-движок для соревнований tsgf (https://gitee.com/fengssy/ts-gameframework)
Развёртывание очень простое. Рекомендуется использовать бесплатный плагин CocosCreator для tsgf-серверов-dev: tsgf-servers-dev (https://store.cocos.com/app/detail/3910).
Вы также можете самостоятельно загрузить исходный код и запустить сервис, используя команды npm i
и npm run dev
.
После развёртывания сервера откройте бесплатный образец проекта: tsgf-sdk-demos-baseMatch (https://store.cocos.com/app/detail/3877).
Просто запустите его для предварительного просмотра!
Комментарии к процессу
Несколько ключевых файлов:
assets\scripts\demo1\Demo1SceneManager.ts
: основная реализация логики.preview-template\index.ejs
: конфигурация сервера во время предварительного просмотра.build-templates\web-mobile\index.ejs
: настройка сервера при публикации H5.build-templates\wechatgame\game.ejs
: сборка мини-игры WeChat для настройки сервера.assets\scripts\env.ts
: сборка для разных платформ для взаимодействия с различными поставщиками SDK. Обратите внимание, что здесь используется платный плагин ifdef-creator (3 юаня), который может автоматически выбирать соответствующий код во время сборки, избегая включения пакетов разных платформ, увеличивая ненужный размер пакета. Если вам это не нужно, вы можете изменить используемый пакет npm в коде при каждой сборке.Основные комментарии логики (assets\scripts\demo1\Demo1SceneManager.ts
), рекомендуется сравнить их со схемой процесса!
onLoad() {
initSdkEnv();// Инициализировать среду выполнения SDK
// Инициализируем клиент demo
// Поскольку у нашего игрока есть собственная система пользователей (например, из собственного общедоступного номера WeChat), нам необходимо сопоставить пользователя с игроком tsgf. Если вы не знакомы с ним, вы можете напрямую использовать упакованный клиент tsgf-dev-demo, чтобы попробовать его. Здесь передается собственный адрес веб-сервера, или вы можете использовать встроенный демонстрационный сервер tsgf
this.demoClient = new DemoClient(typeof (demoServerUrl) === "undefined" ? "http://127.0.0.1:7901/" : demoServerUrl);
}
async start() {
//...
//Room.ins.events.on.. // Различные события регистрации tsgf, такие как вход игрока в комнату/выход из комнаты и т.д. В основном используется для взаимодействия с пользовательским интерфейсом. Обратите внимание, что логика игры не может быть запущена отсюда, и модификация логики игры может быть инициирована только синхронным кадром!
//...
// Исполнитель фрейма синхронизации, настройте Demo1SceneManager как исполнитель фрейма синхронизации ввода, то есть требуется реализовать несколько методов:
// execInputOperates_<тип ввода> Реализовать логику каждого входного кадра
// execInputOthers Другие входные кадры (встроенные), такие как вход игроков в игру и т.д.
// Зарегистрировать событие логического кадра executeOneFrame, которое будет запускаться каждый кадр для обновления данных логического мира
this.frameSyncExecutor = new FrameSyncExecutor(
new SDKFrameSyncConnectAdp(),
'inputType', // Укажите поле, представляющее "тип ввода" в объекте ввода
this,
(dt, frameIndex) => this.executeOneFrame(dt, frameIndex),
() => this.allPlayers);
//...
}
//...
// Войти
async onLoginClick() {
//...
// Здесь мы сопоставляем нашего пользователя с игроком tsgf (то есть получаем информацию об авторизации игрока tsgf)
let result = await this.demoClient.playerAuth(tmpOpenId, this.playerInfoPara.showName);
//...
// Инициализируйте tsgf-sdk, передайте адрес игрового зала и информацию об авторизации игрока
var hallSvUrl = typeof (hallServerUrl) === "undefined" || hallServerUrl;
Game.ins.init(hallSvUrl, result.data.playerId, result.data.playerToken);
//...
}
``` **//...
// 匹配房间**
protected async startPlayersMatchBaseMelee(playerIds: string[], minPlayers: number, maxPlayers: number): Promise<IResult<IMatchResult>> {
return await new Promise(async (resolve) => {
let hasResult = false;
let reqMatchRet = await Room.ins.requestMatchFromPlayers({ // 玩家匹配的基础方法, 可以定义更复杂的匹配逻辑
matchFromType: EMatchFromType.Player, // 玩家匹配类型
matchFromInfo: {
playerIds: playerIds, // 要匹配的玩家id, 通常只要传[当前玩家id]即可
},
maxPlayers: maxPlayers, // 房间最大玩家数,不同的最大玩家数不会相互匹配到
matcherKey: MatcherKeys.Single, // 单人玩家匹配模式(即无组队)matcherParams 类型为 ISingleMatcherParams
matcherParams: {
minPlayers: minPlayers, // 最少要匹配到几个玩家才创建房间
resultsContinueRoomJoinUsMatch: true, // 匹配出房间后(满足最少玩家数),是否可以继续匹配进人
} as ISingleMatcherParams, // 这里用 `as` 接口的写法,可以让字段获得vscode中的注释提示
}, (result) => {
if (!hasResult) {
hasResult = true;
return resolve(result);// 匹配成功后, 会拿到匹配的房间(房间和服务器相关信息), 需要调用 Room.ins.joinRoomByServer 加入房间
}
});
if (!reqMatchRet.succ) {
if (!hasResult) {
hasResult = true;
return resolve(Result.buildErr(reqMatchRet.err, reqMatchRet.code));
}
}
});
}
**// 发送输入帧, 注意,所有逻辑修改入口必须来自收到的同步帧, 而本地想要修改, 就必须发送输入帧!**
onJoysickMoveStart(move: IMoveDirection) {
Room.ins.sendFrame([{
inputType: InputType.MoveDirStart,
signRadFromX: move.signRadFromX,
}]);
}
onJoysickMoveEnd() {
Room.ins.sendFrame([{
inputType: InputType.MoveDirEnd,
}]);
}
**//==== 逻辑帧的实现 ====**
onNewPlayer(playerId: string, playerInfo: IPlayerInfo, dt: number): void {
// 新玩家, 创建玩家数据(PlayerData), 以及玩家渲染的cocos节点数据
//...
}
onRemovePlayer(playerId: string, dt: number): void {
// 移除玩家节点以及相关数据
//...
}
**//特殊帧, 由tsgf定义和下发**
execInputOthers(playerId: string, inputFrame: IFramePlayerInput, dt: number, FrameIndex: number) {
switch (inputFrame.inputFrameType) {
case EPlayerInputFrameType.PlayerEnterGame:
//开始游戏时,房间中的玩家都触发一次
this.onNewPlayer(playerId, inputFrame.playerInfo, dt);
break;
case EPlayerInputFrameType.JoinRoom:
//游戏开始后再加入的玩家
this.onNewPlayer(playerId, inputFrame.playerInfo, dt);
break;
case EPlayerInputFrameType.LeaveRoom:
//游戏开始后再离开的玩家
this.onRemovePlayer(playerId, dt);
break;
}
}
**// 所有的同步帧, 推荐都只修改数据层, 而不动渲染层,有需要推荐通过事件方式通知, 是最佳做法**
// 逻辑中不能有"我"的概念, 因为可能本帧是其他玩家的输入帧!
execInputOperates_MoveDirStart(playerId: string, inputFrame: IPlayerInputOperate, dt: number): void {
// 移动开始, 修改玩家数据的朝向和移动状态(PlayerData), 注意, 这里不修改渲染数据(Cocos节点数据)
//...
}
execInputOperates_MoveDirEnd(playerId: string, inputFrame: IPlayerInputOperate, dt: number): void {
// 移动结束, 修改玩家数据的朝向和移动状态(PlayerData), 注意, 这里不修改渲染数据(Cocos节点数据)
//...
}
executeOneFrame(dt: number, frameIndex: number): void {
// 因为本demo比较简单, 只有移动需要每帧计算位置, 所以简单一个循环, 完善的项目应该配套一个状态管理系统,根据每个对象的状态去计算下个状态应该的数据修改
this.frameIndex = frameIndex;
for (var playerId in this.allPlayers) {
var p = this.allPlayers[playerId];
this.playerUpdate(p, dt);
}
// 在玩家的渲染组件(PlayerComponent) 会在渲染循环中(update)去刷新渲染数据(比如模型节点的position), 逻辑帧中不推荐直接修改渲染数据, 但稍微复杂一点的游戏逻辑, 都会有和逻辑有关的渲染动作, 比如角色移动到某个位置发了一个技能, 渲染层分离后, 可以这么做:
// 逻辑层更新位置, 发送技能动作照样修改逻辑数据, 多触发一个"发动某技能"的事件, 渲染层采用过渡方式更新渲染, 收到这个事件后, 可以简单的判断位置瞬移过来, 也可以将渲染动作设计为一个渲染队列, 移动 / 旋转 / 发技能, 都加入队列, 一样采用过渡方式渲染, 过渡时间为20ms, 这样既可以最短的方式准确响应预期动作, 也可以得到最顺畅的渲染效果
}
Для получения дополнительной информации о ts-gameframework (tsgf) рекомендуется посетить «tsgf 3 — проектная документация». 4. Добавить интерактивность
После того как я изучу что-то новое, я обычно пытаюсь добавить что-нибудь, чтобы убедиться, что я действительно понял это. В настоящее время самый простой демо-вариант реализует только то, что несколько человек бегают по земле. Для полноценной игры необходимо хотя бы немного интерактивности, поэтому я хочу добавить элемент «атаки». Представьте себе, что персонаж атакует, и любой персонаж в пределах диапазона атаки считается поражённым, а эффект триггера устанавливается как толчок на определённое расстояние в направлении атаки.
Хорошо, идея есть, начинаем разрабатывать план реализации.
Привычно сначала проектируем данные о состоянии, затем переходим к логической реализации и, наконец, рассматриваем рендеринг.
Для атаки и защиты необходимо иметь состояние для записи, а также записывать информацию, связанную с атакой и защитой, чтобы каждый логический кадр мог выполнять вычисления:
/**为了方便之后状态序列化, 统一采用接口对象而不是class实例*/
/**玩家状态数据*/
export interface PlayerData {
playerId: string;
showName: string;
/**位置, 所有距离单位皆为地图单位, 换算成引擎单位需要除以30, 从主相机视角看,这个坐标系为 x:→ y:↓*/
pos: IVec2Like;
/**当前是否在移动中*/
inMoving: boolean;
/**归一化的朝向向量*/
dir: IVec2Like;
/**朝向在水平面的弧度 (从X轴转到目标方向所需的旋转弧度)*/
dirRadFromX: number;
/**每秒移动的距离*/
speed: number;
/**当前是否在攻击中*/
inAttacking: boolean;
/**攻击是否已经生效过*/
attacked: boolean;
/**攻击动作已经经过的时间(单位秒)*/
attackUseTime: number;
/**攻击动作前摇时长(单位秒), 即攻击动作执行过这个时间才产生攻击计算*/
attackPrevTime: number;
/**攻击动作持续多久(单位秒), 即攻击整体僵持住时间*/
attackAllTime: number;
/**当前是否被击中*/
beAttacked: boolean;
/**被击中动作已经经过的时间(单位秒)*/
beAttackedUseTime: number;
/**被击中后推飞的方向(已归一化)*/
beAttackedDriveDir: IVec2Like;
/**被击中后已经推飞的距离*/
beAttackedDrivePassDistance: number;
/**被击中后要推飞的距离*/
beAttackedDriveAllDistance: number;
/**被击中后推飞的速度(每秒移动距离)*/
beAttackedDriveSpeed: number;
}
onAttackClick() {
Room.ins.sendFrame([{
inputType: InputType.Attack,
}]);
}
execInputOperates_Attack(playerId: string, inputFrame: IPlayerInputOperate, dt: number): void {
const player = this.allPlayers[playerId];
if (!player) return;
// 玩家被攻击中或被击中, 都无法继续发起攻击
if (player.inAttacking || player.beAttacked) return;
// 状态改为攻击中
player.inAttacking = true;
player.attacked = false;
player.inMoving = false;
player.attackUseTime = 0;
}
// 每个逻辑帧的计算
executeOneFrame(dt: number, frameIndex: number): void {
this.frameIndex = frameIndex;
for (var playerId in this.allPlayers) {
var p = this.allPlayers[playerId];
this.playerUpdate(p, dt);
}
}
playerUpdate(player: PlayerData, dt: number) {
if (player.inMoving) {
//有移动
let distance = player.speed * dt;//本帧移动的距离
//根据方向,算出本帧移动向量
Vec2.multiplyScalar(this.tmpV2, player.dir, distance);
//加到老坐标
Vec2.add(player.pos, player.pos, this.tmpV2);
} else if (player.beAttacked) {
// 被击中
player.beAttackedUseTime += dt;
let distance = player.beAttackedDriveSpeed * dt;//本帧移动的距离
//согласно направлению, вычислить вектор перемещения этого кадра
Vec2.multiplyScalar(this.tmpV2, player.beAttackedDriveDir, distance);
//добавить к старому положению
Vec2.add(player.pos, player.pos, this.tmpV2);
player.beAttackedDrivePassDistance += distance;
if (player.beAttackedDrivePassDistance > player.beAttackedDriveAllDistance) {
// 推动的距离满足了, 则停下
player.beAttacked = false;
}
} else if (player.inAttacking) {
// 攻击中
player.attackUseTime += dt;
if (player.attackUseTime >= player.attackPrevTime && !player.attacked) {
// 实现攻击前摇时间到了, 才进行攻击检测
player.attacked = true;
this.playerAttack(player);
}
if (player.attackUseTime >= player.attackAllTime) {
// 攻击动作完成
player.inAttacking = false;
}
}
}
playerAttack(player: PlayerData) {
// 攻击的范围是当前位置往当前朝向一段长度的矩形区域
let attackWidth = 120, attackWidthF2 = attackWidth / 2;
let attackDistance = 250;
// 先计算出朝向攻击距离终点
let target = { x: attackDistance, y: 0 }
this.rotate(target, player.dirRadFromX);
Vec2.add(target, player.pos, target);
//假设从下往上攻击来定义攻击范围矩形的四个点
//
*Примечание: в тексте запроса присутствуют фрагменты кода на языке TypeScript. Я не могу выполнить их компиляцию, но могу предоставить перевод текста.* **Атака**
// Определение диапазона атаки для первой точки, которая находится слева на 90 градусов
let posLeft: IVec2Like = { x: attackWidthF2, y: 0 };
this.rotate(posLeft, player.dirRadFromX + 90 * macro.RAD);
Vec2.add(posLeft, player.pos, posLeft);
// Определение диапазона атаки для второй точки, которая находится справа на 90 градусов
let posRight: IVec2Like = { x: attackWidthF2, y: 0 };
this.rotate(posRight, player.dirRadFromX - 90 * macro.RAD);
Vec2.add(posRight, player.pos, posRight);
// Определение диапазона атаки для третьей точки, которая является правой стороной цели на 90 градусов
let targetRight: IVec2Like = { x: attackWidthF2, y: 0 };
this.rotate(targetRight, player.dirRadFromX - 90 * macro.RAD);
Vec2.add(targetRight, target, targetRight);
// Определение диапазона атаки для четвёртой точки, которая является левой стороной цели на 90 градусов
let targetLeft: IVec2Like = { x: attackWidthF2, y: 0 };
this.rotate(targetLeft, player.dirRadFromX + 90 * macro.RAD);
Vec2.add(targetLeft, target, targetLeft);
let checkPointList = [posLeft, posRight, targetRight, targetLeft];
// Проверка всех игроков-ролей, находятся ли они в диапазоне атаки
for (var playerId in this.allPlayers) {
var p = this.allPlayers[playerId];
if (p.playerId === player.playerId) continue;
if (this.testInArea(p.pos, checkPointList)) {
// Попадание, независимо от состояния, отменить и установить состояние попадания
p.inAttacking = false;
p.inMoving = false;
p.beAttacked = true;
p.beAttackedUseTime = 0;
p.beAttackedDrivePassDistance = 0;
// Направление отталкивания — это направление атаки
Vec2.set(p.beAttackedDriveDir, player.dir.x, player.dir.y);
}
}
}
/**
Вы можете оставить комментарий после Вход в систему
Неприемлемый контент может быть отображен здесь и не будет показан на странице. Вы можете проверить и изменить его с помощью соответствующей функции редактирования.
Если вы подтверждаете, что содержание не содержит непристойной лексики/перенаправления на рекламу/насилия/вульгарной порнографии/нарушений/пиратства/ложного/незначительного или незаконного контента, связанного с национальными законами и предписаниями, вы можете нажать «Отправить» для подачи апелляции, и мы обработаем ее как можно скорее.
Опубликовать ( 0 )