【OpenHarmony/HarmonyOS】自定义对战大厅设计:1v1、3v3、队伍槽位与邀请流程

多人大厅的难点不在于把六个头像摆成两列,而在于“谁有权修改什么”。房主选择模式和地图,附近设备收到邀请,访客接受后进入房间,槽位可能是等待、AI 或关闭,最后所有设备必须基于同一份配置同时进入游戏。本篇基于 OpenHarmony/HarmonyOS 项目中的自定义房间原型,拆解 ArkUI 大厅的数据结构、P2P 信令和路由参数,并明确当前模拟设备、UDP 原型与 3v3 未闭环的边界。👥

一、页面有两个主要阶段

CustomTeamPage 没有拆成两个路由页,而是使用 showLobby 在设置视图和大厅视图之间切换:

@State selectedMode: '1v1' | '3v3' = '1v1';
@State mapSize:
  'small' | 'medium' | 'large' = 'medium';
@State mapSizeValue: number = 1;
@State showLobby: boolean = false;
build(): void {
  Stack() {
    GalaxyBackground();

    if (!this.showLobby) {
      this.SetupView();
    } else {
      this.LobbyView();
    }
  }
}
阶段用户任务核心状态
Setup选择 1v1/3v3、地图小中大、创建房间selectedModemapSize
Lobby发现设备、邀请、安排槽位、开始游戏玩家数组、slotConfig、P2P 回调

这种页面内切换能保留设置数据,也避免房间创建前增加一层路由。但 showLobby 只是布尔值,无法表达 Joining、Waiting、Starting、Failed 等网络阶段;网络流程复杂后需要显式状态。

二、队伍与设备的当前模型

页面使用轻量 PlayerModel

class PlayerModel {
  name: string;
  isHost: boolean;
  deviceId: string = '';

  constructor(name: string, isHost: boolean = false) {
    this.name = name;
    this.isHost = isHost;
  }
}

三个列表分别表达本队、对方和附近设备:

@State teamAPlayers: PlayerModel[] = [
  new PlayerModel('我 (本机)', true)
];
@State teamBPlayers: PlayerModel[] = [];
@State nearbyDevices: PlayerModel[] = [];

这个模型适合 UI 原型,但不足以支撑真实多人状态。name 不是稳定身份,deviceId 只对发现列表有意义,加入队伍后的 newPlayer 没有保存 peer IP 或 ID,断线时难以定位应移除哪个槽位。

生产模型至少还需要:

type PlayerConnection =
  'invited' | 'joining' | 'connected' | 'disconnected';

interface LobbyPlayer {
  playerId: string;
  displayName: string;
  deviceId?: string;
  peerAddress?: string;
  team: 'A' | 'B';
  slotIndex: number;
  role: 'host' | 'guest' | 'ai';
  connection: PlayerConnection;
  ready: boolean;
}

这是演进方案。当前代码尚未建立完整玩家身份与准备状态。

三、模式与地图选择如何驱动 UI

模式按钮接收 '1v1' | '3v3',选中时更新颜色并修改状态。地图使用 0、1、2 的分段控件,同时维护字符串值。

.onClick(() => {
  animateTo({ duration: 200 }, () => {
    this.mapSizeValue = value;
    if (value === 0) this.mapSize = 'small';
    else if (value === 1) this.mapSize = 'medium';
    else this.mapSize = 'large';
  });
})

1v1 时每队显示 1 个槽位,3v3 时显示 3 个:

this.TeamSlots(
  this.selectedMode === '1v1' ? 1 : 3,
  this.teamAPlayers,
  '#00E5FF'
);

模式状态确实改变了大厅布局,但它还没有限制加入人数、验证队伍完整性或改变网络同步协议。所以“UI 可选 3v3”和“支持六人真实对战”是两回事。

四、创建房间后才开始发现设备 📡

点击创建房间会清空附近设备并开始扫描:

createRoom(): void {
  animateTo({ duration: 300 }, () => {
    this.showLobby = true;
    this.nearbyDevices = [];
    P2PConnectionManager.getInstance()
      .startDiscovery();
  });
}

把扫描放在进入大厅之后,能避免用户还在选择配置时持续消耗无线与定时器资源。页面离开时调用 stopDiscovery(),清除广播 interval 并停止分布式发现。

页面显示时先初始化单例 Manager,再绑定四类回调:发现设备、收到邀请、Peer 连接、房主开始游戏。这形成了一套事件驱动 UI:Manager 不依赖 ArkUI,页面将网络事件转换为列表、Dialog、Toast 与路由。

五、设备发现来自两条通道

P2PConnectionManager 同时尝试分布式设备管理和 UDP 广播:

flowchart TD
    A[startDiscovery] --> B[Distributed Device Discovery]
    A --> C[UDP 每 2 秒广播 DISCOVER]
    A --> D[2 秒后注入模拟设备]
    B --> E[onDeviceFound]
    C --> F[收到对端 DISCOVER]
    F --> E
    D --> E
    E --> G[页面去重并加入 nearbyDevices]

分布式设备在线事件被转换为统一 DiscoveredDevice;UDP 收到 DISCOVER 后生成 lan_<ip> 作为设备 ID。页面按 deviceId 去重:

manager.onDeviceFound = (device: DiscoveredDevice) => {
  const exists = this.nearbyDevices.find(
    item => item.deviceId === device.deviceId
  );
  if (exists) return;

  const player = new PlayerModel(device.deviceName);
  player.deviceId = device.deviceId;
  this.nearbyDevices.push(player);
};

同一物理设备可能同时通过分布式发现和 UDP 出现,两个通道的 ID 不同,所以按 deviceId 仍可能显示两次。需要统一身份关联,或给通道结果标注来源并合并。

六、附近列表中包含明确的模拟设备

startDiscovery() 两秒后总会调用:

setTimeout(() => {
  this.mockDeviceFound(
    'dev_001',
    'Mate 60 Pro (模拟)'
  );
}, 2000);

邀请 dev_* 时也不会真正发送网络包,而是使用模拟 IP,并在一秒后伪造 ACCEPT:

if (deviceId.startsWith('dev_')) {
  const targetIp = '192.168.1.100';
  setTimeout(() => {
    this.handleSignal({
      type: 'ACCEPT',
      name: 'Mock Player',
      ip: targetIp
    }, targetIp);
  }, 1000);
  return;
}

这对单机演示 UI 很有价值,但文章、截图和产品说明必须标注“模拟”。否则读者会以为两台真机发现和握手已经完成。

七、邀请握手是怎样完成的

真实 LAN 设备 ID 形如 lan_<ip>。房主点击连接后发送:

{
  "type": "INVITE",
  "name": "我 (本机)"
}

客端收到后弹出 AlertDialog。接受时发送 ACCEPT

acceptInvite(from: P2PInviteInfo): void {
  manager.acceptInvite(from.ip, '我 (本机)');

  this.showLobby = true;
  this.teamAPlayers = [
    new PlayerModel(from.name, true)
  ];
  this.teamBPlayers = [
    new PlayerModel('我 (本机)', false)
  ];
}

房主收到 ACCEPT 后把 peer 写入 peers Map,并触发 onPeerConnected。页面当前只在 Team B 为空时加入一个玩家:

manager.onPeerConnected = (peer: P2PPeerInfo) => {
  if (this.teamBPlayers.length === 0) {
    this.teamBPlayers.push(
      new PlayerModel(peer.name, false)
    );
  }
  // 当前源码明确保留 3v3 待处理注释
};

因此握手链路主要覆盖 1v1。第二、第三名玩家、断线重连、队伍切换和房主迁移都没有闭环。

八、槽位状态:等待、AI、关闭

空槽位点击时在三个值间循环:

@State slotConfig: Map<string, string> = new Map();

toggleSlotState(
  index: number,
  _count: number,
  isTeamA: boolean
): void {
  const team = isTeamA ? 'A' : 'B';
  const key = `${team}_${index}`;
  let current = this.slotConfig.get(key) || 'open';

  if (current === 'open') current = 'ai';
  else if (current === 'ai') current = 'closed';
  else current = 'open';

  this.slotConfig.set(key, current);
}
UI 含义GameEngine 当前消费
open等待真人加入没有完整真人槽位绑定
ai电脑补位Engine 会按 A_/B_ key 生成 AI
closed关闭该槽位不生成 AI

页面还保留 teamASlotStatesteamBSlotStates 两个数组和 getSlotState(),但实际显示主要读取 slotConfig Map。这是原型迭代留下的重复模型,应选一个权威来源。

Map 原地 set() 后使用 this.mapSizeValue = this.mapSizeValue 尝试强制刷新,不够可靠。更合适的是克隆 Map 后重新赋值。

九、房主与访客权限目前没有真正隔离 ⚠️

客端接受邀请后进入同一个 LobbyView。源码注释已经提出“Guest shouldn't change settings”,但没有实现房主权限判断。开始按钮、槽位按钮和返回设置仍可能对访客可见。

多人大厅至少要区分:

操作房主访客
选择模式/地图允许只读
修改 AI/关闭槽位允许不允许
邀请附近设备允许通常不允许
切换自己队伍/准备可按规则允许
开始游戏允许且需校验不允许
离开房间解散或迁移房主退出自身

权限不能只隐藏按钮,网络信令接收端也必须验证 sender 是否为房主,否则访客可以自行构造 START_GAME。

十、START_GAME 配置如何广播

房主把 Map 转为普通对象并字符串化:

const configObject: Record<string, string> = {};
this.slotConfig.forEach((value, key) => {
  configObject[key] = value;
});

manager.broadcastGameStart({
  mapSize: this.mapSize,
  teamMode: this.selectedMode,
  slotConfig: JSON.stringify(configObject)
});

Manager 为每个已连接 peer 发送 UDP JSON:

{
  "type": "START_GAME",
  "config": {
    "mapSize": "medium",
    "teamMode": "3v3",
    "slotConfig": "{\"A_1\":\"ai\"}"
  }
}

客端收到后回调页面并 pushUrl 到 Index。房主也执行同样路由。这个协议表达了“开始意图”,但没有房间 ID、配置版本、随机种子、开始时间和确认 ACK。

十一、UDP 信令的可靠性与安全边界

当前原型使用固定 8888 端口和 JSON 明文。UDP 不保证送达、顺序或唯一性,START_GAME 丢包时可能房主已进入、客端仍留在大厅。重复包则可能重复导航。

生产设计至少需要:

  • messageId:去重;
  • roomId:隔离同网段不同房间;
  • senderId:验证消息来源;
  • sequence:判断新旧配置;
  • ACK + retry:关键命令确认;
  • protocolVersion:兼容升级;
  • 身份验证或会话签名:阻止任意局域网设备伪造信令;
  • 长度与字段白名单:避免恶意大包和非法 JSON。

当前 handleSignal(START_GAME) 会直接把发送者加入 peers 并触发回调,没有确认其此前是否通过邀请成为房主。它适合可信局域网原型,不适合作为公网或正式竞技协议。

十二、开局参数当前没有在 Index 形成闭环

CustomTeamPage 会发送并路由 gameMode='multiplayer'、地图和槽位配置,但 Index 的 onPageShow() 中消费多人参数的代码被注释,标注为离线版本移除。

GameEngine 确实能解析多人配置并为 A_*B_*ai 槽生成不同队伍 AI,但页面路由到 startGame('multiplayer', ..., config) 的接线当前没有执行。

因此准确结论是:

  • 大厅 UI、设备发现接口、邀请信令和开局配置格式已经存在;
  • 模拟设备可以演示 1v1 加入效果;
  • 3v3 真人槽位尚未处理;
  • 路由接收端当前不会自动启动完整多人局;
  • UDP 状态同步属于原型,不能宣称正式联机已完成。

十三、页面离开时的清理还不完整

aboutToDisappear() 调用 stopDiscovery(),能清除广播 interval 和设备发现监听。但页面赋给单例 Manager 的以下回调仍保留:

onDeviceFound
onReceiveInvite
onGameStart
onPeerConnected

如果页面实例离开后单例收到 UDP 消息,回调仍可能修改旧页面状态或执行路由。应在离开时仅当回调仍属于当前页面时置空,或由 Manager 返回 unsubscribe 函数。

UDP socket 本身也没有在页面离开时关闭,因为它属于全局单例。是否保持监听取决于产品:如果后台也要接收邀请,应由 Ability 生命周期管理;如果只在大厅有效,应提供 disposeSocket()

十四、推荐的大厅状态机

stateDiagram-v2
    [*] --> Configuring
    Configuring --> Discovering: 房主创建房间
    Configuring --> Joining: 访客接受邀请
    Discovering --> Lobby: 扫描启动
    Joining --> Lobby: 握手确认
    Lobby --> Starting: 房主点击开始
    Starting --> Lobby: 有设备未确认/发送失败
    Starting --> InGame: 所有设备确认同一配置
    Lobby --> Closed: 房主解散
    Lobby --> Left: 访客离开

主状态之外再保存 LobbySnapshot:房间 ID、版本、房主、玩家、槽位、地图和模式。每次变更由房主增加 revision 并广播完整快照,客端只接受更新版本,能比零散地修改数组更容易恢复一致。

十五、测试矩阵 🧪

场景预期
重复发现同一 deviceId附近列表只出现一次
分布式与 LAN 发现同设备通过稳定身份合并
模拟设备出现UI 明确标“模拟”
1v1 接受邀请房主和访客占据正确队伍
第二名 peer 加入 1v1拒绝或进入观察,不静默丢失
3v3 三名访客加入每人有稳定 slotIndex
访客点击开始UI 和协议端都拒绝
START_GAME 丢包重试并等待 ACK
START_GAME 重复只导航一次
非房主伪造 START_GAME消息被拒绝
页面离开后收到消息不再修改旧组件
非法 slotConfig JSON保留大厅并提示配置错误
Index 参数消费关闭测试明确标记端到端未完成

十六、总结 ✨

项目已经搭建了一条有价值的自定义大厅原型:ArkUI 中选择模式与地图,进入大厅后发现设备,UDP 发送邀请和接受信令,槽位可切换等待、AI、关闭,房主广播统一开局配置,客端根据回调导航。

但“原型可演示”和“多人功能完成”必须严格区分。当前附近列表主动注入模拟设备,Peer 加入只完整处理 1v1,3v3 真人槽位留有待处理注释,访客权限没有隔离,UDP 没有 ACK、身份验证和去重,Index 的开局参数消费也处于注释状态。下一步应先建立权威 LobbySnapshot、房主权限、稳定玩家身份和可靠信令,再连接战斗会话。只有配置、网络、页面和引擎四端都消费同一份状态,大厅才真正成为多人对战的入口。🚀


推荐标签: OpenHarmony HarmonyOS ArkTS ArkUI 局域网 UDP 多人游戏 状态同步 P2P

img

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐