1 В избранное 0 Ответвления 0

OSCHINA-MIRROR/fengssy-ts-gameframework

Клонировать/Скачать
帧同步联机游戏实战(一)Demo起步.md 30 КБ
Копировать Редактировать Web IDE Исходные данные Просмотреть построчно История
gitlife-traslator Отправлено 30.11.2024 08:14 f4652ea

Преимущества онлайн-игр не буду перечислять, основные недостатки связаны с реализацией и качеством. Что касается онлайн-игр, мне больше нравятся онлайн-игры в реальном времени (IO), которые отличаются коротким, простым и быстрым игровым процессом или пошаговым соревновательным форматом. Игры в реальном времени лучше соответствуют сценариям использования мобильных устройств.

Простые реализации для многопользовательской игры и удобство преобразования однопользовательских игр в многопользовательские — вот главные причины, по которым я выбрал синхронизацию кадров в качестве решения для многопользовательского режима. Также необходимо чётко понимать недостатки синхронизации кадров:

  1. Она подходит для сценариев с комнатами, но не для крупномасштабных многопользовательских онлайн-игр. Если вы не планируете создавать крупномасштабные многопользовательские онлайн-игры, этот недостаток можно игнорировать.
  2. Требуется наличие достаточного количества решений для обработки асинхронных ситуаций. Это включает предотвращение, обработку после возникновения и последующее расследование.

Далее я представлю серию курсов, в которых поделюсь своими идеями о синхронизации кадров.

В этой главе мы быстро создадим демонстрацию синхронизации кадров, чтобы новички могли легко войти в курс дела и получить успешный пример!

1. Онлайн-сервисы

Открытый игровой онлайн-движок для соревнований 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.

2. Запускаем «самый простой демо-проект»!

После развёртывания сервера откройте бесплатный образец проекта: tsgf-sdk-demos-baseMatch (https://store.cocos.com/app/detail/3877). Просто запустите его для предварительного просмотра! Demo предварительного просмотра

3. Анализ демо

Схема процесса tsgf-демо

  • Комментарии к процессу

    • Получение информации об авторизации игрока tsgf: для обеспечения безопасности связи необходимо использовать информацию об авторизации игрока (идентификатор игрока и токен), предоставленную tsgf, для подключения к серверу tsgf. Вызывая интерфейс службы сервера tsgf, передавая информацию пользователя (идентификатор пользователя + псевдоним и т. д.), вы получаете информацию об авторизации игрока. Информация о пользователе определяется разработчиком. В демонстрационном проекте используется случайная строка в качестве идентификатора пользователя, что позволяет каждому входу быть новым пользователем. Официальный проект должен быть реализован в собственной системе пользователей разработчика. Например, текущая игра — это мини-игра WeChat: пользователь входит в систему через WeChat, получает открытый идентификатор WeChat и информацию о псевдониме, аватаре и т.д., а затем сопоставляет или регистрирует идентификатор пользователя в системе собственных пользователей, использует этот идентификатор пользователя для вызова интерфейса авторизации игрока tsgf и обменивает информацию об авторизации игрока, а затем передаёт её клиенту для подключения к tsgf сервер (передаётся при инициализации tsgf-sdk). Для получения дополнительной информации см. Временная диаграмма использования игрока tsgf.
    • Инициализация sdk: требуется адрес большого зала сервера, информация об авторизации игрока и все API должны быть вызваны перед использованием.
    • Вход в комнату: независимо от того, создаёте ли вы комнату самостоятельно или присоединяетесь к ней, или сопоставляете комнату, сначала получите информацию о комнате, а затем вызовите api подключения комнаты tsgf-sdk. Информацию о структуре сервера tsgf см. в Структурная схема сервера tsgf
    • Отправка входного кадра: любая операция над логикой игры должна отправлять входной кадр, а не изменять логику напрямую. Конкретные операции, определённые как входные кадры, являются наиболее грубым способом определения всех действий, вызванных клавиатурой или мышью, как входных кадров.
    • Приём и выполнение синхронного кадра: синхронный кадр, отправленный сервером tsgf вниз, является основным драйвером логики игры. Синхронный кадр — это самая важная концепция синхронизации кадров. Все изменения начинаются с отправки входного кадра, и вся логика управляется синхронным кадром!
    • Логические данные игры: обычно разделение данных и логики игры является основной задачей разработки многопользовательских игр.
    • Цикл рендеринга: слой рендеринга относится к движку рендеринга игры, такому как cocos, который отделяет слой рендеринга от логического слоя, что может значительно уменьшить проблемы синхронизации, возникающие позже. Он также приносит много преимуществ (изменение реализации слоя рендеринга, реализация робота на сервере, проверка логики на сервере и т.д.).
  • Несколько ключевых файлов:

    • 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. Добавить интерактивность

После того как я изучу что-то новое, я обычно пытаюсь добавить что-нибудь, чтобы убедиться, что я действительно понял это. В настоящее время самый простой демо-вариант реализует только то, что несколько человек бегают по земле. Для полноценной игры необходимо хотя бы немного интерактивности, поэтому я хочу добавить элемент «атаки». Представьте себе, что персонаж атакует, и любой персонаж в пределах диапазона атаки считается поражённым, а эффект триггера устанавливается как толчок на определённое расстояние в направлении атаки.

Хорошо, идея есть, начинаем разрабатывать план реализации.

Привычно сначала проектируем данные о состоянии, затем переходим к логической реализации и, наконец, рассматриваем рендеринг.

1. Сначала проектируем данные состояния

Для атаки и защиты необходимо иметь состояние для записи, а также записывать информацию, связанную с атакой и защитой, чтобы каждый логический кадр мог выполнять вычисления:

/**为了方便之后状态序列化, 统一采用接口对象而不是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;
}

2. Затем переходим к логике

Можно начать с ввода и добавить кнопку, которая затем отправляет новый входной кадр: атака

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

}

/**

  • Проверка, находится ли точка в многоугольнике, образованном несколькими точками (метод луча, то есть вычисление пересечения луча с каждой стороной многоугольника, если количество пересечений нечётно, точка находится вне многоугольника, иначе внутри) */ testInArea(test: IVec2Like, _pointList: IVec2Like[]): boolean { let result = false; for (let i = 0, j = _pointList.length - 1; i < _pointList.length; j = i++) { if ((_pointList[i].y > test.y) != (_pointList[j].y > test.y) && (test.x < (_pointList[j].x - _pointList[i].x) * (test.y - _pointList[i].y) / (_pointList[j].y - _pointList[i].y) + _pointList[i].x)) { result = !result; } } return result; }

Опубликовать ( 0 )

Вы можете оставить комментарий после Вход в систему

1
https://api.gitlife.ru/oschina-mirror/fengssy-ts-gameframework.git
git@api.gitlife.ru:oschina-mirror/fengssy-ts-gameframework.git
oschina-mirror
fengssy-ts-gameframework
fengssy-ts-gameframework
master