1516 lines
55 KiB
Markdown
1516 lines
55 KiB
Markdown
# 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<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 死亡遗骸数据
|
||
|
||
```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<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
|
||
```
|
||
|
||
```csharp
|
||
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)
|
||
|
||
**设计目标**:玩家进入遗骸所在房间时,遗骸对玩家产生微弱的"召唤感",辅助玩家找到遗骸位置(尤其在大型房间)。
|
||
|
||
```csharp
|
||
/// <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),强度随距离增加:
|
||
|
||
```csharp
|
||
// 在 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` 时,在地图上对应位置显示**闪烁的骷髅图标**:
|
||
|
||
```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
|
||
{
|
||
/// <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` 加载时,必须通过对应句柄卸载,否则引用计数不归零:
|
||
|
||
```csharp
|
||
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 — 房间邻接表
|
||
|
||
```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<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 场景与多邻居同时驻留超预算。
|