WebSocket 技术实现 FiveM 游戏外挂仪表

N 年没更新的我今天来更新了(什么?著名鸽子王更新文章了?)在鸽了 3 个月后我又回来了 🤔

在阅读这篇文章之前,如果你不知道什么是 WebSocket 的话,可以看看我的这篇文章:https://lo-li.cn/444 (演示已经挂了,看文章内容就好了)

众所周知我有一个 G27 方向盘(众人:谁知道啊),我之前还在 Bilibili 发视频演示过,因为没钱买外挂仪表,那玩意淘宝卖 900 多,于是我就打算自己造一个功能差不多的,同时又不需要多花一分钱的自制仪表。

这里我就发一个和本文相关的视频吧,配合视频阅读本文效果更佳:https://www.bilibili.com/video/av58089964/

首先我想到的是用真车仪表来改,不过马上这个想法就被 Pass 掉了,因为真车仪表太大,没地方放,我桌子不大;然后就是我家里也没有拆下来的汽车仪表,去外面收个二手的也要几十块钱吧,而我的目的是不想花钱又能搞到一个仪表盘。

然后我看到了窗台上放了半年多的华为畅想 8,马上就 jio 得有点搞头,然后就想到了用手机当仪表盘,大小刚好合适。那手机用什么做仪表应用呢?我不擅长 Android 开发,但是做个网页还是可以的,前端虽然水的一比但是能凑合。

OK,那既然是网页,用什么和游戏实时同步数据呢?Ajax?不,那玩意效率低的吓人,轮询起来服务器得炸,那还有什么技术呢?不用说,裆燃是 WebSocket 啦。说到 WebSocket,我就想到服务端,说到服务端,我就想到连接,说到连接我就想到万里传输数据,说到万里我就想到西天取经,说到西天取经我就想到西游记,今年下半年……开花……

不扯了,本次选用的技术是 PHP Swoole,Swoole 是一个优秀的多线程 PHP 扩展,使用 Swoole 可以快速开发多线程的 PHP 服务器应用,它集成了很多常见的服务端功能,例如 Http 服务器,WebSocket 服务器,因此只需要很少一部分代码就可以实现你要的功能。

首先用一张图来概括一下设计架构和思路:

img

因为 FiveM 实际上就是 GTA5 套了个 Chromium 浏览器,所以我们可以在客户端进行任何普通浏览器能进行的操作,这就包括了 WebSocket。

我们所需要的是一个中间服务器,FiveM 客户端会与中间服务器建立 WebSocket 连接,然后实时将车辆数据推送到中间服务器,然后手机打开网页,也连接到中间服务器,此时中间服务器就可以将 FiveM 客户端推送上来的车辆数据再转推给手机,手机接收到之后转换为仪表数据并显示。

前排温馨提示:不想看文章只想要资源的伸手党请直接跳到文章结尾

这里为了方便,我就用了一个速度计插件来进行修改,我们来看 FiveM 服务端代码(client.lua):

Citizen.CreateThread(function()
    -- 创建死循环
    while true do

        -- 延迟 1 毫秒
        Wait(1)

        -- 取得玩家的实体
        playerPed = GetPlayerPed(-1)

        if playerPed then
            -- 获得玩家驾驶的车辆实体
            playerCar = GetVehiclePedIsIn(playerPed, false)
            -- 判断玩家是否在车上,以及是否在驾驶位
            if playerCar and GetPedInVehicleSeat(playerCar, -1) == playerPed then
                -- 获得车辆发动机转速
                carRPM = GetVehicleCurrentRpm(playerCar)
                -- 获得车辆当前速度
                carSpeed = GetEntitySpeed(playerCar)
                -- 获得车辆当前的档位
                carGear = GetVehicleCurrentGear(playerCar)
                -- 获得车辆的转向灯状态
                carIL = GetVehicleIndicatorLights(playerCar)
                -- 获得车辆的手刹状态
                carHandbrake = GetVehicleHandbrake(playerCar)
                -- 获得车辆的大灯状态,三个变量对应的是(是否有车灯,是否开启车灯,是否开启远光灯)
                carLS_r, carLS_o, carLS_h = GetVehicleLightsState(playerCar)

                -- 将车辆速度进行计算,获得公里和英里速度
                speedKmh = math.ceil(carSpeed * 3.6)
                speedMph = math.ceil(carSpeed * 2.236936)

                SendNUIMessage({
                    -- 向客户端的 Chromium 浏览器发送消息
                    showhud = true,

                    -- 这是一些插件自带的速度显示
                    unitLine1 = "KM/H",
                    speedLine1 = speedKmh,

                    unitLine2 = "MPH",
                    speedLine2 = speedMph,

                    -- 这一部分是要发送给客户端的信息
                    CurrentCarRPM = carRPM,
                    CurrentCarGear = carGear,
                    CurrentCarSpeed = carSpeed,
                    CurrentCarIL = carIL,
                    CurrentCarHandbrake = carHandbrake,
                    CurrentCarLS_r = carLS_r,
                    CurrentCarLS_o = carLS_o,
                    CurrentCarLS_h = carLS_h,

                    -- 玩家的 ID,后续辨识客户端使用
                    playerID = GetPlayerServerId(GetPlayerIndex())

                })
            else
                -- 玩家不在载具内时,停止跟踪数据
                SendNUIMessage({hidehud = true})
            end
        end
    end
end)

接下来是 FiveM 客户端 JS 代码(main.js):

/* FiveMES configuration start */
var use_SSL = false;
var ws_host = "example.com";
var ws_port = 9230;
/* FiveMES configuration end */

/* Don't change anything following this line */
var s_playerID;
var s_rpm;
var s_speed;
var s_gear;
var s_IL;
var s_Handbrake;
var s_LS_r;
var s_LS_o;
var s_LS_h;
var inVehicle = false;

$(function() {

    var ws;
    var connected = false;

    function websocket () {
        pl = use_SSL ? "wss" : "ws";
        ws = new WebSocket(`${pl}://${ws_host}:${ws_port}/`);
        ws.onopen = function(event){
            console.log('Connect successful');
            connected = true;
        };
        ws.onmessage = function (event) {
            var message = event.data;
        }
        ws.onclose = function(event) {
            connected = false;
            console.log("Connect close, status: " + this.readyState);
            websocket();
        };
        ws.onerror = function(event) {
            console.log("WebSocket error.");
        };
    }

    function heartbeat() {
        if(connected) {
            var senddata = "{\"action\":\"heartbeat\",\"token\":\"" + $("#token").val() + "\"}";
            ws.send(senddata);
            return;
        }
    }

    function setSpeed(id, speed, rpm, gear, IL, Handbrake, LS_r, LS_o, LS_h) {
        if(connected) {
            Handbrake = booltoString(Handbrake);
            LS_r = booltoString(LS_r);
            LS_o = booltoString(LS_o);
            LS_h = booltoString(LS_h);
            var senddata = `{"action":"setspeed","sid":"${id}","speed":"${speed}","rpm":"${rpm}","gear":"${gear}","IL":"${IL}","Handbrake":"${Handbrake}","LS_r":"${LS_r}","LS_o":"${LS_o}","LS_h":"${LS_h}"}`;
            ws.send(senddata);
            return;
        }
    }

    function booltoString(str) {
        return str ? "true" : "false";
    }

    websocket();

    window.addEventListener("message", function(event) {
        var item = event.data;

        if (item.incar) {

            inVehicle = true;
            s_playerID = item.playerID;
            s_rpm = item.CurrentCarRPM;
            s_speed = item.CurrentCarSpeed;
            s_gear = item.CurrentCarGear;
            s_IL = item.CurrentCarIL;
            s_Handbrake = item.CurrentCarHandbrake;
            s_LS_r = item.CurrentCarLS_r;
            s_LS_o = item.CurrentCarLS_o;
            s_LS_h = item.CurrentCarLS_h;

        } else if (item.outcar) {
            inVehicle = false;
        }
    });

    setInterval(function() {
        if(inVehicle) {
            setSpeed(s_playerID, s_speed, s_rpm, s_gear, s_IL, s_Handbrake, s_LS_r, s_LS_o, s_LS_h);
        }
    }, 50);
});

实际上你只需要修改 FiveMES configuration start 到 end 那一部分内容就行了,懒得注释了……大概的流程呢,就是客户端 Chromium 浏览器接收到 lua 脚本传来的 message 之后,通过 WebSocket 再发给中间服务器,中间服务器再转推给手机这个过程。

我们来看看中间服务器的代码(server.php):

< ?php
/**
 *
 *  FiveMES by Akkariin
 *
 */
if(PHP_SAPI !== 'cli') {
? >
<html lang="en">
    <head>
        <title>403 Forbidden</title>
        <style type="text/css">
            body {
                background: #F1F1F1;
                font-weight: 100 ! important;
                padding: 32px;
            }
            h1 {
                font-weight: 100 ! important;
            }
            logo {
                font-size: 100px;
            }
        </style>
    </head>
    <body>
        <logo>:(</logo>
        <h1>403 Forbidden</h1>
        <p><b>Error:</b> Plugin is disabled in php-fpm mode.</p>
        <p><em>Powered by ZeroDream</em></p>
    </body>
</html>
< ?php
    exit;
}

// 储存服务器 ID => 连接 ID
$clientStorage = new Swoole\Table(2048);
$clientStorage->column('id', swoole_table::TYPE_INT, 4);
$clientStorage->column('client', swoole_table::TYPE_INT, 4);
$clientStorage->create();

// 储存连接 ID => 服务器 ID
$fidStorage = new Swoole\Table(2048);
$fidStorage->column('id', swoole_table::TYPE_INT, 4);
$fidStorage->column('client', swoole_table::TYPE_INT, 4);
$fidStorage->create();

$server = new swoole_websocket_server("0.0.0.0", 9230);
$server->clientStorage = $clientStorage;
$server->fidStorage = $fidStorage;

$server->on('open', function (swoole_websocket_server $server, $request) {
    echo "Client {$request->fd} connected\n";
});

$server->on('message', function (swoole_websocket_server $server, $frame) {
    $rawdata = $frame->data;
    $data = json_decode($rawdata, true);
    if($data) {
        if(isset($data['action'])) {
            switch($data['action']) {
                case 'setclient':
                    if(isset($data['value']) && preg_match("/^[0-9]{1,5}$/", $data['value'])) {
                        if(!$server->clientStorage->get(Intval($data['value']))) {
                            $server->clientStorage->set(Intval($data['value']), Array('client' => $frame->fd));
                            $server->fidStorage->set($frame->fd, Array('client' => Intval($data['value'])));
                            echo "Client {$frame->fd} Connect to game ID {$data['value']}\n";
                        }
                    }
                    break;
                case 'setspeed':
                    if(isset($data['sid']) && preg_match("/^[0-9]{1,5}$/", $data['sid'])) {
                        $fd = $server->clientStorage->get(Intval($data['sid']), 'client');
                        if($fd) {
                            if(isset($data['speed']) && isset($data['gear']) && isset($data['rpm'])) {
                                $rs = $server->push($fd, json_encode(Array(
                                    'action' => 'update',
                                    'speed' => Floatval($data['speed']),
                                    'gear' => Intval($data['gear']),
                                    'rpm' => Floatval($data['rpm']),
                                    'IL' => Intval($data['IL']),
                                    'Handbrake' => $data['Handbrake'],
                                    'LS_r' => $data['LS_r'],
                                    'LS_o' => $data['LS_o'],
                                    'LS_h' => $data['LS_h']
                                )));
                                if(!$rs) {
                                    $fid = $server->fidStorage->get($fd, 'client');
                                    $server->fidStorage->del($fd);
                                    $server->clientStorage->det($fid);
                                }
                            }
                        }
                    }
                    break;
                default:
                    // Undefined action
            }
        }
    }
});

$server->on('close', function (swoole_websocket_server $server, $fd) {
    echo "Client {$fd} offline\n";
    $fid = $server->fidStorage->get($fd, 'client');
    if($fid) {
        $server->fidStorage->del($fd);
        $server->clientStorage->del($fid);
    }

});
$server->start();

function emptyStr($str) {
    if(empty($str) || $str == null) {
        return "";
    } else {
        return $str;
    }
}

function getBoolean($data) {
    return $data == "true";
}

这里呢,我创建了两个 Table,用于储存用户信息,其中 clientStorage 表负责储存 服务器 ID => 浏览器标识,而 fidStorage 表则负责储存 浏览器标识 => 服务器 ID,你可能会疑问为什么要两个表,我们接着往下看看。

首先,浏览器打开后会要求你输入游戏中的 ID(这里就叫“服务器 ID”),输入之后,浏览器会与中间服务器建立 WebSocket 连接,并发送一个 setClient 的消息,参数就是你的服务器 ID,这就大概是告诉中间服务器:我是浏览器 XXX,我现在要查看服务器里 ID 为 YYY 的玩家的车辆数据。

中间服务器接收到了以后,就把 YYY 作为 Key 值,Value 为你的浏览器标识,储存到 clientStorage 表里,然后把你的浏览器标识作为 Key,Value 为你的服务器 ID,储存到 fidStorage 表里。

接下来,你打开游戏,进入服务器,此时客户端就会与服务器建立一个 WebSocket 连接,然后以每 50 毫秒一次的频率将你的车辆数据发送到中间服务器(setSpeed),并且附带你游戏中的 ID,中间服务器接收到了以后,根据这个传过来的 ID 在 clientStorage 里搜寻对应的浏览器标识,然后再给对应的浏览器传输你的车辆数据。

浏览器在接收到你的车辆数据之后,绘制转速表的指针,就可以实时显示了。

最后,话不多说,放上 Github 地址:https://github.com/kasuganosoras/FiveMES

希望大家玩的愉快,喜欢的话记得给我点个小星星~

推荐阅读文章

4 条评论

  1. tong说道:

    大佬,Sakura Frp这几天为啥经常出问题啊…

  2. 天鸡部落说道:

    化腐朽为神奇啊!我觉得博主真的太牛了,想到牛,我就想起了牛魔王,想到了牛魔王,我就想到了铁扇公主,想到了铁扇公主,我就想到了红孩儿,也不知道这孩子现在过得怎么样。为了让孩子有个好前程,我让他认牛做父啊!

  3. 皮皮蟹说道:

    这么巨吗?博主多大啊?

回复 tong 取消回复

您的电子邮箱地址不会被公开。 必填项已用*标注