MeteorCat / 游戏服务架构(四)

Created Fri, 13 Oct 2023 19:26:59 +0800 Modified Wed, 29 Oct 2025 23:24:54 +0800
1774 Words

游戏服务架构(四)

这篇章涉及的比较多, 可能需要做好前置的知识:

建议如果以上都没有概念需要网上查漏补缺下, 这些基础知识基本上或多或少会遇到需要处理.

这里用 Godot 做演示, 具体 Unity 也可以去处理.

Web简单游戏

单纯利用 Web 做游戏, 这种简单的游戏服务端形态在差不多 2015 年见过, 那时候用于各地 地方麻将 游戏( PS: 那时候国内游戏项目讲究快速上线, 所以诞生很多为了压低成本而速成的技术, 单纯 Web 请求做的三消游戏也是当年时代产物 ).

当年衍生很多奇奇怪怪的技术栈, 所以能够看到很多现在看起来怪异的技术选型, 而且 2015 年的时候 Websocket 还没爆发性的推广

开发游戏必须先要确定 玩法, 影射现实来说 只有确定玩法的游戏才能叫游戏, 哪怕猜拳都是得知道 '剪刀-石头-布'.

这里采用 骰子比大小 玩法这种数值对拼玩法( 戏称 '电子斗蛐蛐' ), 直接用 php+json 实现个 web 无状态服务:

<?php

// 解析 RAW + JSON 参数
$_PARAMS = file_get_contents('php://input');
$_PARAMS = $_PARAMS ? json_decode($_PARAMS, 1) : [];
$_PARAMS = $_PARAMS ?: [];


// JSON输出工具
function json_response(int $status, string $message = "SUCCESS", array $data = []) {
    exit(json_encode([
        'status' => $status,
        'message' => $message,
        'data' => $data
    ]));
}


// 数据库, 玩家数据肯定需要数据库落地, 这里临时采用 MYSQL 做内存, 实际可以考虑 MongoDB 处理来保证告诉内存数据读写
$_DB = new mysqli("127.0.0.1:3306", "meteorcat", "meteorcat", "game");
if ($_DB->connect_errno) {
    json_response(1, "failed by connection database");
}

if (!isset($_PARAMS['proto'])) {
    json_response(1, "failed by proto");
}


// 获取提交的状态
$proto = intval($_PARAMS['proto']);
switch ($proto) {
    // 默认协议找不到
    default:
        json_response(1, "failed by not found proto");
        break;

    // 登录协议
    case 101:
        // 这里获取账号名称
        $username = trim($_PARAMS['username'] ?? "");
        $password = trim($_PARAMS['password'] ?? "");
        if (strlen($username) <= 0 || strlen($password) <= 0) {
            json_response(1, "Failed by Username|Password");
            return;
        }

        // 确定是否存在的账户, 不存在需要帮助注册
        $table_name = "player_info";
        $hash_password = md5($password);
        $sql = "SELECT id FROM {$table_name} WHERE username=? AND password=? LIMIT 1";
        $stmt = $_DB->prepare($sql);
        if (!$stmt) {
            json_response(1, "Failed by Database");
            return;
        }
        $stmt->bind_param("ss", $username, $hash_password);
        $stmt->execute();
        $row = $stmt->get_result();
        $row = $row->fetch_assoc() ?: [];
        $stmt->close();
        if (!$row) {

            // 创建玩家
            $sql = "INSERT INTO {$table_name} (username, password, token) VALUES(?, ?, '')";
            $stmt = $_DB->prepare($sql);
            if (!$stmt) {
                json_response(1, "Failed by Database");
                return;
            }
            $stmt->bind_param("ss", $username, $hash_password);
            $stmt->execute();
            $stmt->close();


            // 重新查询数据
            $sql = "SELECT id FROM {$table_name} WHERE username=? AND password=? LIMIT 1";
            $stmt = $_DB->prepare($sql);
            if (!$stmt) {
                json_response(1, "Failed by Database");
                return;
            }
            $stmt->bind_param("ss", $username, $hash_password);
            $stmt->execute();
            $row = $stmt->get_result();
            $row = $row->fetch_assoc() ?: [];
            $stmt->close();
        }

        // 确定玩家ID
        $uid = intval($row['id'] ?? "0");
        if (!$uid) {
            json_response(1, "Failed by Player");
            return;
        }

        // 更新Token写入记录
        $micro = microtime(true);
        $micro_md5 = md5($micro);
        $sql = "UPDATE {$table_name} SET token=? WHERE id=? LIMIT 1";
        $stmt = $_DB->prepare($sql);
        if (!$stmt) {
            json_response(1, "Failed by Token");
            return;
        }
        $stmt->bind_param("si", $micro_md5, $uid);
        $stmt->execute();
        $stmt->close();


        // 响应数据返回
        json_response(0, "SUCCESS", [
            "uid" => $uid,
            "token" => $micro_md5
        ]);
        break;

    // 登出协议
    case 102:
        $uid = intval($_PARAMS['uid'] ?? 0);
        $token = $_PARAMS['token'] ?? "";

        // Token失效机制
        $table_name = "player_info";
        $sql = "UPDATE {$table_name} SET token='' WHERE id=? AND token=? LIMIT 1";
        $stmt = $_DB->prepare($sql);
        if (!$stmt) {
            json_response(1, "Failed by Token");
            return;
        }

        $stmt->bind_param("is", $uid, $token);
        $stmt->execute();
        $stmt->close();
        json_response(0);
        break;

    // 详情信息, 获取胜利分数等信息
    case 103:
        // todo: 这里为了简单实现暂不实现
        break;

    // 加入房间, 清空结算之前进行的活动
    case 201:
        $uid = intval($_PARAMS['uid'] ?? 0);
        $token = $_PARAMS['token'] ?? "";

        // todo: 用于进入返回获取房间码之后结算之前的骰子比赛, 这里目前没考虑实现
        break;

    // 开始游玩骰子游戏, 投出骰子确定自己和AI的点数, 返回给客户端做表现
    case 202:
        $uid = intval($_PARAMS['uid'] ?? 0);
        $token = $_PARAMS['token'] ?? "";

        // 获取是否登录
        $table_name = "player_info";
        $sql = "SELECT id,value FROM {$table_name} WHERE id=? AND token=? LIMIT 1";
        $stmt = $_DB->prepare($sql);
        if (!$stmt) {
            json_response(1, "Failed by Database");
            return;
        }
        $stmt->bind_param("is", $uid, $token);
        $stmt->execute();
        $row = $stmt->get_result();
        $row = $row->fetch_assoc() ?: [];
        $stmt->close();
        if (!$row) {
            json_response(1, "Failed by Token");
            return;
        }

        // 确定玩家ID
        $score = intval($row['value'] ?? "0");
        $uid = intval($row['id'] ?? "0");
        if (!$uid) {
            json_response(1, "Failed by Player");
            return;
        }


        // 投出随机值并写入数据库值
        $value = mt_rand(10000, 69999);
        $value = intval($value / 10000);
        $sql = "UPDATE {$table_name} SET value=value+{$value} WHERE id=? AND token=? LIMIT 1";
        $stmt = $_DB->prepare($sql);
        if (!$stmt) {
            json_response(1, "Failed by Record");
            return;
        }
        $stmt->bind_param("is", $uid, $token);
        $stmt->execute();
        $stmt->close();

        // 响应值返回
        json_response(0, "SUCCESS", [
            "index" => $value - 1,
            "value" => $value,
            "score" => $score + $value,
        ]);
        break;
}

之后数据库表只需要暂时生成如下架构:

/*!40101 SET @OLD_CHARACTER_SET_CLIENT = @@CHARACTER_SET_CLIENT */;
/*!40101 SET NAMES utf8 */;
/*!50503 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE = @@TIME_ZONE */;
/*!40103 SET TIME_ZONE = '+00:00' */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS = @@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS = 0 */;
/*!40101 SET @OLD_SQL_MODE = @@SQL_MODE, SQL_MODE = 'NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES = @@SQL_NOTES, SQL_NOTES = 0 */;


-- 导出 game 的数据库结构
CREATE DATABASE IF NOT EXISTS `game` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */;
USE `game`;

-- 导出  表 game.player_info 结构
CREATE TABLE IF NOT EXISTS `player_info`
(
    `id`       bigint(20) unsigned NOT NULL AUTO_INCREMENT,
    `username` varchar(64)         NOT NULL COMMENT '账号名',
    `password` varchar(64)         NOT NULL COMMENT '密码',
    `token`    varchar(64)         NOT NULL DEFAULT '',
    `value`    bigint(20) unsigned NOT NULL DEFAULT 0 COMMENT '投掷游戏值',
    PRIMARY KEY (`id`),
    UNIQUE KEY `username` (`username`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_unicode_ci COMMENT ='玩家信息';

-- 数据导出被取消选择。

/*!40103 SET TIME_ZONE = IFNULL(@OLD_TIME_ZONE, 'system') */;
/*!40101 SET SQL_MODE = IFNULL(@OLD_SQL_MODE, '') */;
/*!40014 SET FOREIGN_KEY_CHECKS = IFNULL(@OLD_FOREIGN_KEY_CHECKS, 1) */;
/*!40101 SET CHARACTER_SET_CLIENT = @OLD_CHARACTER_SET_CLIENT */;
/*!40111 SET SQL_NOTES = IFNULL(@OLD_SQL_NOTES, 1) */;

客户端实现

这里就是做的初版 Web 简单游戏服务器, 之后客户端先暂时实现 Http 推送服务( 基于Godot ):

Game

这里就是最简单的 Web 线上游戏, 很简陋但是已经涵盖有自己所在的玩法系统( 其实主要是懒得找客户端美术资源随便处理下的案例 ), 可以先跑下游戏能否运行, 后续篇章解析出内部存在的问题并且解析出怎么处理存在的问题.

当然这个项目有很多问题, 可能安全性地下/数据传输冗余浪费等不重复十几个问题, 但是你已经做出实现玩法自己玩法的游戏! 有句话说得好, 你制作出来前十个大概率垃圾游戏, 你要做的就是赶快把这前十个做完! 不要总是瞻前顾后, 而是要赶快行动起来边制作边思考总结.