55 KiB
08 · 世界系统
命名空间
BaseGames.World
所属文档集 ← 返回索引 · 总览
依赖BaseGames.Core.Events·BaseGames.Player· PathBerserker2d(NavSurface)
目录
- 系统总览
- 场景结构规范
- 房间过渡系统
- 存档系统
- SaveData JSON Schema
- 危险区域
- 可收集物
- 能力解锁物件
- 可交互环境
- 地图系统(P1)
- 商店 NPC
- 死亡遗骸(DeathShade)
- 世界事件频道
- 编辑器友好设计
- 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
{
"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 持久化:
// 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
魔法障壁是只能被太虚斩穿越的特殊墙体。太虚斩施放期间玩家切换至 Ghost 物理层,MagicWall 对 Ghost 层无碰撞,完全由 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 房间(进度锁) |
| 解谜障壁 | 需在障壁另一侧完成某操作,再太虚斩穿越取物 |
Gizmo(Scene 视图):紫色实线矩形;鼠标悬停显示"MagicWall — Ghost 层可穿越"标注。
9.10 松软地面 (SoftTerrain)
关联技能:地行术(地魂 SoulSkill) · 架构实现:见 08_WorldModule §15.2
松软地面是配合地行术的特殊地块:进入 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
幻影机关继承 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):
"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 与地图系统联动
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 — 商品数据
[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)
├─ 若 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 死亡遗骸数据
[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 遗骸地图标记
地图系统(§10)在 deathShade.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 链):
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 中):
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 MB;keepAroundHops = 1 最坏约 255 MB,留有安全余量。
Boss 战期间可将
_keepAroundHops临时降至 0,战后恢复 1,避免大型 Boss 场景与多邻居同时驻留超预算。