Files
zeling_v2/Docs/Design/08_WorldSystem.md
2026-05-08 11:04:00 +08:00

55 KiB
Raw Permalink Blame History

08 · 世界系统

命名空间 BaseGames.World
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.Core.Events · BaseGames.Player · PathBerserker2dNavSurface


目录

  1. 系统总览
  2. 场景结构规范
  3. 房间过渡系统
  4. 存档系统
  5. SaveData JSON Schema
  6. 危险区域
  7. 可收集物
  8. 能力解锁物件
  9. 可交互环境
  10. 地图系统P1
  11. 商店 NPC
  12. 死亡遗骸DeathShade
  13. 世界事件频道
  14. 编辑器友好设计
  15. Tilemap 与物理材质配置

1. 系统总览

世界系统职责:
  ├─ 场景(房间)结构规范        → 所有关卡按统一层级组织
  ├─ 房间过渡(门/传送)         → RoomTransition 组件
  ├─ 存档/读档JSON 持久化)    → SaveManager
  ├─ 危险区域(即死/恢复)       → HazardZone
  ├─ 可收集物Geo/物品)        → Collectible
  ├─ 能力解锁物件                → AbilityUnlock
  ├─ 地图系统P1             → MapManager / RoomReveal
  └─ 商店 NPCP1             → 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: GroundIntact 态同 GroundDestroyed 后禁用 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.10Layer: 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

{
  "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
    └── (生成特殊 DamageInfoDamageType = TrueIgnoreIFrame 标记)
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
单向平台 PlatformEffector2DUnity 内置)
移动平台 MovingPlatform 否(重进房间重置)
可破坏地形 DestructibleTile ✓(isPermanent = true 时)
单向可破坏墙体 DirectionalDestructible ✓(同上)
单向可交互机关 DirectionalInteractable ✓(isOneShot = true 时) ✓(机关态)
碎裂平台 CrumblePlatform 否(重进房间重置)
假墙 / 秘密通道 FalseWall ✓(揭示后写入 SaveData ✓(显示为通道)
魔法障壁 MagicWall 否(障壁本身不销毁)
松软地面 SoftTerrainMarker 组件)
幻影机关 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 类型 NavLinkBD_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检测到 PlayerEnemy 层时,将乘客 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]
├── SpriteRendererSprite 图集Intact / Cracked / Destroyed 三帧)
├── BoxCollider2DIntact 时启用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[]
                      └─ MapManagerP1刷新地图显示
                  → 若 _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(依据朝向配置)

GizmoScene 视图在可破坏面上叠加橙色半透明箭头,标注有效攻击方向;无效方向绘制灰色叉号。


9.6 单向可交互机关 (DirectionalInteractable)

[Switch_Forest_01]
├── SpriteRendererInactive / Active 两态动画)
├── BoxCollider2DIsTrigger 用于玩家接触;或挂 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]
├── SpriteRenderer4 帧图集Idle / Warning / Crumbling / Gone
├── BoxCollider2DIdle/Warning 时启用Crumbling/Gone 时禁用)
├── PlatformEffector2D可选配置为单向踩踏
├── BoxCollider2D (IsTrigger乘客检测覆盖顶面上方一像素)
└── CrumblePlatform.cs
    ├── _warningDuration: float      ← 踩上后抖动警告时间(默认 0.6s
    ├── _crumbleDuration: float      ← 碎裂下落动画时长(默认 0.3s
    ├── _respawnDelay: float         ← 碎裂后等待复原时长(默认 3.0s0 = 永久消失)
    ├── _isOneShot: bool             ← true = 碎裂后永久消失,不复原
    └── _crumbleFeedback: MMF_Player ← 预警震动 + 碎裂粒子 + 音效

状态机

          [乘客检测到 Player]
Idle ────────────────────► Warning ──[warningDuration]──► Crumbling ──[动画完成]──► Gone
                            │                                                        │
                        抖动动画                              ──[respawnDelay非 OneShot]──► Idle
                        预警音效                                 (恢复 Collider + Renderer

NavAgent 联动

  • 进入 Gone 状态 → 对应 NavLink.enabled = falsePathBerserker2d 路径重算AI 不走此链接)
  • 恢复 IdleNavLink.enabled = true

9.8 假墙 / 秘密通道 (FalseWall)

假墙外观与普通墙体几乎相同,但玩家可以穿越攻击揭示通往秘密区域的隐藏通道。
DestructibleTile 的区别FalseWall 不销毁,只是使碰撞体进入"允许穿透"状态。

[FalseWall_Forest_01]
├── SpriteRenderer2 帧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.0fUnity 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 持久化

// 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 显示为"通道"(虚线缺口),未揭示则与普通墙线相同。

GizmosScene 视图)

  • Normal 态:紫色虚线矩形框(区别于绿色 HazardZone
  • proximityRadius 范围:紫色半透明圆形
  • 鼠标悬停显示 _wallId

9.9 魔法障壁 (MagicWall)

关联技能:太虚斩(命魂 SoulSkill · 架构实现:见 08_WorldModule §15.1

魔法障壁是只能被太虚斩穿越的特殊墙体。太虚斩施放期间玩家切换至 Ghost 物理层,MagicWallGhost 层无碰撞,完全由 Physics Layer Matrix 实现——无代码逻辑,设计师只需摆放 Prefab 并确认 Layer 正确。

[MagicWall_Ruins_01]
├── SpriteRenderer半透明紫色光晕外观
├── BoxCollider2D / TilemapCollider2D     Layer: MagicWall
└── MagicWall.cs仅 Gizmo无运行时逻辑

层矩阵配置要求(见 57_PhysicsLayerMatrix §Ghost/MagicWall

层 A 层 B 碰撞
Player MagicWall 实体阻挡
Ghost MagicWall 穿越
Enemy MagicWall 实体阻挡

关卡设计用途

场景 说明
命魂专属捷径 仅解锁命魂形态的玩家才能穿越
Boss 前魔法门 未获得太虚斩前无法进入 Boss 房间(进度锁)
解谜障壁 需在障壁另一侧完成某操作,再太虚斩穿越取物

GizmoScene 视图):紫色实线矩形;鼠标悬停显示"MagicWall — Ghost 层可穿越"标注。


9.10 松软地面 (SoftTerrain)

关联技能:地行术(地魂 SoulSkill · 架构实现:见 08_WorldModule §15.2

松软地面是配合地行术的特殊地块:进入 GroundDive 状态后,若玩家脚下为松软地面,灵力消耗速率降为 0普通地面正常消耗且移动速度不减。

Tilemap_SoftGround
├── TilemapCollider2D + CompositeCollider2D    Layer: Ground普通碰撞
└── SoftTerrain.csMarker 组件,无运行时逻辑)

铺设规则

  • 使用独立 Tilemap_SoftGroundTile 外观为黄褐色沙粒/软土纹理
  • 地行术穿行时,GroundDiveState 每帧 Physics2D.OverlapPoint() 检测脚下 Tilemap命中带 SoftTerrain 组件的 → 暂停灵力消耗
  • 普通行走时 SoftTerrainGround 完全相同(正常站立、摩擦)

关卡设计用途

场景 说明
地行术专属路径 长段软土连接隐藏区域,仅地行术可无消耗穿越
技能养成奖励 在松软地面采集/打怪更划算,激励玩家探索
危险区绕路 利用软土潜行绕过大量敌人

GizmoScene 视图)Tilemap_SoftGround 在 Scene 视图叠加黄色半透明填充,区别于普通 Ground灰色


9.11 幻影机关 (PhantomInteractable)

关联技能:残阴术(命魂 SpiritSkill1 · 架构实现:见 08_WorldModule §15.3

幻影机关继承 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 前危机通道

GizmoScene 视图):青色填充压板轮廓 + 灵体图标标注,区别于普通黄色 DirectionalInteractable。


9.12 与地图系统联动

地图系统为 P1 优先级联动接口在此定义P1 实现时直接对接,无需修改运行时代码

SaveData world 新增字段(已并入 §5 JSON Schema

"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 实现时)

MapRoomDataSO
├── _roomId: string
├── _destructibleIds: string[]      ← 本房间所有可破坏地形 IDEdit Time 填写)
├── _mechanismIds: string[]         ← 本房间所有机关 ID
└── _roomOutlineTexture: Texture2D  ← Edit Time 预烘焙轮廓贴图

MapUI 渲染时,对照 SaveData.world.destroyedTerrain / triggeredMechanisms 列表,在房间轮廓图上叠加状态图标层。


10. 地图系统P1

优先级 P1当前版本不实现
可破坏地形与机关的地图联动设计详见 §9.8 与地图系统联动

RoomReveal 机制

  • 每个房间 GameObject 含 RoomRevealDataSO房间名称、区域、是否已探索
  • OnRoomEntered 事件触发后,MapManager.RevealRoom(roomId) 记录已探索
  • 已探索房间在 MapUI 中显示轮廓;未探索的显示为黑色

地图UI

  • 按 Map 键打开地图(独立 Scene Overlay UI
  • 房间以矩形格子排列已探索显示地形轮廓Boss 房间特殊标记
  • 玩家当前位置显示小图标(实时更新)

11. 商店 NPC

命名空间 BaseGames.World.Shop

11.1 场景结构

[ShopNPC_Seer]
├── SpriteRenderer + AnimatorNPC 动画)
├── BoxCollider2D (IsTrigger交互范围
├── InteractableNPC.cs     ← 对话系统DialogueGraph SO 驱动,见 15_DialogueSystem.md
├── ShopController.cs      ← 商店逻辑(接收交互事件,打开 UI
└── ShopInventorySO        ← 商品列表资产(每个 NPC 独立配置)

11.2 ShopItemSO — 商品数据

[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 — 购买效果接口

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 — 商品列表

[CreateAssetMenu(menuName = "World/Shop/Inventory")]
public class ShopInventorySO : ScriptableObject
{
    public ShopItemSO[] items;

    public IEnumerable<ShopItemSO> 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)
             ├─ 若 isBuyOnceSaveManager.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 死亡遗骸数据

[Serializable]
public struct DeathShadeData
{
    public bool    hasShade;      // 是否存在遗骸
    public int     geo;           // 遗骸携带的 Geo
    public Vector2 worldPosition; // 遗骸世界坐标
    public string  sceneName;     // 遗骸所在场景
}

SaveData 扩展(在 §5 JSON Schema 基础上新增 deathShade 字段):

"deathShade": {
  "hasShade": true,
  "geo": 340,
  "worldPosition": { "x": 12.5, "y": -3.0 },
  "sceneName": "Room_Forest_02"
}

12.2 DeathShadeManager

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<DeathShade>().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<int>  ← 回调至 Manager
    └── _defeatedFeedback: MMF_Player
public class DeathShade : MonoBehaviour
{
    int         _geo;
    Action<int> _onDefeated;
    bool        _isDefeated;

    public void Initialize(int geo, Action<int> 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

设计目标:玩家进入遗骸所在房间时,遗骸对玩家产生微弱的"召唤感",辅助玩家找到遗骸位置(尤其在大型房间)。

/// <summary>
/// 附加在 DeathShade Prefab 上,当玩家进入感应范围时施加轻柔吸引力。
/// 吸引力不影响实际物理移动(仅视觉/音频暗示),避免影响游戏感。
/// </summary>
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强度随距离增加

// 在 DeathShadeManager.SpawnShade() 中调用:
void SpawnShade()
{
    _activeShade = Instantiate(_shadePrefab, _shadeData.worldPosition, Quaternion.identity);
    var shade    = _activeShade.GetComponent<DeathShade>();
    var puller   = _activeShade.GetComponent<DeathShadePuller>();

    shade.Initialize(_shadeData.geo, OnShadeDefeated);
    puller.SetPlayer(_playerTransform);
}

12.7 遗骸地图标记

地图系统§10deathShade.hasShade == true 时,在地图上对应位置显示闪烁的骷髅图标

// 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 链):

DeathShadeRecoveryFeedbackMMF_Player 序列):
  [0] 屏幕闪白0.05s → 淡出 0.3s
  [1] 玩家周围爆发蓝色粒子环SpawnParticle: ShadePop_VFX
  [2] UIGeo 数字跳动动画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完成后 CameraStateControllerNavSurface重新配置
OnSavePointActivated.asset VoidEventChannelSO SavePoint SaveManagerHUD
OnAbilityUnlocked.asset IntEventChannelSO(存 AbilityType AbilityUnlock PlayerStatsHUD(提示)
OnCollectiblePickedUp.asset StringEventChannelSO(存 ID Collectible SaveManager
OnBossFightStarted.asset VoidEventChannelSO BossRoomTrigger CameraStateControllerAudioManager
OnBossFightEnded.asset VoidEventChannelSO BossBase(死亡时) GameManagerAudioManagerDoor开门
OnDestructibleDestroyed.asset StringEventChannelSO(存 ID DestructibleTile SaveManagerMapManagerP1
OnDestructibleRespawned.asset StringEventChannelSO(存 ID DestructibleTile MapManagerP1
OnMechanismTriggered.asset StringEventChannelSO(存 ID DirectionalInteractable SaveManager
OnPlayerDied.asset VoidEventChannelSO PlayerStats DeathShadeManagerGameManager(触发读档)
OnGeoRecovered.asset IntEventChannelSOGeo 数值) DeathShadeManager PlayerStats(增加 Geo
OnGeoSpent.asset IntEventChannelSO(花费量) ShopController PlayerStats(扣减 Geo
OnShopOpened.asset VoidEventChannelSO ShopController InputReader(切 ActionMapShopUI
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 2DComposite Collider 2D
    ├── Platforms       → 单向平台PlatformEffector2DrotationalOffset 180°
    ├── Hazards         → 即死刺/熔岩(仅触发器,无 HazardZone 的场景装饰)
    ├── SoftGround      → 松软地面Layer: Ground挂 SoftTerrain Marker 组件;地行术无消耗穿行)
    ├── MagicWall       → 魔法障壁Layer: MagicWallGhost 层可穿越,见 §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 中):

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

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

namespace BaseGames.World
{
    /// <summary>
    /// 管理房间的预加载与卸载,确保内存中仅保留 "当前 + 相邻" 房间。
    /// 挂载于常驻场景PersistentScene
    /// </summary>
    public class RoomStreamingManager : MonoBehaviour
    {
        [Header("事件频道")]
        [SerializeField] StringEventChannelSO _onRoomEntered;

        [Header("配置")]
        [SerializeField] int _keepAroundHops = 1; // 保留当前房间 ±1 跳的邻近房间

        readonly HashSet<string> _loadedScenes     = new();
        readonly HashSet<string> _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<string>(_loadedScenes);
            foreach (var s in toUnload)
                if (!toKeep.Contains(s))
                    await UnloadSceneAsync(s);
        }

        /// <summary>由 RoomTransition 在玩家靠近出口时调用,触发预加载。</summary>
        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<string> GetNeighborScenes(string sceneId, int hops)
            => RoomGraphSO.Instance.GetNeighbors(sceneId, hops);
    }
}

16.2 Addressables 场景卸载规范

当场景通过 Addressables.LoadSceneAsync 加载时,必须通过对应句柄卸载,否则引用计数不归零:

readonly Dictionary<string, AsyncOperationHandle<SceneInstance>> _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 — 房间邻接表

[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<string, string[]> _graph = new();

    void OnEnable()
    {
        Instance = this;
        foreach (var r in rooms)
            _graph[r.sceneId] = r.neighbors;
    }

    /// <summary>BFS返回 hops 跳以内的所有邻居 ID。</summary>
    public HashSet<string> GetNeighbors(string sceneId, int hops)
    {
        var result = new HashSet<string>();
        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 MBkeepAroundHops = 1 最坏约 255 MB留有安全余量。

Boss 战期间可将 _keepAroundHops 临时降至 0战后恢复 1避免大型 Boss 场景与多邻居同时驻留超预算。