Local Hosting & Cloud Orchestration

10. 容器化游戏服务

Launcher 支持本地镜像、配置和容器生命周期管理,中心服务提供云端实例分配、端口管理和存档回收。

6 个核心函数5 个重点文件基于当前工程源码

模块职责

Launcher 支持本地镜像、配置和容器生命周期管理,中心服务提供云端实例分配、端口管理和存档回收。

重点文件

调用链

  1. 1选择游戏模板
  2. 2检查 Docker 与镜像
  3. 3生成容器参数
  4. 4启动实例并分配端口
  5. 5返回连接信息
  6. 6停止实例与归档存档
本地开服入口

CustomGameLobbyWindow::startLocalDockerServer

收集当前大厅配置,校验房主权限并调用 LocalDockerHostManager 启动容器。

customgamelobbywindow.cpp · L1351–L1395
原型void CustomGameLobbyWindow::startLocalDockerServer()

调用时机

房主点击启动本地游戏服务时调用。

返回说明

无返回值。

参数

无显式参数。

执行流程

  1. 读取表单配置
  2. 校验 Docker 开关与权限
  3. 保存配置
  4. 调用 startGameServer
  5. 更新实例信息与日志

工程说明

UI 层负责配置编排,Docker 命令由独立管理器生成。

关联接口

查看完整实现
customgamelobbywindow.cpp
void CustomGameLobbyWindow::startLocalDockerServer()
{
    if (m_joinMode) {
        QMessageBox::information(this, "成员模式", "当前为成员模式,不会启动房主本地游戏服务;可导入/导出配置并验证连接信息。");
        return;
    }

    CustomGameRoomConfig cfg = collectConfig();

    if (cfg.roomId.isEmpty() || cfg.docker.imageName.isEmpty()) {
        QMessageBox::warning(this, "启动失败", "room_id 和 Docker 镜像不能为空。");
        return;
    }

    saveRoomConfig();
    startLobbyControlServerIfNeeded();

    QString containerId;
    QString msg;

    appendLog("正在启动本地游戏服务...");

    if (!m_docker->startGameServer(cfg, containerId, msg)) {
        appendLog("启动失败:" + msg);
        QMessageBox::warning(this, "Docker 启动失败", msg);
        return;
    }

    qint64 now = QDateTime::currentSecsSinceEpoch();

    cfg.status = "running";
    cfg.locked = true;
    cfg.startedAt = now;
    cfg.updatedAt = now;
    cfg.docker.runningContainerId = containerId;

    QString error;
    m_storage.saveRoomConfig(cfg, &error);

    m_currentConfig = cfg;
    populateForm(cfg);

    appendLog(msg);
    appendLog("本地服务器地址:" + cfg.hostIp + ":" + QString::number(cfg.docker.hostPort));
}
本地 Docker 启动

LocalDockerHostManager::startGameServer

根据房间配置构造 docker run 参数,检查环境、镜像与端口并启动容器。

localdockerhostmanager.cpp · L149–L312
原型bool LocalDockerHostManager::startGameServer(const CustomGameRoomConfig& cfg, QString& outContainerId, QString& outMsg) const

调用时机

本地开服入口调用。

返回说明

启动成功返回 true。

参数

参数说明
cfg自定义游戏房间配置
outContainerId输出容器 ID
outMessage输出状态说明

执行流程

  1. 检查 Docker CLI/Engine
  2. 检查镜像
  3. 规范容器名
  4. 构造端口/环境/挂载参数
  5. 执行 docker run
  6. 解析容器 ID

工程说明

仅接受结构化配置,避免 UI 直接拼接命令。

关联接口

查看完整实现
localdockerhostmanager.cpp
bool LocalDockerHostManager::startGameServer(const CustomGameRoomConfig& cfg,
                                             QString& outContainerId,
                                             QString& outMsg) const
{
    outContainerId.clear();
    outMsg.clear();

    const bool advancedMode =
        cfg.docker.advancedMode ||
        cfg.gameId.compare("custom_command", Qt::CaseInsensitive) == 0;

    if (cfg.roomId.trimmed().isEmpty()) {
        outMsg = "房间 ID 为空,无法启动本地游戏服务。";
        return false;
    }

    if (cfg.docker.imageName.trimmed().isEmpty()) {
        outMsg = "Docker 镜像名为空,请先选择游戏模板或填写服务镜像。";
        return false;
    }

    if (!advancedMode) {
        if (cfg.docker.hostPort < 1 || cfg.docker.hostPort > 65535 ||
            cfg.docker.containerPort < 1 || cfg.docker.containerPort > 65535) {
            outMsg = "端口范围不合法,请使用 1-65535 之间的端口。";
            return false;
        }

        QString protocol = cfg.docker.protocol.trimmed().toLower();
        if (protocol != "tcp" && protocol != "udp") {
            outMsg = "协议类型不合法,请填写 tcp 或 udp。";
            return false;
        }
    }

    QString dockerMsg;
    if (!isDockerAvailable(&dockerMsg)) {
        outMsg = "Docker 不可用:" + dockerMsg;
        return false;
    }

    if (!imageExists(cfg.docker.imageName, &dockerMsg)) {
        outMsg = "Docker 镜像不存在,请先构建或导入镜像:"
                 + cfg.docker.imageName
                 + "\n"
                 + dockerMsg;
        return false;
    }

    QString containerName = cfg.docker.containerName;
    if (containerName.trimmed().isEmpty()) {
        containerName = "custom_" + cfg.roomId;
    }
    containerName = sanitizeDockerName(containerName);

    QStringList args;
    args << "run"
         << "-d"
         << "--pull=never"
         << "--restart=no"
         << "--name" << containerName
         << "--label" << "p2p.custom_game=1"
         << "--label" << ("p2p.room=" + cfg.roomId);

    if (!cfg.docker.memoryLimit.trimmed().isEmpty()) {
        args << "--memory" << cfg.docker.memoryLimit.trimmed();
    }

    if (!cfg.docker.cpuLimit.trimmed().isEmpty()) {
        args << "--cpus" << cfg.docker.cpuLimit.trimmed();
    }

    if (advancedMode) {

        for (const QString& mapping : nonEmptyTrimmedLines(cfg.docker.portMappings)) {
            args << "-p" << mapping;
        }

        for (const QString& env : nonEmptyTrimmedLines(cfg.docker.envVars)) {
            args << "-e" << env;
        }

        for (const QString& volume : nonEmptyTrimmedLines(cfg.docker.volumeMounts)) {
            args << "-v" << QDir::toNativeSeparators(volume);
        }

        for (const QString& line : nonEmptyTrimmedLines(cfg.docker.extraDockerArgs)) {
            appendSplitArgs(args, line);
        }
    } else {
        QString protocol = cfg.docker.protocol.trimmed().toLower();
        if (protocol != "udp") {
            protocol = "tcp";
        }

        QString portMapping = QString("%1:%2/%3")
                                  .arg(cfg.docker.hostPort)
                                  .arg(cfg.docker.containerPort)
                                  .arg(protocol);

        args << "-p" << portMapping;

        args << "-e" << ("GAME_ID=" + cfg.gameId)
             << "-e" << ("GAME_NAME=" + cfg.gameName)
             << "-e" << ("ROOM_ID=" + cfg.roomId)
             << "-e" << ("MAX_PLAYERS=" + QString::number(cfg.maxPlayers));

        const bool isFactorio =
            cfg.gameId.compare("factorio", Qt::CaseInsensitive) == 0 ||
            cfg.docker.imageName.contains("factorio", Qt::CaseInsensitive);

        if (!cfg.docker.saveDir.trimmed().isEmpty()) {
            QDir().mkpath(cfg.docker.saveDir);

            QString targetMount = isFactorio ? "/factorio" : "/data/saves";
            args << "-v" << (QDir::toNativeSeparators(cfg.docker.saveDir) + ":" + targetMount);
        }

        if (!isFactorio && !cfg.docker.configDir.trimmed().isEmpty()) {
            QDir().mkpath(cfg.docker.configDir);
            args << "-v" << (QDir::toNativeSeparators(cfg.docker.configDir) + ":/data/config");
        }
    }

    args << cfg.docker.imageName;

    if (advancedMode && !cfg.docker.containerCommand.trimmed().isEmpty()) {
        for (const QString& line : nonEmptyTrimmedLines(cfg.docker.containerCommand)) {
            appendSplitArgs(args, line);
        }
    }

    CommandResult r = runDocker(args, 30000);

    if (!r.success) {
        outMsg = "Docker 启动失败:" + r.message;
        return false;
    }

    outContainerId = r.stdOut.trimmed();

    if (outContainerId.isEmpty()) {
        outMsg = "Docker 已返回成功,但未获得 container_id。";
        stopContainer(containerName, outMsg);
        return false;
    }

    outMsg = "本地游戏服务已启动:" + outContainerId;
    return true;
}
镜像离线导入

CustomGameImageManager::loadImageFromTar

使用 docker load 导入镜像 tar,并返回命令结果。

customgameimagemanager.cpp · L158–L180
原型bool CustomGameImageManager::loadImageFromTar(const QString& imageTarPath, QString& outMsg) const

调用时机

用户选择离线镜像包时调用。

返回说明

导入成功返回 true。

参数

参数说明
imageTarPathDocker 镜像归档路径
outMsg输出执行信息

执行流程

  1. 校验文件存在
  2. 检查 Docker Engine
  3. 执行 docker load
  4. 解析退出状态
  5. 返回结果

工程说明

镜像归档与普通游戏存档包在入口层分开处理。

关联接口

查看完整实现
customgameimagemanager.cpp
bool CustomGameImageManager::loadImageFromTar(const QString& imageTarPath, QString& outMsg) const
{
    if (imageTarPath.trimmed().isEmpty()) {
        outMsg = "镜像 tar 路径为空。";
        return false;
    }

    QFileInfo tar(imageTarPath);
    if (!tar.exists() || !tar.isFile()) {
        outMsg = "镜像 tar 文件不存在:\n" + imageTarPath;
        return false;
    }

    CommandResult r = runDocker(QStringList() << "load" << "-i" << imageTarPath, 120000);

    if (r.success) {
        outMsg = "镜像导入成功:\n" + r.stdOut;
        return true;
    }

    outMsg = "镜像导入失败:\n" + r.message;
    return false;
}
云端控制请求

CloudFactorioWindow::sendControlRequest

构造带 owner 和显示名称的控制请求,通过 UDP 发送云端实例命令。

cloudfactoriowindow.cpp · L244–L260
原型void CloudFactorioWindow::sendControlRequest(const QString& cmd, const QJsonObject& extra)

调用时机

查询、启动、停止或导出云端实例时调用。

返回说明

无返回值。

参数

参数说明
cmd云端控制命令
extra附加参数

执行流程

  1. 构造基础 JSON
  2. 写入 owner/display_name
  3. 合并附加字段
  4. 发送到中心服务

工程说明

文件上传下载使用独立 TCP 通道,控制面保持轻量。

关联接口

查看完整实现
cloudfactoriowindow.cpp
void CloudFactorioWindow::sendControlRequest(const QString& cmd, const QJsonObject& extra)
{
    if (!m_udp) {
        return;
    }

    QJsonObject req = extra;
    req["cmd"] = cmd;
    req["owner"] = ownerId();
    req["display_name"] = displayName();
    req["request_id"] = QString::number(QDateTime::currentMSecsSinceEpoch());

    QByteArray data = QJsonDocument(req).toJson(QJsonDocument::Compact);
    m_udp->writeDatagram(data, QHostAddress(serverIp()), udpPort());

    appendLog("已发送控制请求:" + cmd);
}
云端实例编排

GameInstanceManager::startCloudFactorioInstance

检查镜像与并发限额,分配 UDP 端口、准备实例目录和配置,并启动 Factorio 容器。

game_instance_manager.cpp · L815–L965
原型GameInstanceManager::startCloudFactorioInstance(const std::string& owner, const std::string& displayName)

调用时机

服务端收到云端开服请求后调用。

返回说明

返回 CloudFactorioInstanceInfo。

参数

参数说明
owner实例所有者
displayName显示名称
saveArchivePath可选存档包路径

执行流程

  1. 检查镜像
  2. 检查并发配额
  3. 分配 UDP 端口
  4. 创建实例目录
  5. 准备配置与存档
  6. 执行 docker run
  7. 返回连接信息

工程说明

实例信息包含容器 ID、主机、端口和状态。

关联接口

查看完整实现
game_instance_manager.cpp
GameInstanceManager::startCloudFactorioInstance(const std::string& owner,
                                                const std::string& displayName)
{
    std::lock_guard<std::mutex> lock(start_mutex);

    CloudFactorioInstanceInfo info;

    if (owner.empty()) {
        info.msg = "owner 为空";
        return info;
    }

    std::string imageMsg;
    if (!cloudFactorioImageExists(&imageMsg)) {
        info.msg = imageMsg;
        return info;
    }

    int runningCount = countCloudFactorioRunning();

    if (runningCount >= CLOUD_FACTORIO_MAX_RUNNING) {
        info.msg = "当前云端 Factorio 实例已满,最多同时运行 2 个实例";
        return info;
    }

    std::string safeOwner = sanitizeName(owner);
    std::string safeDisplayName = sanitizeName(displayName.empty() ? owner : displayName);

    long long now = static_cast<long long>(time(nullptr));
    std::string suffix = randomSuffix();

    std::string instanceId =
        "cfactorio_" + safeOwner + "_" + std::to_string(now) + "_" + suffix;

    instanceId = sanitizeName(instanceId);

    std::string containerName =
        "cloud_factorio_" + safeOwner + "_" + suffix;

    containerName = sanitizeName(containerName);

    std::string instanceDir =
        cloudFactorioRoot() + "/instances/" + safeOwner + "/" + instanceId;

    std::string saveDir = instanceDir + "/saves";
    std::string configDir = instanceDir + "/config";

    std::string mkdirCmd =
        "mkdir -p " + shellQuote(saveDir) + " " +
        shellQuote(configDir) + " " +
        shellQuote(cloudFactorioRoot() + "/exports/" + safeOwner);

    int mkdirExit = 0;
    std::string mkdirOut = execCommand(mkdirCmd, &mkdirExit);

    if (mkdirExit != 0) {
        info.msg = "创建 Factorio 数据目录失败:" + mkdirOut;
        return info;
    }

    for (int hostPort = CLOUD_FACTORIO_PORT_START;
         hostPort <= CLOUD_FACTORIO_PORT_END;
         ++hostPort) {
        if (!isUdpPortAvailable(hostPort)) {
            continue;
        }

        std::ostringstream cmd;

        cmd << "docker rm -f " << containerName << " >/dev/null 2>&1; "
            << "timeout " << DOCKER_CMD_TIMEOUT_SECONDS << "s "
            << "docker run -d --pull=never "
            << "--restart=no "
            << "--memory=1536m "
            << "--cpus=1.50 "
            << "--label p2p.cloud_game=1 "
            << "--label p2p.game_id=factorio "
            << "--label p2p.owner=" << safeOwner << " "
            << "--label p2p.display_name=" << safeDisplayName << " "
            << "--label p2p.instance_id=" << instanceId << " "
            << "--name " << containerName << " "
            << "-p " << hostPort << ":" << CLOUD_FACTORIO_CONTAINER_PORT << "/udp "
            << "-v " << shellQuote(instanceDir) << ":/factorio "
            << CLOUD_FACTORIO_IMAGE;

        std::cout << "☁️ [CloudFactorio] 准备启动云端 Factorio 实例\n";
        std::cout << "☁️ [CloudFactorio] owner=" << owner
                  << " instance=" << instanceId
                  << " port=" << hostPort << "\n";
        std::cout << "☁️ [CloudFactorio] command=" << cmd.str() << "\n";

        int exitCode = 0;
        std::string output = execCommand(cmd.str(), &exitCode);

        std::cout << "☁️ [CloudFactorio] docker exitCode=" << exitCode
                  << " output=" << output << "\n";

        if (exitCode != 0) {
            std::string cleanupCmd =
                "timeout " + std::to_string(DOCKER_CMD_TIMEOUT_SECONDS) +
                "s docker rm -f " + containerName + " >/dev/null 2>&1";

            int cleanupExit = 0;
            execCommand(cleanupCmd, &cleanupExit);

            continue;
        }

        std::string containerId = extractContainerId(output);

        if (containerId.empty()) {
            std::string cleanupMsg;
            stopInstance(containerName, cleanupMsg);
            continue;
        }

        if (!isContainerRunning(containerId)) {
            std::string cleanupMsg;
            stopInstance(containerId, cleanupMsg);

            std::cerr << "❌ [CloudFactorio] 容器启动后未保持 running,已清理。container="
                      << containerId << " cleanup=" << cleanupMsg << "\n";

            continue;
        }

        info.success = true;
        info.instanceId = instanceId;
        info.containerId = containerId;
        info.containerName = containerName;
        info.owner = safeOwner;
        info.displayName = safeDisplayName;
        info.host = resolveGameHost();
        info.port = hostPort;
        info.saveDir = saveDir;
        info.configDir = configDir;
        info.msg = "云端 Factorio 服务器已启动";

        std::cout << "✅ [CloudFactorio] 已启动实例"
                  << " instance=" << info.instanceId
                  << " container=" << info.containerId
                  << " host=" << info.host
                  << " port=" << info.port << "\n";

        return info;
    }

    info.success = false;
    info.msg = "34197-34198 没有可用 UDP 端口,或 Docker 启动均失败";
    return info;
}
云端实例停止

GameInstanceManager::stopCloudFactorioInstance

验证实例所有权,停止并移除容器,释放实例运行状态。

game_instance_manager.cpp · L967–L1001
原型bool GameInstanceManager::stopCloudFactorioInstance(const std::string& owner, const std::string& instanceId, std::string& outMsg)

调用时机

用户结束云端托管服务时调用。

返回说明

停止成功返回 true。

参数

参数说明
owner实例所有者
instanceId实例标识
outMsg输出状态说明

执行流程

  1. 查找实例
  2. 校验所有权
  3. 执行 docker rm -f
  4. 更新实例状态
  5. 返回结果

工程说明

停止实例与存档导出可组成完整托管闭环。

关联接口

查看完整实现
game_instance_manager.cpp
bool GameInstanceManager::stopCloudFactorioInstance(const std::string& owner,
                                                    const std::string& instanceId,
                                                    std::string& outMsg)
{
    if (owner.empty() || instanceId.empty()) {
        outMsg = "owner 或 instanceId 为空";
        return false;
    }

    std::string safeOwner = sanitizeName(owner);
    std::string safeInstance = sanitizeName(instanceId);

    std::string findCmd =
        "timeout " + std::to_string(DOCKER_CMD_TIMEOUT_SECONDS) +
        "s docker ps -aq "
        "--filter label=p2p.cloud_game=1 "
        "--filter label=p2p.game_id=factorio "
        "--filter label=p2p.owner=" + safeOwner + " "
        "--filter label=p2p.instance_id=" + safeInstance;

    int findExit = 0;
    std::string containerId = trim(execCommand(findCmd, &findExit));

    if (findExit != 0) {
        outMsg = "查询云端 Factorio 容器失败";
        return false;
    }

    if (containerId.empty()) {
        outMsg = "未找到对应云端 Factorio 实例,可能已经停止";
        return true;
    }

    return stopInstance(containerId, outMsg);
}