# 08 · 世界系统 > **命名空间** `BaseGames.World` > **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md) > **依赖** `BaseGames.Core.Events` · `BaseGames.Player` · PathBerserker2d(NavSurface) --- ## 目录 1. [系统总览](#1-系统总览) 2. [场景结构规范](#2-场景结构规范) 3. [房间过渡系统](#3-房间过渡系统) 4. [存档系统](#4-存档系统) 5. [SaveData JSON Schema](#5-savedata-json-schema) 6. [危险区域](#6-危险区域) 7. [可收集物](#7-可收集物) 8. [能力解锁物件](#8-能力解锁物件) 9. [可交互环境](#9-可交互环境) 10. [地图系统(P1)](#10-地图系统p1) 11. [商店 NPC](#11-商店-npc) 12. [死亡遗骸(DeathShade)](#12-死亡遗骸deathshade) 13. [世界事件频道](#13-世界事件频道) 14. [编辑器友好设计](#14-编辑器友好设计) 15. [Tilemap 与物理材质配置](#15-tilemap-与物理材质配置) --- ## 1. 系统总览 ``` 世界系统职责: ├─ 场景(房间)结构规范 → 所有关卡按统一层级组织 ├─ 房间过渡(门/传送) → RoomTransition 组件 ├─ 存档/读档(JSON 持久化) → SaveManager ├─ 危险区域(即死/恢复) → HazardZone ├─ 可收集物(Geo/物品) → Collectible ├─ 能力解锁物件 → AbilityUnlock ├─ 地图系统(P1) → MapManager / RoomReveal └─ 商店 NPC(P1) → ShopNPC ``` **零耦合原则**:世界物件通过 SO 事件频道通知玩家系统,不直接持有 `PlayerController` 引用。 --- ## 2. 场景结构规范 ### 场景文件命名 ``` Room_{Region}_{Index} 示例: Room_Forest_01, Room_Cave_03 Boss_{Region} 示例: Boss_Forest, Boss_Ruins Hub_Town 示例: 休息营地/镇子 Persistent 常驻场景(GameManager, AudioManager, InputReader 等) ``` ### 标准房间场景层级结构 ``` Scene: Room_Forest_01 │ ├── [Persistent] ← 多场景共享,使用 Additive Load │ └─ 见 Persistent 场景说明 │ ├── [Level] │ ├── Tilemap_Ground Layer: Ground │ ├── Tilemap_Background Layer: Background(非碰撞) │ ├── Tilemap_Foreground Layer: Foreground(非碰撞) │ ├── Tilemap_OneWay Layer: OneWayPlatform │ ├── Tilemap_Destructible Layer: Ground(Intact 态同 Ground;Destroyed 后禁用 Renderer) │ ├── Tilemap_SoftGround Layer: Ground(地行术松软地面;挂载 SoftTerrain Marker 组件) │ └── Tilemap_MagicWall Layer: MagicWall(太虚斩/地行术 Ghost 层可穿越) │ ├── [NavMesh] │ ├── NavSurface ← PathBerserker2d 导航面(提前烘焙) │ ├── NavLink_01 ← 平台跳跃链接 │ └── NavLink_02 │ ├── [Enemies] │ ├── Enemy_GruntWarrior_01 (Prefab实例) │ └── Enemy_SkullArcher_01 (Prefab实例) │ ├── [Interactables] │ ├── SavePoint_01 ← 存档点 │ ├── Door_To_Forest_02 ← 房间出口 │ ├── Collectible_Geo_01 ← 掉落物 │ ├── MovingPlatform_01 ← 移动平台(见 §9.3) │ ├── DestructibleWall_01 ← 可破坏地形(见 §9.4) │ ├── Switch_01 ← 单向可交互机关(见 §9.6) │ ├── CrumblePlatform_01 ← 碎裂平台(见 §9.7) │ ├── FalseWall_01 ← 假墙/秘密通道(见 §9.8) │ ├── MagicWall_01 ← 魔法障壁(见 §9.10,Layer: MagicWall) │ └── PhantomPlate_01 ← 幻影机关(见 §9.11,残阴术灵体可触发) │ ├── [Hazards] │ └── HazardZone_Spikes_01 │ ├── [CameraBounds] │ ├── PolygonCollider2D ← 镜头约束 │ └── RoomCameraBounds.cs │ └── [Audio] └── AudioZone_Forest ← 环境音区域(P1) ``` ### Persistent 常驻场景 ``` Scene: Persistent (Additive,始终加载) ├── [Managers] │ ├── GameManager.cs ← 游戏状态(MainMenu/Gameplay/Pause/GameOver) │ ├── SaveManager.cs ← 存档管理 │ └── GlobalFeedbackController.cs │ ├── [Input] │ └── InputReaderSO 实例 ← 事实上是 SO 资产,在 Managers 中注册事件 │ ├── [Camera] │ └── CameraRig (Prefab) ← 见 02_CameraSystem │ └── [UI] └── HUD_Canvas ``` --- ## 3. 房间过渡系统 ### RoomTransition 组件 `RoomTransition` 挂在房间的"门"或"出口触发器"上: ``` [Door_To_Forest_02] ├── BoxCollider2D (IsTrigger) └── RoomTransition.cs ├── _targetScene: string ← 目标场景名 ├── _spawnPointId: string ← 目标场景的玩家出生点 ID ├── _transitionType: TransitionType ← Door / Fall / Teleport └── _onRoomEntered: TransformEventChannelSO ← 过渡完成后发布 ``` ### 过渡流程(LoadScene 事件频道) ``` 玩家进入 RoomTransition 触发器 │ ▼ RoomTransition → 发布 LoadSceneEvent(targetScene, spawnPointId) │ ▼ GameManager.OnLoadSceneRequested(LoadSceneEvent) │ ├─ 1. 触发过渡动画(遮罩淡入) ├─ 2. AsyncOperation: LoadSceneAsync(targetScene, Additive) ├─ 3. 卸载旧场景: UnloadSceneAsync(currentScene) ├─ 4. 在新场景中找到 SpawnPoint(spawnPointId) ├─ 5. 移动玩家到 SpawnPoint 位置 ├─ 6. 触发 OnRoomEntered 事件频道(新房间 Transform) └─ 7. 过渡动画淡出 ``` ### SpawnPoint 组件 ``` [SpawnPoint_Entry_From_Forest_01] ├── SpawnPoint.cs │ └── _id: string ← 与 RoomTransition._spawnPointId 对应 └── (Scene 视图中绿色旗帜 Gizmo) ``` --- ## 4. 存档系统 ### SaveManager 职责 - 在玩家触发存档点时,序列化 `SaveData` 并写入本地 JSON - 游戏启动时自动读取并反序列化 `SaveData` - 多存档槽支持(最多 3 个) ### 存档触发流程 ``` 玩家与 SavePoint 交互 │ ▼ SavePoint.OnInteract() │ ├─ 播放存档动画(发光/点亮) ├─ PlayerStats.FullHeal()(存档点满血) ├─ 发布 OnSavePointActivated 事件频道 └─ SaveManager.Save() ``` ### 读档时机 - 玩家死亡后:自动读取最近存档,恢复 `SaveData` 并加载存档点所在场景 - 返回主菜单后继续游戏:加载 `SaveData.LastSaveSceneName` --- ## 5. SaveData JSON Schema ```json { "version": "1.0", "slotIndex": 0, "lastSaveTimestamp": "2024-01-01T00:00:00Z", "lastSaveScene": "Room_Forest_01", "lastSavePointId": "SP_Forest_01_Entry", "player": { "maxHP": 5, "currentHP": 5, "maxSoul": 99, "currentGeo": 340, "position": { "x": 12.5, "y": -3.0 } }, "abilities": { "doubleJump": true, "wallGrab": false, "aerialDash": false, "swim": false, "parry": true }, "items": { "healthContainersCollected": ["HC_Forest_01", "HC_Cave_02"], "keyItems": ["Key_RuinsGate"] }, "world": { "discoveredRooms": ["Room_Forest_01", "Room_Forest_02", "Room_Cave_01"], "clearedRooms": ["Room_Forest_01"], "collectiblesPickedUp": ["Geo_Forest_01_Cluster", "HealthContainer_Cave_01"], "defeatedBosses": [], "openedDoors": ["Door_Forest_To_Cave"], "activatedSavePoints": ["SP_Forest_01_Entry", "SP_Forest_02_Main"], "destroyedTerrain": ["DT_Forest_01_WallA", "DT_Cave_02_FloorB"], "triggeredMechanisms": ["Switch_Forest_01", "Lever_Cave_03"] }, "settings": { "sfxVolume": 1.0, "musicVolume": 0.8, "hapticsEnabled": true } } ``` ### 持久化路径 | 平台 | 路径 | |------|------| | Windows | `%APPDATA%/Zeling/Saves/Slot{n}.json` | | macOS | `~/Library/Application Support/Zeling/Saves/Slot{n}.json` | | Android/iOS | `Application.persistentDataPath/Saves/Slot{n}.json` | ### 安全考量 - JSON 文件写入使用**原子写入**:先写临时文件,完成后 rename,防止写入中断导致存档损坏 - 不使用 `PlayerPrefs` 存储敏感游戏数据 --- ## 6. 危险区域 `HazardZone` 处理即死/持续伤害区域: ``` [HazardZone_Spikes] ├── BoxCollider2D (IsTrigger) └── HazardZone.cs ├── _hazardType: HazardType ← InstantKill / DamagePerSecond ├── _damageAmount: int ← DamagePerSecond 时的每秒伤害 ├── _respawnType: RespawnType ← AtLastSavePoint / AtRoomEntry └── (生成特殊 DamageInfo,DamageType = True,IgnoreIFrame 标记) ``` | HazardType | 行为 | |-----------|------| | `InstantKill` | 接触即触发 `OnPlayerDied`(不经过 HP 扣减,真实伤害 = MaxHP)| | `DamagePerSecond` | 每秒生成 DamageInfo(通过 HurtBox 正常流程处理)| --- ## 7. 可收集物 `Collectible` 处理场景中可拾取的物件: ``` [Collectible_Geo_Cluster] ├── SpriteRenderer(旋转动画) ├── CircleCollider2D (IsTrigger) └── Collectible.cs ├── _collectibleType: CollectibleType ← Geo / SmallGeo / HealthOrb / SoulOrb ├── _value: int ← 数值 ├── _collectibleId: string ← 唯一ID,用于 SaveData 记录 └── _onCollected: VoidEventChannelSO ← 触发后发布 ``` ### Collectible 类型 | 类型 | 效果 | 说明 | |------|------|------| | `Geo` | 增加 `value` 货币 | 固定位置掉落(Scene 中放置)| | `SmallGeo` | 增加少量货币(1~3)| 敌人死亡时随机生成(ObjectPool)| | `HealthOrb` | 恢复 1 HP | 大型敌人/Boss 死亡掉落 | | `SoulOrb` | 恢复 33 Soul | 特定地点放置 | --- ## 8. 能力解锁物件 `AbilityUnlock` 是隐藏在关卡中的特殊物件,玩家交互后永久解锁能力: ``` [AbilityUnlock_DoubleJump] ├── SpriteRenderer(特殊图标/动画) ├── BoxCollider2D (IsTrigger) └── AbilityUnlock.cs ├── _abilityType: AbilityType ← DoubleJump / WallJump / etc. ├── _unlockId: string ← 唯一ID,存入 SaveData ├── _acquisitionFeedback: MMF_Player ← 获取演出(特效/音效) └── _onAbilityUnlocked: ...EventChannel ← 发布解锁事件 ``` **解锁流程**: ``` 玩家接触 AbilityUnlock 触发器 → AbilityUnlock.OnTriggerEnter2D → PlayerStats.UnlockAbility(abilityType) → SaveManager.MarkCollected(unlockId) → 播放 acquisitionFeedback(演出:画面特写、动画、音乐节拍) → GameObject 设为 inactive(存档后不再出现) ``` --- ## 9. 可交互环境 > **命名空间** `BaseGames.World.Interactables` ### 9.1 类型总览 | 类型 | 组件 | 持久化 | 地图联动(P1)| |------|------|--------|-------------| | 单向平台 | `PlatformEffector2D`(Unity 内置)| 否 | 否 | | 移动平台 | `MovingPlatform` | 否(重进房间重置)| 否 | | 可破坏地形 | `DestructibleTile` | ✓(`isPermanent = true` 时)| ✓ | | 单向可破坏墙体 | `DirectionalDestructible` | ✓(同上)| ✓ | | 单向可交互机关 | `DirectionalInteractable` | ✓(`isOneShot = true` 时)| ✓(机关态)| | 碎裂平台 | `CrumblePlatform` | 否(重进房间重置)| 否 | | 假墙 / 秘密通道 | `FalseWall` | ✓(揭示后写入 SaveData)| ✓(显示为通道)| | **魔法障壁** | `MagicWall` | 否(障壁本身不销毁)| 否 | | **松软地面** | `SoftTerrain`(Marker 组件)| 否 | 否 | | **幻影机关** | `PhantomInteractable` | ✓(`isOneShot = true` 时)| ✓(同 DirectionalInteractable)| **零耦合原则**:所有可交互环境物件通过 SO 事件频道通知其他系统,不直接持有 `PlayerController` / `SaveManager` 引用。 --- ### 9.2 单向平台 (OneWayPlatform) 单向平台使用 Unity 内置 `PlatformEffector2D` 实现,置于独立的 `Tilemap_OneWay` 层(已在场景层级规范中定义)。 **组件配置**: ``` Tilemap_OneWay └── TilemapCollider2D + CompositeCollider2D + PlatformEffector2D ├── useOneWay = true ├── surfaceArc = 170° ← 允许从侧面和底部穿透 └── rotationalOffset = 0 ← 上表面为有效碰撞面 ``` **下穿逻辑**(在 `PlayerMovement` 侧处理,平台自身无额外代码): 按下 ↓ + Jump(处于 InputBuffer 窗口内)→ `PlayerMovement` 临时调用 `Physics2D.IgnoreLayerCollision(Player, OneWayPlatform, true)`,持续 0.2s 后恢复。 **NavLink 集成**:PathBerserker2d 对 `OneWayPlatform` 层 Tilemap 自动生成 `DropDown` 类型 NavLink;`BD_DropDown` Action Task 驱动敌人下穿。 --- ### 9.3 移动平台 (MovingPlatform) ``` [MovingPlatform_AB_01] ├── SpriteRenderer ├── Rigidbody2D (Kinematic) ├── CompositeCollider2D(平台碰撞体) ├── BoxCollider2D (IsTrigger,乘客检测,覆盖顶面上方一像素) └── MovingPlatform.cs ├── _moveType: MoveType ← LinearAB / WayPoints / TriggeredLinear ├── _wayPoints: Transform[] ← 路径点列表(LinearAB 仅用 [0][1]) ├── _speed: float ← 移动速度(u/s,默认 3.0) ├── _waitAtEndpoint: float ← 端点停留时间(默认 0.5s) └── _activationChannel: VoidEventChannelSO ← TriggeredLinear 模式监听此频道 ``` **乘客处理**(Passenger Pattern): - 乘客检测器(IsTrigger)检测到 `Player` 或 `Enemy` 层时,将乘客 `transform.SetParent(this.transform)` 跟随平台位移 - 平台停止/到达终点时解除父子关系,乘客速度继承平台当前速度 **MoveType 说明**: | 类型 | 行为 | |------|------| | `LinearAB` | `_wayPoints[0]` ↔ `_wayPoints[1]` 往返循环 | | `WayPoints` | 按 `_wayPoints[]` 顺序环形循环 | | `TriggeredLinear` | 收到 `_activationChannel` 信号后单程 [0]→[1],到达后停止 | **NavSurface 联动**:每个移动平台挂载独立 `LocalNavSurface`(局部坐标系烘焙),附着其上的敌人 NavAgent 使用该局部 NavSurface 进行寻路。 --- ### 9.4 可破坏地形 (DestructibleTile) ``` [DestructibleWall_Forest_01] ├── SpriteRenderer(Sprite 图集:Intact / Cracked / Destroyed 三帧) ├── BoxCollider2D(Intact 时启用;Destroyed 后禁用) ├── HurtBox.cs(接受伤害,将 DamageInfo 转发至 DestructibleTile) └── DestructibleTile.cs ├── _destructibleId: string ← 全局唯一 ID(如 "DT_Forest_01_WallA") ├── _destroyTrigger: DestroyTrigger ← AnyAttack / SpecificAbility / Interact ├── _requiredAbility: AbilityType ← SpecificAbility 模式所需能力 ├── _isPermanent: bool ← true = 破坏后写入 SaveData,持久化 ├── _respawnDelay: float ← 0 = 永久消失;> 0 = N 秒后复原 ├── _onDestroyed: StringEventChannelSO ← 广播 _destructibleId ├── _onRespawned: StringEventChannelSO ← 广播 _destructibleId(仅 respawn 型) └── _destroyFeedback: MMF_Player ← 碎裂粒子 + 音效 ``` **破坏判定流程**: ``` HurtBox 收到 DamageInfo → DestructibleTile.CheckDestroyCondition(info) ├─ 检查 _destroyTrigger(攻击类型 / 玩家携带能力 / 交互键) ├─ [不满足] → 播放受击晃动,无破坏效果 └─ [满足] → 播放碎裂帧序列(Cracked → Destroyed) → 禁用 BoxCollider2D + SpriteRenderer → 发布 OnDestructibleDestroyed(_destructibleId) ├─ SaveManager:若 _isPermanent,追加到 destroyedTerrain[] └─ MapManager(P1):刷新地图显示 → 若 _respawnDelay > 0:启动 Coroutine, 倒计时后恢复 Intact 并发布 OnDestructibleRespawned ``` **房间加载时恢复永久破坏状态**: ``` DestructibleTile.Start() → 查询 SaveData.world.destroyedTerrain.Contains(_destructibleId) → 若已记录 → 直接跳过动画,静默设为 Destroyed 状态(禁用 Collider + Renderer) ``` **DestroyTrigger 类型**: | 类型 | 示例用途 | |------|----------| | `AnyAttack` | 薄土墙、木板、装饰性障碍物 | | `SpecificAbility` | 需解锁"冲击跺地"能力才能砸碎石板 | | `Interact` | 玩家按交互键推倒、踢开的遮挡物 | --- ### 9.5 单向可破坏墙体 (DirectionalDestructible) 继承 `DestructibleTile`,在其基础上增加**攻击方向校验**: ``` DirectionalDestructible.cs(: DestructibleTile) └── _validAttackSide: AttackSide ← Left / Right / Top / Bottom / Any override CheckDestroyCondition(DamageInfo info): attackDir = normalize(info.SourcePosition - transform.position) 仅当 attackDir 与 _validAttackSide 方向匹配时, 调用 base.CheckDestroyCondition(info) ``` **典型用例**: | 场景 | 配置 | |------|------| | 地板薄板:只能从下方攻击打穿 | `_validAttackSide = Bottom` | | 密室单向封墙:仅能从房间内向外打开 | `_validAttackSide = Right` | | 伪装暗道:内侧任意攻击可破坏,外侧无效 | `_validAttackSide = Left`(依据朝向配置)| **Gizmo**:Scene 视图在可破坏面上叠加橙色半透明箭头,标注有效攻击方向;无效方向绘制灰色叉号。 --- ### 9.6 单向可交互机关 (DirectionalInteractable) ``` [Switch_Forest_01] ├── SpriteRenderer(Inactive / Active 两态动画) ├── BoxCollider2D(IsTrigger 用于玩家接触;或挂 HurtBox 接受攻击触发) └── DirectionalInteractable.cs ├── _interactableId: string ← 全局唯一 ID ├── _triggerSide: TriggerSide ← Left / Right / Top / Any ├── _triggerCondition: TriggerCondition ← PlayerAttack / PlayerBody / InteractKey ├── _isOneShot: bool ← true = 一次性,触发后永久激活 ├── _activationChannel: VoidEventChannelSO ← 触发时发布(关卡受体订阅此频道) ├── _deactivationChannel: VoidEventChannelSO ← 非 OneShot 时,玩家离开后发布 └── _activateFeedback: MMF_Player ``` **零耦合关卡连接**(通过共享同一 SO 频道资产): ``` Switch_Forest_01._activationChannel ──► MovingPlatform._activationChannel ──► Door_Locked._openChannel ──► HazardZone_Spikes._disableChannel ``` 机关自身**只发布事件**,受体自行订阅同一 SO 资产,关卡逻辑完全解耦,无脚本间直接引用。 **OneShot 持久化流程**: ``` 触发 → 发布 _activationChannel → 发布 OnMechanismTriggered(_interactableId) └─ SaveManager:写入 triggeredMechanisms[] 读档时:DirectionalInteractable.Start() → 检查 SaveData.world.triggeredMechanisms.Contains(_interactableId) → 若已记录:直接设为激活态,静默发布 _activationChannel(恢复关卡联动状态) ``` --- ### 9.7 碎裂平台 (CrumblePlatform) ``` [CrumblePlatform_Cave_01] ├── SpriteRenderer(4 帧图集:Idle / Warning / Crumbling / Gone) ├── BoxCollider2D(Idle/Warning 时启用;Crumbling/Gone 时禁用) ├── PlatformEffector2D(可选,配置为单向踩踏) ├── BoxCollider2D (IsTrigger,乘客检测,覆盖顶面上方一像素) └── CrumblePlatform.cs ├── _warningDuration: float ← 踩上后抖动警告时间(默认 0.6s) ├── _crumbleDuration: float ← 碎裂下落动画时长(默认 0.3s) ├── _respawnDelay: float ← 碎裂后等待复原时长(默认 3.0s;0 = 永久消失) ├── _isOneShot: bool ← true = 碎裂后永久消失,不复原 └── _crumbleFeedback: MMF_Player ← 预警震动 + 碎裂粒子 + 音效 ``` **状态机**: ``` [乘客检测到 Player] Idle ────────────────────► Warning ──[warningDuration]──► Crumbling ──[动画完成]──► Gone │ │ 抖动动画 ──[respawnDelay,非 OneShot]──► Idle 预警音效 (恢复 Collider + Renderer) ``` **NavAgent 联动**: - 进入 `Gone` 状态 → 对应 `NavLink.enabled = false`(PathBerserker2d 路径重算,AI 不走此链接) - 恢复 `Idle` → `NavLink.enabled = true` --- ### 9.8 假墙 / 秘密通道 (FalseWall) 假墙外观与普通墙体几乎相同,但玩家可以**穿越**或**攻击揭示**通往秘密区域的隐藏通道。 与 `DestructibleTile` 的区别:FalseWall 不销毁,只是使碰撞体进入"允许穿透"状态。 ``` [FalseWall_Forest_01] ├── SpriteRenderer(2 帧:Normal / Revealed,透明度过渡) ├── PolygonCollider2D(实心态时 enabled = true;穿透态时 enabled = false) └── FalseWall.cs ├── _wallId: string ← SaveData 持久化键,如 "FW_Forest_01_SecretA" ├── _revealCondition: RevealCondition │ • Proximity ← 玩家进入 _proximityRadius 范围内自动揭示(仅视觉,通道需攻击) │ • AttackOnce ← 玩家攻击一次后碰撞体禁用,可穿越 │ • AlwaysOpen ← 已无碰撞,天生可穿越(仅用于返程单向暗门) ├── _revealOnProximity: bool ← true = Proximity 范围内仅播放 Shimmer,不启用穿透 ├── _proximityRadius: float ← 默认 2.0f(Unity Unit) ├── _passThrough: bool ← 当前是否可穿越(运行时状态) └── _revealFeedback: MMF_Player ← Shimmer粒子 + 空洞回声音效 ``` **状态机**: ``` Normal(碰撞启用) │ ├──[RevealCondition = Proximity,玩家进入 proximityRadius]──► Shimmer(仅视觉暗示,碰撞仍启用) │ │ │ [玩家离开 radius]◄──┘ │ ├──[RevealCondition = AttackOnce,玩家攻击命中]──► Revealed(碰撞禁用,可穿越,永久) │ └──[RevealCondition = AlwaysOpen]──────────────────────────── Revealed(初始即穿透) ``` **视觉设计三原则**(配合 44_LevelDesignGuide §6.3): | 层级 | 实现方式 | |------|---------| | 外观暗示 | `Normal` 帧 Sprite 含细微色差/裂纹变体 Tile(与周围 90% 相似)| | 近距暗示 | Proximity 触发 `_revealFeedback` 播放轻微 Shimmer 粒子 | | 音效暗示 | 玩家攻击 FalseWall 时播放空洞回声(与普通地形攻击声不同)| **SaveData 持久化**: ```csharp // SaveData.world 新增字段(已并入 §5 JSON Schema) "revealedFalseWalls": ["FW_Forest_01_SecretA", "FW_Cave_03_ShortcutB"] // FalseWall.Start() 读档恢复: bool revealed = SaveManager.CurrentSave.world.revealedFalseWalls.Contains(_wallId); if (revealed) SetPassThroughImmediate(); // 静默恢复,不播放演出 ``` **地图联动**:已揭示的 FalseWall 在 MapUI 显示为"通道"(虚线缺口),未揭示则与普通墙线相同。 **Gizmos(Scene 视图)**: - `Normal` 态:紫色虚线矩形框(区别于绿色 HazardZone) - `proximityRadius` 范围:紫色半透明圆形 - 鼠标悬停显示 `_wallId` --- ### 9.9 魔法障壁 (MagicWall) > **关联技能**:太虚斩(命魂 SoulSkill) · **架构实现**:见 [08_WorldModule §15.1](../Architecture/08_WorldModule.md#151-magicwall--魔法障壁太虚斩专属) 魔法障壁是**只能被太虚斩穿越**的特殊墙体。太虚斩施放期间玩家切换至 `Ghost` 物理层,`MagicWall` 对 `Ghost` 层无碰撞,完全由 Physics Layer Matrix 实现——**无代码逻辑**,设计师只需摆放 Prefab 并确认 Layer 正确。 ``` [MagicWall_Ruins_01] ├── SpriteRenderer(半透明紫色光晕外观) ├── BoxCollider2D / TilemapCollider2D Layer: MagicWall └── MagicWall.cs(仅 Gizmo,无运行时逻辑) ``` **层矩阵配置要求**(见 [57_PhysicsLayerMatrix §Ghost/MagicWall](./57_PhysicsLayerMatrix.md)): | 层 A | 层 B | 碰撞 | |------|------|------| | `Player` | `MagicWall` | ✅ 实体阻挡 | | `Ghost` | `MagicWall` | ❌ 穿越 | | `Enemy` | `MagicWall` | ✅ 实体阻挡 | **关卡设计用途**: | 场景 | 说明 | |------|------| | 命魂专属捷径 | 仅解锁命魂形态的玩家才能穿越 | | Boss 前魔法门 | 未获得太虚斩前无法进入 Boss 房间(进度锁)| | 解谜障壁 | 需在障壁另一侧完成某操作,再太虚斩穿越取物 | **Gizmo(Scene 视图)**:紫色实线矩形;鼠标悬停显示"MagicWall — Ghost 层可穿越"标注。 --- ### 9.10 松软地面 (SoftTerrain) > **关联技能**:地行术(地魂 SoulSkill) · **架构实现**:见 [08_WorldModule §15.2](../Architecture/08_WorldModule.md#152-softterrain--松软地面地行术专属) 松软地面是配合**地行术**的特殊地块:进入 `GroundDive` 状态后,若玩家脚下为松软地面,灵力消耗速率降为 0(普通地面正常消耗),且移动速度不减。 ``` Tilemap_SoftGround ├── TilemapCollider2D + CompositeCollider2D Layer: Ground(普通碰撞) └── SoftTerrain.cs(Marker 组件,无运行时逻辑) ``` **铺设规则**: - 使用独立 `Tilemap_SoftGround` 层,Tile 外观为黄褐色沙粒/软土纹理 - 地行术穿行时,`GroundDiveState` 每帧 `Physics2D.OverlapPoint()` 检测脚下 Tilemap,命中带 `SoftTerrain` 组件的 → 暂停灵力消耗 - 普通行走时 `SoftTerrain` 与 `Ground` 完全相同(正常站立、摩擦) **关卡设计用途**: | 场景 | 说明 | |------|------| | 地行术专属路径 | 长段软土连接隐藏区域,仅地行术可无消耗穿越 | | 技能养成奖励 | 在松软地面采集/打怪更划算,激励玩家探索 | | 危险区绕路 | 利用软土潜行绕过大量敌人 | **Gizmo(Scene 视图)**:Tilemap_SoftGround 在 Scene 视图叠加黄色半透明填充,区别于普通 Ground(灰色)。 --- ### 9.11 幻影机关 (PhantomInteractable) > **关联技能**:残阴术(命魂 SpiritSkill1) · **架构实现**:见 [08_WorldModule §15.3](../Architecture/08_WorldModule.md#153-phantominteractable--幻影机关残阴术专属) 幻影机关继承 `DirectionalInteractable`,**额外响应残阴术灵体**(`PhantomBody` 层)触发。玩家可留下灵体持续踩住压板,自己在别处完成其他操作。 ``` [PhantomPlate_Cave_01] ├── SpriteRenderer(特殊发光压板外观,区别于普通 PressurePlate) ├── BoxCollider2D (IsTrigger) ← 同时检测 Player 和 PhantomBody 层 └── PhantomInteractable.cs(: DirectionalInteractable) ├── 继承所有 DirectionalInteractable 字段 └── 额外在 OnTriggerEnter2D 中响应 PhantomBody 层 ``` **典型谜题流程**: ``` ① 玩家面对幻影压板 ─ 施放残阴术 ─ 留下灵体踩住压板 ─ 关联门打开 ② 玩家(本体)快速通过门洞 ③ 残阴术持续时间结束 ─ 灵体消失 ─ 压板失活 ─ 门关闭(若非 OneShot) ``` **零耦合连接**(同 DirectionalInteractable): ``` PhantomPlate_Cave_01._activationChannel ──► Door._openChannel ──► Elevator._activationChannel ``` **设计约束**: - 残阴术持续时间(`SpiritSkill1.duration`)决定谜题的时间压力,设计时需留足通过时间 - `_isOneShot = true` 时灵体触发一次即永久解锁,适合进度节点 - `_isOneShot = false` 时门/机关随灵体消失而复位,适合 Boss 前危机通道 **Gizmo(Scene 视图)**:青色填充压板轮廓 + 灵体图标标注,区别于普通黄色 DirectionalInteractable。 --- ### 9.12 与地图系统联动 > 地图系统为 P1 优先级;联动接口在此定义,P1 实现时直接对接,**无需修改运行时代码**。 **SaveData `world` 新增字段**(已并入 §5 JSON Schema): ```json "destroyedTerrain": ["DT_Forest_01_WallA", "DT_Cave_02_FloorB"], "triggeredMechanisms": ["Switch_Forest_01", "Lever_Cave_03"] ``` **地图显示规则**: | 地形类型与状态 | 地图显示 | |--------------|----------| | `DestructibleTile` 完好 | 实心墙线(同普通墙,灰色)| | `DestructibleTile` 已破坏 | 缺口/通道(断线 + 橙色边框)| | `DirectionalDestructible` 完好 | 实心墙 + 橙色单向箭头图标 | | `DirectionalDestructible` 已破坏 | 缺口(同普通破坏态)| | `DirectionalInteractable` OneShot 未触发 | 黄色问号图标 | | `DirectionalInteractable` OneShot 已触发 | 白色齿轮图标 | **`MapRoomData` SO 扩展(P1 实现时)**: ``` MapRoomData(SO) ├── _roomId: string ├── _destructibleIds: string[] ← 本房间所有可破坏地形 ID(Edit Time 填写) ├── _mechanismIds: string[] ← 本房间所有机关 ID └── _roomOutlineTexture: Texture2D ← Edit Time 预烘焙轮廓贴图 ``` `MapUI` 渲染时,对照 `SaveData.world.destroyedTerrain` / `triggeredMechanisms` 列表,在房间轮廓图上叠加状态图标层。 --- ## 10. 地图系统(P1) > **优先级 P1,当前版本不实现** > 可破坏地形与机关的地图联动设计详见 **[§9.8 与地图系统联动](#98-与地图系统联动)** ### RoomReveal 机制 - 每个房间 GameObject 含 `RoomRevealData`(SO):房间名称、区域、是否已探索 - `OnRoomEntered` 事件触发后,`MapManager.RevealRoom(roomId)` 记录已探索 - 已探索房间在 `MapUI` 中显示轮廓;未探索的显示为黑色 ### 地图UI - 按 Map 键打开地图(独立 Scene Overlay UI) - 房间以矩形格子排列,已探索显示地形轮廓,Boss 房间特殊标记 - 玩家当前位置显示小图标(实时更新) --- ## 11. 商店 NPC > **命名空间** `BaseGames.World.Shop` ### 11.1 场景结构 ``` [ShopNPC_Seer] ├── SpriteRenderer + Animator(NPC 动画) ├── BoxCollider2D (IsTrigger,交互范围) ├── InteractableNPC.cs ← 对话系统(DialogueGraph SO 驱动,见 15_DialogueSystem.md) ├── ShopController.cs ← 商店逻辑(接收交互事件,打开 UI) └── ShopInventorySO ← 商品列表资产(每个 NPC 独立配置) ``` ### 11.2 ShopItemSO — 商品数据 ```csharp [CreateAssetMenu(menuName = "World/Shop/ShopItem")] public class ShopItemSO : ScriptableObject { [Header("基础信息")] public string itemId; // 唯一 ID,存入 SaveData 已购列表 public string displayName; [TextArea(2, 4)] public string description; public Sprite icon; [Header("价格")] public int cost; // Geo 价格 public bool isBuyOnce; // true = 购买后从商店消失(如能力解锁) [Header("解锁条件")] public string unlockConditionId; // 空 = 始终可购;否则查询 SaveData.defeatedBosses [Header("购买效果")] [SerializeReference] public IShopItemEffect effect; // 购买时执行的效果 } ``` ### 11.3 IShopItemEffect — 购买效果接口 ```csharp public interface IShopItemEffect { void OnPurchased(ShopContext ctx); string GetEffectDescription(); } public struct ShopContext { public PlayerStats Stats; public EquipmentManager Equipment; public SaveManager Save; public EventChannelRegistry Events; } ``` **内置效果实现**: | 效果类 | 说明 | |--------|------| | `GiveCharmEffect` | 将 `CharmSO` 添加到玩家魅力库(`EquipmentManager.AddCollected`)| | `GiveToolEffect` | 增加工具持有数量 | | `UpgradeNotchEffect` | 调用 `EquipmentManager.AddNotchCapacity(1)` | | `UpgradeMaxHPEffect` | `PlayerStats.AddMaxHP(1)` | | `GiveMapDataEffect` | 解锁某个区域地图(`MapManager.RevealRegion`)| | `GiveAbilityEffect` | 解锁能力(`PlayerStats.UnlockAbility`)| ### 11.4 ShopInventorySO — 商品列表 ```csharp [CreateAssetMenu(menuName = "World/Shop/Inventory")] public class ShopInventorySO : ScriptableObject { public ShopItemSO[] items; public IEnumerable GetAvailableItems(SaveData saveData) => items.Where(item => (!item.isBuyOnce || !saveData.world.purchasedItems.Contains(item.itemId)) && (string.IsNullOrEmpty(item.unlockConditionId) || saveData.world.defeatedBosses.Contains(item.unlockConditionId))); } ``` ### 11.5 购买流程 ``` 玩家与 ShopNPC 交互 │ ▼ InteractableNPC.OnInteract() ├─ 播放开场对话(DialogueGraph → DialogueManager) └─ 对话结束后 → ShopController.OpenShop() │ ▼ ShopUI 显示商品列表 │ 玩家选择商品 → 确认购买 │ ├─ 检查 PlayerStats.CurrentGeo >= item.cost │ └─ [不足] → 显示"Geo 不足"提示,闪烁 Geo 数字 │ ├─ 发布 OnGeoSpent(item.cost) ├─ 执行 item.effect.OnPurchased(ctx) ├─ 若 isBuyOnce:SaveManager.MarkPurchased(item.itemId) ├─ 刷新商品列表(隐藏已购一次性商品) └─ 播放购买反馈(MMF_Player: 硬币音效 + 金光特效) ``` ### 11.6 商店 UI 结构 ``` Canvas_Shop (Sorting Order: 30) └── ShopPanel ├── NpcPortrait (左侧 NPC 立绘) ├── ItemGrid (ScrollView,商品列表) │ └── ShopItemCell(图标 + 名称 + 价格 + 售罄标签) ├── DetailPanel (右侧选中商品详情) │ ├── ItemIcon (大图) │ ├── ItemName + CostText (Geo 图标 + 数字) │ ├── DescriptionText │ ├── EffectText (IShopItemEffect.GetEffectDescription()) │ └── BuyButton / SoldOutLabel ├── PlayerGeoDisplay (当前持有 Geo,实时更新) └── CloseButton(或按 B / Esc 关闭) ``` --- ## 12. 死亡遗骸(DeathShade) > **命名空间** `BaseGames.World` 死亡遗骸是银河恶魔城游戏的核心死亡惩罚机制:玩家死亡后 Geo 清零,遗骸(影子)留在死亡位置;回到死亡地点击败遗骸可取回全部 Geo。 ### 12.1 死亡遗骸数据 ```csharp [Serializable] public struct DeathShadeData { public bool hasShade; // 是否存在遗骸 public int geo; // 遗骸携带的 Geo public Vector2 worldPosition; // 遗骸世界坐标 public string sceneName; // 遗骸所在场景 } ``` SaveData 扩展(在 §5 JSON Schema 基础上新增 `deathShade` 字段): ```json "deathShade": { "hasShade": true, "geo": 340, "worldPosition": { "x": 12.5, "y": -3.0 }, "sceneName": "Room_Forest_02" } ``` ### 12.2 DeathShadeManager ```csharp namespace BaseGames.World { public class DeathShadeManager : MonoBehaviour { [Header("事件频道")] [SerializeField] VoidEventChannelSO _onPlayerDied; [SerializeField] StringEventChannelSO _onRoomEntered; [SerializeField] IntEventChannelSO _onGeoChanged; [SerializeField] IntEventChannelSO _onGeoRecovered; // 发布,通知 PlayerStats [Header("遗骸预制件")] [SerializeField] GameObject _shadePrefab; DeathShadeData _shadeData; GameObject _activeShade; int _lastKnownGeo; void OnEnable() { _onPlayerDied.OnEventRaised += HandlePlayerDied; _onRoomEntered.OnEventRaised += HandleRoomEntered; _onGeoChanged.OnEventRaised += geo => _lastKnownGeo = geo; } void OnDisable() { _onPlayerDied.OnEventRaised -= HandlePlayerDied; _onRoomEntered.OnEventRaised -= HandleRoomEntered; } void HandlePlayerDied() { // 旧遗骸被新死亡覆盖,旧 Geo 丢失 if (_shadeData.hasShade) DestroyActiveShade(); _shadeData = new DeathShadeData { hasShade = true, geo = _lastKnownGeo, worldPosition = PlayerPositionCache.Instance.LastPosition, sceneName = SceneManager.GetActiveScene().name }; SaveManager.Instance.SetDeathShadeData(_shadeData); } void HandleRoomEntered(string sceneName) { DestroyActiveShade(); // 清理上一场景残留 if (_shadeData.hasShade && _shadeData.sceneName == sceneName) SpawnShade(); } void SpawnShade() { _activeShade = Instantiate(_shadePrefab, _shadeData.worldPosition, Quaternion.identity); _activeShade.GetComponent().Initialize(_shadeData.geo, OnShadeDefeated); } void OnShadeDefeated(int geo) { _onGeoRecovered.Raise(geo); // PlayerStats 增加 Geo _shadeData = default; SaveManager.Instance.SetDeathShadeData(_shadeData); _activeShade = null; } void DestroyActiveShade() { if (_activeShade != null) { Destroy(_activeShade); _activeShade = null; } } } } ``` ### 12.3 DeathShade 组件(遗骸 Prefab) ``` [DeathShade_Prefab] ├── SpriteRenderer(透明度 60%,蓝灰色 Shader 参数) ├── Animator(浮动/脉冲动画循环) ├── BoxCollider2D(可接受伤害) ├── HurtBox.cs(接受玩家攻击) └── DeathShade.cs ├── _geo: int ← 由 Manager 注入 ├── _onDefeated: Action ← 回调至 Manager └── _defeatedFeedback: MMF_Player ``` ```csharp public class DeathShade : MonoBehaviour { int _geo; Action _onDefeated; bool _isDefeated; public void Initialize(int geo, Action onDefeated) => (_geo, _onDefeated) = (geo, onDefeated); // HurtBox.OnHurt UnityEvent 绑定到此方法 public void OnHurt(DamageInfo _) { if (_isDefeated) return; _isDefeated = true; _defeatedFeedback.PlayFeedbacks(); _onDefeated?.Invoke(_geo); Destroy(gameObject, 0.5f); } } ``` ### 12.4 死亡流程完整时序 ``` 玩家 HP 降至 0 → PlayerStats.OnDied() → 发布 OnPlayerDied ├─ DeathShadeManager:写入 DeathShadeData,清零玩家 Geo ├─ PlayerFeedback:死亡演出(变暗/震屏/音效) └─ GameManager:延迟 1s 后,读档重生 → GameManager.LoadLastSave() → 玩家 HP 满血、Geo = 0 重生于最近存档点 → 若遗骸在当前场景:自动生成 DeathShade 玩家击败遗骸 → DeathShade.OnHurt → _onDefeated(geo) → DeathShadeManager → 发布 OnGeoRecovered(geo) → PlayerStats.AddGeo(geo) → 发布 OnGeoChanged → SaveData 清除 deathShade ``` ### 12.5 特殊规则 | 规则 | 说明 | |------|------| | 再次死亡 | 新遗骸覆盖旧遗骸,旧 Geo 永久丢失 | | 即死危险区域 | 与普通死亡相同流程 | | 遗骸在 Boss 房间 | Boss 门开启后正常返回取回 | | 读档时遗骸不在当前场景 | 进入遗骸场景时自动生成 | ### 12.6 遗骸吸引系统(DeathShadePuller) **设计目标**:玩家进入遗骸所在房间时,遗骸对玩家产生微弱的"召唤感",辅助玩家找到遗骸位置(尤其在大型房间)。 ```csharp /// /// 附加在 DeathShade Prefab 上,当玩家进入感应范围时施加轻柔吸引力。 /// 吸引力不影响实际物理移动(仅视觉/音频暗示),避免影响游戏感。 /// public class DeathShadePuller : MonoBehaviour { [SerializeField] float _pullRadius = 12f; // 开始显示视觉提示的距离 [SerializeField] float _audioFadeRadius = 20f; // 开始播放 Ghost 音效的距离 [SerializeField] AudioClip _ghostHum; // 低频幽灵嗡鸣(3D 音效,随距离衰减) [SerializeField] ParticleSystem _echoParticles; // 向玩家方向飘散的粒子 [SerializeField] AudioSource _audioSource; Transform _player; bool _isActive; void Update() { if (_player == null) return; float dist = Vector2.Distance(transform.position, _player.position); // 音频淡入(距离越近越响) float audioVol = Mathf.InverseLerp(_audioFadeRadius, _pullRadius * 0.5f, dist); _audioSource.volume = audioVol; // 粒子方向朝向玩家(不影响物理) if (dist < _pullRadius && !_isActive) { _isActive = true; _echoParticles.Play(); } else if (dist >= _pullRadius && _isActive) { _isActive = false; _echoParticles.Stop(); } if (_isActive) { // 粒子朝向玩家方向 var dir = (_player.position - transform.position).normalized; var main = _echoParticles.main; _echoParticles.transform.rotation = Quaternion.LookRotation(Vector3.forward, new Vector3(dir.x, dir.y, 0)); } } public void SetPlayer(Transform player) => _player = player; } ``` **GhostEchoFeedback(玩家侧)**: 当玩家与遗骸距离 < `pullRadius` 时,在玩家 Sprite 边缘叠加一层半透明蓝色轮廓(Shader Outline),强度随距离增加: ```csharp // 在 DeathShadeManager.SpawnShade() 中调用: void SpawnShade() { _activeShade = Instantiate(_shadePrefab, _shadeData.worldPosition, Quaternion.identity); var shade = _activeShade.GetComponent(); var puller = _activeShade.GetComponent(); shade.Initialize(_shadeData.geo, OnShadeDefeated); puller.SetPlayer(_playerTransform); } ``` ### 12.7 遗骸地图标记 地图系统(§10)在 `deathShade.hasShade == true` 时,在地图上对应位置显示**闪烁的骷髅图标**: ```csharp // MapManager.RefreshDeathShadeMarker() 由 OnRoomEntered 事件触发 void RefreshDeathShadeMarker() { var data = SaveManager.Instance.GetDeathShadeData(); _deathShadeMarker.gameObject.SetActive(data.hasShade); if (data.hasShade) { _deathShadeMarker.rectTransform.anchoredPosition = WorldToMapPosition(data.worldPosition, data.sceneName); // 骷髅图标闪烁(DOTween) _deathShadeMarker.DOFade(0.3f, 0.8f) .SetLoops(-1, LoopType.Yoyo) .SetId("ShadeMarkerBlink"); } else { DOTween.Kill("ShadeMarkerBlink"); } } ``` ### 12.8 取回遗骸演出 `OnShadeDefeated` 触发时,播放完整取回演出(Feel FB 链): ``` DeathShadeRecoveryFeedback(MMF_Player 序列): [0] 屏幕闪白(0.05s → 淡出 0.3s) [1] 玩家周围爆发蓝色粒子环(SpawnParticle: ShadePop_VFX) [2] UI:Geo 数字跳动动画(FloatingText "+" + geo数量) [3] 音效:Soul_Recovery_Heavy.wav [4] 相机震动(轻度,0.1s,振幅 0.3) [5] HUD 金币数字闪烁(高亮 0.5s) ``` --- ## 13. 世界事件频道 | 频道资产 | 类型 | 发布方 | 主要订阅方 | |---------|------|--------|----------| | `LoadScene.asset` | `LoadSceneEventChannelSO` | `RoomTransition` | `GameManager` | | `OnRoomEntered.asset` | `TransformEventChannelSO` | `RoomTransition(完成后)` | `CameraStateController`、`NavSurface(重新配置)` | | `OnSavePointActivated.asset` | `VoidEventChannelSO` | `SavePoint` | `SaveManager`、`HUD` | | `OnAbilityUnlocked.asset` | `IntEventChannelSO`(存 AbilityType) | `AbilityUnlock` | `PlayerStats`、`HUD`(提示)| | `OnCollectiblePickedUp.asset` | `StringEventChannelSO`(存 ID) | `Collectible` | `SaveManager` | | `OnBossFightStarted.asset` | `VoidEventChannelSO` | `BossRoomTrigger` | `CameraStateController`、`AudioManager` | | `OnBossFightEnded.asset` | `VoidEventChannelSO` | `BossBase`(死亡时)| `GameManager`、`AudioManager`、`Door(开门)` | | `OnDestructibleDestroyed.asset` | `StringEventChannelSO`(存 ID)| `DestructibleTile` | `SaveManager`、`MapManager(P1)` | | `OnDestructibleRespawned.asset` | `StringEventChannelSO`(存 ID)| `DestructibleTile` | `MapManager(P1)` | | `OnMechanismTriggered.asset` | `StringEventChannelSO`(存 ID)| `DirectionalInteractable` | `SaveManager` | | `OnPlayerDied.asset` | `VoidEventChannelSO` | `PlayerStats` | `DeathShadeManager`、`GameManager`(触发读档)| | `OnGeoRecovered.asset` | `IntEventChannelSO`(Geo 数值)| `DeathShadeManager` | `PlayerStats`(增加 Geo)| | `OnGeoSpent.asset` | `IntEventChannelSO`(花费量)| `ShopController` | `PlayerStats`(扣减 Geo)| | `OnShopOpened.asset` | `VoidEventChannelSO` | `ShopController` | `InputReader`(切 ActionMap)、`ShopUI` | | `OnShopClosed.asset` | `VoidEventChannelSO` | `ShopUI` | `InputReader`(恢复 ActionMap)| --- ## 14. 编辑器友好设计 ### SaveManager 调试工具(Play Mode Inspector) ``` ┌─ SaveManager ──────────────────────────────────┐ │ Slot : 0 │ │ Scene : Room_Forest_01 │ │ SavePoint: SP_Forest_01_Entry │ │ HP : 5/5 | Geo: 340 | Soul: 66/99 │ │ ─────────────────────────────────────────────│ │ [强制存档] [强制读档] [清除存档] [打开存档文件]│ │ [解锁全部能力] [设置HP: ___] [添加Geo: ___] │ └────────────────────────────────────────────────┘ ``` ### SpawnPoint Gizmos - Scene 视图中每个 `SpawnPoint` 显示绿色旗帜 + `[ID]` 文字标注 - `RoomTransition` 在 Scene 视图绘制箭头指向 `targetScene`(文字标注目标场景名) ### 房间加载测试 `RoomTransition` 自定义 Inspector 底部: ``` [预览目标场景: Room_Forest_02] [立即传送(Editor Play Mode)] ``` 点击"立即传送"后,不经过过渡动画直接加载目标场景(用于快速测试特定房间)。 ### SaveData JSON Viewer 编辑器菜单:`BaseGames > World > Open SaveData Viewer` - 显示当前存档文件的格式化 JSON - 支持直接在编辑器内修改值并保存(调试 / 关卡测试用) --- ## 15. Tilemap 与物理材质配置 ### Tilemap 层级规范 ``` 场景根节点 └── Tilemaps ├── Ground → 主地面(Tilemap Collider 2D,Composite Collider 2D) ├── Platforms → 单向平台(PlatformEffector2D,rotationalOffset 180°) ├── Hazards → 即死刺/熔岩(仅触发器,无 HazardZone 的场景装饰) ├── SoftGround → 松软地面(Layer: Ground,挂 SoftTerrain Marker 组件;地行术无消耗穿行) ├── MagicWall → 魔法障壁(Layer: MagicWall;Ghost 层可穿越,见 §9.9) ├── Background_A → 近景装饰(无碰撞) ├── Background_B → 远景装饰(无碰撞,Scale 略小实现视差) └── Foreground → 最前景遮罩(无碰撞,遮挡玩家) ``` **渲染层(Sorting Layer)顺序**(从后到前): ``` Background_Far (-300) Background_Mid (-200) Background_Near (-100) Ground (0) Objects (100) Player (200) Enemies (200) Foreground (500) UI (1000) ``` ### 物理材质(Physics Material 2D)配置 | 材质名 | 摩擦力 | 弹性 | 适用 Tilemap | 说明 | |--------|--------|------|-------------|------| | `PM_Ground_Stone` | 0.4 | 0.0 | Ground(石地)| 标准摩擦,落地不弹起 | | `PM_Ground_Ice` | 0.02 | 0.0 | 冰面区域 | 极低摩擦,滑行感 | | `PM_Ground_Mud` | 0.8 | 0.0 | 泥地区域 | 高摩擦,减速效果 | | `PM_Platform` | 0.0 | 0.0 | Platforms | 零摩擦(PlatformEffector 处理)| | `PM_Wall_Climbable` | 0.5 | 0.0 | 可攀爬墙壁 | 高于0摩擦使壁滑减速 | | `PM_Bouncy` | 0.0 | 0.6 | 弹跳台 | 0.6 弹性让玩家弹起 | **赋值方式**:将 Physics Material 2D 资产拖到 Composite Collider 2D 的 `Material` 字段(整体地面共用一个材质,不需要每块 tile 单独设置)。 ### PlatformEffector2D 配置规范 ``` PlatformEffector2D(附加在 Platforms Tilemap 的 Composite Collider 上): Use One Way: ✓(从下方可穿过) Use One Way Grouping: ✓(同一 Collider 下所有平台联动) Surface Arc: 180°(标准单向平台) Rotational Offset: 180°(确保碰撞面朝上) Use Friction: ✗(由玩家自身 Physics Material 控制) ``` **下蹲穿平台实现**(在 `PlayerMovement` 中): ```csharp void DropThroughPlatform() { // 短暂禁用 Player 与 Platforms 层的碰撞 Physics2D.IgnoreLayerCollision( LayerMask.NameToLayer("Player"), LayerMask.NameToLayer("Platforms"), true ); DOVirtual.DelayedCall(0.3f, () => Physics2D.IgnoreLayerCollision( LayerMask.NameToLayer("Player"), LayerMask.NameToLayer("Platforms"), false ) ); } ``` ### Tile 表面标签(用于脚步音效系统) 在 Tilemap 的 Collider 所在 GameObject 上挂载 `SurfaceTag` 组件(见 [20_AnimationEventSystem §7](./20_AnimationEventSystem.md#7-脚步声系统)): ``` Ground(石头) → SurfaceTag.surfaceType = Stone Ground(泥土区域) → SurfaceTag.surfaceType = Dirt(需单独 Tilemap 或 Collider2D 区域) Water Area → SurfaceTag.surfaceType = Water Crystal Cave → SurfaceTag.surfaceType = Crystal ``` --- ## 16. 房间内存管理(Room Streaming) > **问题**:累加式场景加载(Additive Loading)若无卸载策略,长时间游戏后内存持续增长,最终导致 OOM 崩溃或严重帧率下降。 ### 16.1 RoomStreamingManager ```csharp namespace BaseGames.World { /// /// 管理房间的预加载与卸载,确保内存中仅保留 "当前 + 相邻" 房间。 /// 挂载于常驻场景(PersistentScene)。 /// public class RoomStreamingManager : MonoBehaviour { [Header("事件频道")] [SerializeField] StringEventChannelSO _onRoomEntered; [Header("配置")] [SerializeField] int _keepAroundHops = 1; // 保留当前房间 ±1 跳的邻近房间 readonly HashSet _loadedScenes = new(); readonly HashSet _preloadingScenes = new(); string _currentScene; void OnEnable() => _onRoomEntered.OnEventRaised += HandleRoomEntered; void OnDisable() => _onRoomEntered.OnEventRaised -= HandleRoomEntered; async void HandleRoomEntered(string newScene) { _currentScene = newScene; _loadedScenes.Add(newScene); var toKeep = GetNeighborScenes(newScene, _keepAroundHops); toKeep.Add(newScene); var toUnload = new List(_loadedScenes); foreach (var s in toUnload) if (!toKeep.Contains(s)) await UnloadSceneAsync(s); } /// 由 RoomTransition 在玩家靠近出口时调用,触发预加载。 public async void PreloadScene(string targetScene) { if (_loadedScenes.Contains(targetScene)) return; if (_preloadingScenes.Contains(targetScene)) return; _preloadingScenes.Add(targetScene); var op = SceneManager.LoadSceneAsync(targetScene, LoadSceneMode.Additive); op.allowSceneActivation = false; await UniTask.WaitUntil(() => op.progress >= 0.9f); op.allowSceneActivation = true; await UniTask.WaitUntil(() => op.isDone); _loadedScenes.Add(targetScene); _preloadingScenes.Remove(targetScene); } async UniTask UnloadSceneAsync(string sceneName) { _loadedScenes.Remove(sceneName); await SceneManager.UnloadSceneAsync(sceneName).ToUniTask(); } HashSet GetNeighborScenes(string sceneId, int hops) => RoomGraphSO.Instance.GetNeighbors(sceneId, hops); } } ``` ### 16.2 Addressables 场景卸载规范 当场景通过 `Addressables.LoadSceneAsync` 加载时,必须通过对应句柄卸载,否则引用计数不归零: ```csharp readonly Dictionary> _addressableHandles = new(); async UniTask LoadSceneAdditiveAddressable(string address) { var handle = Addressables.LoadSceneAsync(address, LoadSceneMode.Additive, activateOnLoad: false); _addressableHandles[address] = handle; await handle.Task; handle.Result.ActivateAsync(); } async UniTask UnloadSceneAddressable(string address) { if (!_addressableHandles.TryGetValue(address, out var handle)) return; await Addressables.UnloadSceneAsync(handle).Task; _addressableHandles.Remove(address); } ``` > **关键规则**:每次 `Addressables.LoadSceneAsync` 必须配对 `Addressables.UnloadSceneAsync`。直接使用 `SceneManager.UnloadSceneAsync` 卸载 Addressables 场景会导致引用计数泄漏。 ### 16.3 RoomGraphSO — 房间邻接表 ```csharp [CreateAssetMenu(menuName = "World/RoomGraph")] public class RoomGraphSO : ScriptableObject { public static RoomGraphSO Instance; [Serializable] public class RoomNode { public string sceneId; public string[] neighbors; // 直接相邻(1 跳) } public RoomNode[] rooms; readonly Dictionary _graph = new(); void OnEnable() { Instance = this; foreach (var r in rooms) _graph[r.sceneId] = r.neighbors; } /// BFS,返回 hops 跳以内的所有邻居 ID。 public HashSet GetNeighbors(string sceneId, int hops) { var result = new HashSet(); var queue = new Queue<(string id, int depth)>(); queue.Enqueue((sceneId, 0)); while (queue.Count > 0) { var (id, depth) = queue.Dequeue(); if (depth >= hops) continue; if (!_graph.TryGetValue(id, out var neighbors)) continue; foreach (var n in neighbors) if (result.Add(n)) queue.Enqueue((n, depth + 1)); } return result; } } ``` **维护方式**:在 Inspector 手动填写邻接表,或使用编辑器工具从场景 `RoomTransition` 组件自动生成(`BaseGames > World > Generate RoomGraph`)。 ### 16.4 内存预算参考 | 场景类型 | 预估内存 | 典型场景 | |---------|---------|---------| | 小型过渡房间 | ~8 MB | 普通走廊 | | 标准探索房间 | ~20 MB | 含敌人/机关 | | 大型区域房间 | ~45 MB | 含 Boss 前厅 | | Boss 房间 | ~60 MB | 含 Boss Prefab 和特效 | **目标上限(移动端)**:全局 350 MB;常驻场景约 30 MB;`keepAroundHops = 1` 最坏约 255 MB,留有安全余量。 > Boss 战期间可将 `_keepAroundHops` 临时降至 0,战后恢复 1,避免大型 Boss 场景与多邻居同时驻留超预算。