跳到主要内容

玩家房屋 —— 秘密基地与道馆

MCE 的房屋功能是一个可建造的个人室内:玩家拥有一个秘密基地(以及作为同一引擎变体的自定义道馆),他们将其放置在世界中、进入并在图块网格上装饰。整个建造与装饰循环100% 离线运作,并持久化在本地存档中;在线层随后在其上增加两个服务器权威的多人层 —— 一个快照层(在好友离线时访问其基地)和一个实时同场共存层(实时地一起在基地内走动)。

服务器在此存储的一切都是服务器权威且失败即拒绝的:Nakama 后端会解析每一次装饰放置和每一只道馆馆主怪物,并拒绝任何越界、重叠、超上限或伪造的内容 —— 客户端永远无法写入一个它并非合法建造的基地或道馆。这已通过对抗性渗透测试验证。

两个系统,一个引擎

秘密基地(SecretBase* 类型)和我的道馆(Gym* 类型)共享同一个室内/装饰引擎。道馆在基地已装饰房间的基础上增加了馆主队伍、自定义徽章和对话键。本页先记录基地,然后是道馆特有的补充。

第 1 层 —— 离线秘密基地(Core,无需服务器)​

离线基地完全存在于 Assets/OpenMon/Core/Runtime/World/SecretBase/,无需在线连接。

类文件职责
SecretBaseManagerWorld/SecretBase/SecretBaseManager.cs放置/移除基地、添加/移除/移动装饰、所有权查询
SecretBaseDataWorld/SecretBase/SecretBaseData.cs可序列化的基地状态(锚点 + 装饰列表),保存在 GlobalGameData 中
DecorCatalogWorld/SecretBase/DecorCatalog.csDecorEntry(id、预制体、图标、占位)的 ScriptableObject 注册表
SecretBaseInteriorLoaderWorld/SecretBase/SecretBaseInteriorLoader.cs将保存的装饰变成可见的室内场景
SecretBaseInteriorSessionWorld/SecretBase/SecretBaseInteriorSession.cs静态的"我此刻是否在我的室内"会话指针
SecretBaseDecorateControllerWorld/SecretBase/SecretBaseDecorateController.cs打开室内装饰 UI

放置并进入一个基地​

基地被放置在一个世界单元格(通常来自 PlaceSecretBase 战斗外道具效果),它落下保留的大地图锚点:

var manager = new SecretBaseManager(globalGameData, gridController);
PlacementResult result = manager.TryPlaceBase(cell, new SecretBaseData());

玩家通过暂停菜单(GameMenuScreen)中的 "Mi Base"(我的基地) 条目进入。在室内之外,它将玩家传送进一个实例化的室内场景(SecretBaseInteriorSession);在室内时,同一个条目改为打开装饰控制器。

装饰​

装饰放置在室内图块网格上(默认室内大小为 SecretBaseManager.DefaultInteriorSize = 8×8):

manager.AddDecor(decorId: "bookshelf_oak", tile: new Vector3Int(2, 3, 0), rotation: 1);
manager.MoveDecor(from: new Vector3Int(2, 3, 0), to: new Vector3Int(4, 3, 0));
manager.RemoveDecorAt(new Vector3Int(4, 3, 0));

rotation 是一个 0..3 的四分之一转步长(加载器应用 rotation * 90°)。管理器在每次变更后触发 OnDecorChanged,使实时视图和在线同场共存层能够作出反应。

持久化​

基地是常规存档的一部分 —— 无单独文件,无需服务器:

  • GlobalGameData.SecretBase(SecretBaseData)持有已放置的基地及其装饰。
  • SavableGlobalGameData.SecretBase 将其带入 JSON 存档(对旧存档为 null 容忍)。

第 2 层 —— 快照多人(F3-A,访问离线好友)​

在线秘密基地方法存在于 HousingService (Assets/OpenMon/Online/Runtime/Housing/HousingService.cs),经由以下方式访问:

var housing = NakamaManager.Instance.Housing;

它们通过服务器模块 Assets/OpenMon/Online/Server~/nakama/src/world/secretbase.ts 使用四个 secret_base_* RPC 进行往返。基地室内存储在服务器上,因此即便拥有者离线,另一名玩家也能访问它(一个静态快照)。

RPC 与存储​

RPC(RpcIds)用途
secret_base_save拥有者上传其自己的基地快照(失败即拒绝校验)
secret_base_get按 ownerId 读取另一名玩家的基地(拥有者可离线)
secret_base_list公开浏览基础(轻量卡片)
secret_base_invite推送一个访问邀请(通知代码 300)

存储使用集合 secret_bases、键 base,位于拥有者的 userId 之下 —— 每名用户一个基地;请求永远无法选择键(始终为 ctx.userId)。权限为 permissionRead:2(公开 —— 访客读取另一名玩家的基地)和 permissionWrite:0(仅服务器 —— 客户端无法绕过 RPC 直接写入)。存储的值是 { snapshot, savedAt }。

保存你的基地​

SaveBaseAsync 通过 SecretBaseWireMapper 将 Core 的 SecretBaseData 映射为扁平的线路快照(剥离保留的锚点),然后运行与云存档相同的乐观并发(OCC)阶梯:

bool ok = await housing.SaveBaseAsync(secretBaseData);

线路载荷是一个 SecretBaseSaveRequest:

[Serializable]
public class SecretBaseSaveRequest
{
public string baseVersion; // last-known OCC version ("" on first save)
public SecretBaseSnapshotWire snapshot; // { gridWidth, gridHeight, decor[] }
}

[Serializable]
public class SecretBaseDecorWire
{
public string decorId;
public int tileX, tileY, tileZ;
public int footprintW, footprintH;
public int rotation; // 0..3 quarter-turn step
public string customData;
}

成功时服务器返回 { success:true, conflict:false, version },新版本被缓存到 HousingService.LastKnownVersion。在 OCC 冲突时它返回 { success:false, conflict:true, serverVersion, serverData:{ snapshot } } —— 服务将权威快照呈现在 LastConflictSnapshot 中并返回 false。最后写入者永远不会无声获胜;调用方必须合并并重试。

访问一个基地​

SecretBaseData theirBase = await housing.VisitBaseAsync(ownerId);
// Feed a non-null result into SecretBaseInteriorLoader.VisitOverride with ReadOnlyVisit = true.

await housing.LeaveBaseAsync();

VisitBaseAsync 调用 secret_base_get。如果拥有者没有基地(或获取失败),它返回 null —— 它永不捏造房间。被访问的基地通过 SecretBaseInteriorLoader.VisitOverride 以只读方式渲染。

邀请与浏览​

bool sent = await housing.InviteToBaseAsync(targetUserId, "Come visit my base!");
SecretBaseListResponse page = await housing.ListBasesAsync(cursor);

邀请作为 Nakama 通知(代码 300)投递,并与好友图配对。ListBasesAsync 是一个附加的公开浏览基础;发布版的用户体验仍保持邀请/好友。

失败即拒绝校验(服务器端)​

在任何写入之前,secretbase.ts 中的 validateSnapshot / validateDecorPlacement 会在任何一项失败时拒绝整次保存(零写入):

  • 网格尺寸必须是 [1, 32] 范围内的整数;装饰数量 <= 128。
  • 每个 decorId 是一个非空字符串、<= 64 字符,且绝不是保留的锚点 __mce_secret_base_anchor__。
  • 图块/占位/旋转必须是整数;tileZ 在 [-64, 64] 内;占位在 [1, 32] 内;旋转 0..3。
  • 每次放置都进行边界检查,并针对所有先前接受的放置进行 AABB 重叠检查(在奇数旋转步长时,有效占位交换 W/H)。
  • 序列化 blob 上限为 1 MiB。

第 3 层 —— 实时同场共存(F3-B,一起走动)​

BaseSyncService (Assets/OpenMon/Online/Runtime/Housing/BaseSyncService.cs),经由 NakamaManager.Instance.BaseSync 访问,在 F3-A 快照之上为室内增加了实时在场。它是 WorldSyncService 的一个有界克隆,指向一个按名称 base:<ownerId> 创建的中继 Nakama 匹配 —— 零新服务器面(现有的 beforeMatchDataSend 守卫自动适用)。

  • 主机是基地拥有者在其自己的室内;访客是加入者且严格只读。
  • 位置通过 OP_POSITION(op 代码 1,与世界同步相同的 schema)广播;map 字段是匹配名称,因此现有的客户端 AOI 过滤器对室内进行分区。
  • 主机通过新的 op 代码 OP_DECOR_CHANGED(5)重新发布装饰编辑,携带一个 SecretBaseSnapshotWire 增量。调用 PublishDecorChanged 的访客会被拒绝 —— 访客的客户端永远无法驱动一次服务器广播。
// Host (owner) entering their own interior:
await NakamaManager.Instance.BaseSync.EnterBaseAsync(myUserId, asHost: true);

// Visitors are wired automatically: BaseSync.BindHousing(Housing) makes
// HousingService.VisitBaseAsync/OnVisitorEntered join base:<ownerId> as a visitor.

远程化身逐字复用 PlayerNetworkEntity / PlayerSpawnManager —— 该服务触发与世界同步相同的 OnPlayerEnterAOI / OnPlayerExitAOI / OnPlayerPositionUpdate 事件。离线时(无 socket)每个方法都是一个安全的空操作:基地仍在本地渲染,只是你看不到实时化身。

我的道馆 —— 服务器权威的挑战快照(F2b)​

道馆是同一室内引擎的道馆变体:玩家构建一个完全自定义的道馆(一支从自己队伍中抽取的馆主队伍、一个装饰过的房间、一个自定义徽章,以及开场/胜利对话),而另一名玩家可以在拥有者离线时挑战它。挑战战斗从获取的快照在客户端运行 —— 此切片是存储 + 失败即拒绝校验 + 徽章授予完整性,而非一个服务器战斗引擎。

客户端(Core,离线)​

类文件职责
GymManagerWorld/Gym/GymManager.cs建立/命名道馆、设置徽章与对话、挑选馆主槽位、装饰房间
PlayerGymDataWorld/Gym/PlayerGymData.cs可序列化的道馆状态(房间、馆主槽位、徽章、对话)
GymInteriorLoader / GymInteriorSessionWorld/Gym/实例化的道馆室内(复用基地室内的动态出口)
var gym = new GymManager(globalGameData);
gym.EstablishGym("Boulder Gym");
gym.SetBadge("badge_boulder", "Boulder Badge");
gym.SetDialogs(introDialogKey, victoryDialogKey);
gym.SetLeaderSlots(new[] { 0, 1, 2 }, rosterSize);
gym.AddDecor("trophy_case", new Vector3Int(1, 1, 0), rotation: 0);

道馆复用 "Mi Gimnasio"(我的道馆) 暂停菜单条目,镜像 "Mi Base" 流程,并持久化在 GlobalGameData.PlayerGym / SavableGlobalGameData.PlayerGym 中。 GymManager.CanChallenge(ownerId, challengerId) 门控谁可以挑战(你不能挑战自己的道馆)。

在线(GymOnlineService)​

GymOnlineService (Assets/OpenMon/Online/Runtime/Housing/GymOnlineService.cs),经由 NakamaManager.Instance.Gym 访问,通过 Assets/OpenMon/Online/Server~/nakama/src/world/playergym.ts 在一个专用 集合 player_gyms(键 gym,permissionRead:2 / permissionWrite:0)上进行往返 —— 不并入 secret_bases,因为道馆有不同的生命周期和形状。

RPC(RpcIds)用途
gym_save拥有者上传其自己的道馆快照(房间 + 馆主队伍 + 徽章,失败即拒绝校验)
gym_get获取另一名玩家的道馆以挑战它(拥有者可离线)
gym_list公开浏览基础(拥有者/名称/徽章/馆主数量)
// Save (leaderRoster is the frozen team, built from the owner's roster upstream):
bool ok = await gym.SaveGymAsync(playerGymData, leaderRoster);

// Challenge (fetch the frozen snapshot, then run the battle client-side):
GymSnapshotWire theirGym = await gym.ChallengeGymAsync(ownerId);

GymListResponse page = await gym.ListGymsAsync(cursor);

道馆线路快照携带馆主队伍外加道馆元数据,并逐字复用基地房间线路:

[Serializable]
public class GymSnapshotWire
{
public MonsterSnapshot[] leaderRoster; // 1..6, re-validated by the anti-cheat seam
public string badgeId; // non-empty; integrity-checked server-side
public string badgeName;
public string gymName;
public string introDialogKey;
public string victoryDialogKey;
public SecretBaseSnapshotWire room; // the SAME decorated-room wire a base uses
}

失败即拒绝校验(服务器端)​

playergym.ts 中的 validateGymSnapshot 逐字复用秘密基地的装饰规则集来校验房间,并增加道馆特有的完整性检查 —— 全部失败即拒绝(任何单项失败 ⇒ 零写入):

  • 房间 —— 由秘密基地所用的同一个 validateSnapshot 校验(无 AABB 重复实现)。
  • 馆主队伍 —— 一个 1..6 只怪物的数组,每只都通过与战斗所用相同的反作弊接缝重新校验(validateMonsterSnapshot —— 能力值/HP/招式上限;对不可能的数值抛出异常 → 保存被拒绝)。挑战者永远无法面对一支伪造的超强队伍。
  • badgeId —— 非空且有界(<= 128);挑战者击败后赢取的徽章必须不可伪造或跨用户写入。
  • 自由文本(badgeName / gymName / 对话键) —— 有界,默认 ""。
  • 序列化 blob 上限为 1 MiB。OCC(gymVersion)镜像秘密基地/云存档。

遗留房屋/家具 API​

HousingService 仍暴露旧的模板房屋/家具方法 (PurchaseHouseAsync、PlaceFurnitureAsync、RemoveFurnitureAsync、 VisitHouseAsync、SaveHouseAsync、SetVisitorsAllowedAsync)。这些是诚实的 NOT_IMPLEMENTED 存根 —— 它们记录一条警告并返回 null/false,不捏造任何状态。发布版的房屋功能是上文记录的秘密基地/道馆引擎;遗留的模板房屋模型从未接到服务器契约。Housing.InitializeAsync 同样是一个空操作,仅当 OnlineConfig.EnableHousing 启用时才运行。