diff --git "a/.github/docs/DrumGo \346\212\200\346\234\257\346\226\207\346\241\243.md" "b/.github/docs/DrumGo \346\212\200\346\234\257\346\226\207\346\241\243.md" new file mode 100644 index 0000000..6e64581 --- /dev/null +++ "b/.github/docs/DrumGo \346\212\200\346\234\257\346\226\207\346\241\243.md" @@ -0,0 +1,422 @@ +# DrumGo 技术文档 + +> 本文档基于仓库当前代码与资源状态编写(Swift 源码位于 `DrumGo/`,Reality Composer Pro 资源位于 `Packages/RealityKitContent/`)。 + +## 1. 项目概览 + +**DrumGo** 是一个面向 **visionOS(Apple Vision Pro)** 的沉浸式交互项目:在 `ImmersiveSpace` 中加载 3D 架子鼓场景,通过 **手部追踪(ARKit Hand Tracking)** 驱动鼓棒实体,与鼓/镲的碰撞触发 **音效播放** 与 **Reality Composer Pro 行为动画**;同时包含多个 SwiftUI 窗口用于选择/展示内容(含体积窗口展示 IP 模型)。 + +关键特性: + +- **多窗口**:`ContentView` → `SelectView` → 打开 `ImmersiveSpace`,以及体积窗口 `IPModelView` +- **沉浸空间**:`ImmersiveSpace(id: "DrumImmersive")` 加载 `ImmersiveView` +- **手部追踪**:`ARKitSession + HandTrackingProvider`,将鼓棒实体绑定到手部关节姿态 +- **碰撞驱动**:鼓棒末端 `StickSphere` 与鼓面/镲实体发生 `CollisionEvents.Began`,触发音效 + 行为动画 +- **谱面驱动生成物**:读取 `DrumGo/Soundjson/Missing U.json`(Beatmap/Stage),按时间调度生成 `CircleAnimation`(作为“节奏提示/可击打物”) +- **Reality Composer Pro 行为触发**:通过 `NotificationCenter` 发布 `"RealityKit.NotificationTrigger"` 通知,驱动 `IntactDrumScene.usda` 内的 `OnNotification*` 行为播放 Timeline + +## 2. 技术栈与平台 + +- 语言/框架:Swift / SwiftUI / RealityKit / ARKit +- 资源系统:Reality Composer Pro(`.rkassets` / `.usda` / `.usdz`) +- 音频: + - 歌曲:`AVAudioPlayer` 播放 `DrumGo/Missing U.mp3` + - 鼓点音效:`AudioFileResource`(RealityKit)播放 `DrumGo/Sound/*.MP3` +- 项目平台(由 `DrumGo.xcodeproj/project.pbxproj` 定义): + - `SDKROOT = xros` + - `SUPPORTED_PLATFORMS = "xros xrsimulator"` + - `XROS_DEPLOYMENT_TARGET = 2.4` + - `TARGETED_DEVICE_FAMILY = 7`(Vision) + +## 3. 仓库结构 + +``` +DrumGo.xcodeproj/ Xcode 工程 +DrumGo/ App 主 Target 源码与资源(文件夹同步到 Target) + DrumGoApp.swift App 入口:WindowGroup + ImmersiveSpace + Info.plist 权限与沉浸空间配置 + View/ SwiftUI 窗口 UI + ContentView.swift + SelectView.swift + GameView.swift 目前为占位 + GameManage/ 业务/交互逻辑(游戏管理、沉浸视图等) + AppModel.swift 全局状态(ImmersiveSpaceState) + ImmersiveView.swift RealityView 容器,接入 GameManager + ManageGame.swift GameManager:手追踪、碰撞、音频、谱面调度 + Song.swift 歌曲 + 谱面(json)加载 + Stage.swift 谱面数据模型与坐标映射 + GameScene.swift 主题枚举(目前未接入) + IPDetailView.swift 体积窗口中展示 Model3D + Sound/ 鼓点音效 MP3(供 AudioFileResource 加载) + Soundjson/ 谱面 json(Stage/Note) + Assets.xcassets/ UI 图片资源(专辑封面、鼓/场景缩略图等) + +Packages/ + RealityKitContent/ Swift Package:Reality Composer Pro 资产 bundle + +DrumGoTests/ 测试 Target(Testing 框架) +``` + +## 4. SwiftUI 应用结构与场景管理 + +### 4.1 App 入口与 Scene + +入口:`DrumGo/DrumGoApp.swift` + +- `@State private var appModel = AppModel()`,通过 `.environment(appModel)` 注入(Observation) +- 创建 3 个窗口组: + - `WindowGroup(id: "ContentView")` → `ContentView` + - `WindowGroup(id: "SelectView")` → `SelectView` + - `WindowGroup(id:"IPModelView", for: String.self)` → `IPDetailView(modelName:)` + - `.windowStyle(.volumetric)`:体积窗口(3D) +- 创建沉浸空间: + - `ImmersiveSpace(id: "DrumImmersive")` → `ImmersiveView` + - 在 `onAppear/onDisappear` 更新 `appModel.immersiveSpaceState` + +### 4.2 全局状态(ImmersiveSpace 状态机) + +文件:`DrumGo/GameManage/AppModel.swift` + +- `AppModel.ImmersiveSpaceState`:`closed` / `inTransition` / `open` +- `immersiveSpaceID = "DrumImmersive"` 与 `DrumGoApp` 中一致 + +`SelectView` 使用该状态机做“开关沉浸空间”的按钮: + +- `openImmersiveSpace(id:)` 成功后由 `ImmersiveSpace` 的 `onAppear` 将状态置为 `.open` +- `dismissImmersiveSpace()` 后由 `onDisappear` 将状态置为 `.closed` + +## 5. UI 层(窗口) + +### 5.1 ContentView(首页/入口) + +文件:`DrumGo/View/ContentView.swift` + +- 展示横向滚动的“专辑封面”图片(`Album1...Album10`) +- 主要按钮: + - 点击后 `dismissWindow(id: "ContentView")` + - `openWindow(id: "SelectView")` 打开选择页 + +### 5.2 SelectView(选择鼓/场景 + 打开沉浸空间 + 打开 IP 模型) + +文件:`DrumGo/View/SelectView.swift` + +- 分段选择: + - `selectedTab == 0`:鼓缩略图(`Drum1...Drum6`) + - `selectedTab == 1`:场景缩略图(`Scene 1...Scene 6`) +- “IP 模型”: + - `openWindow(id: "IPModelView", value: "IP1")` + - `openWindow(id: "IPModelView", value: "IP2")` +- “Go!/Quilt”按钮:驱动 `ImmersiveSpace` 打开/关闭(基于 `appModel.immersiveSpaceState`) + +> 注意:目前 UI 的“选择鼓/场景”仅在本页保存 `selectedIndex`,未与 `GameManager` 或 RealityKit 场景联动。 + +### 5.3 IPDetailView(体积窗口 Model3D) + +文件:`DrumGo/GameManage/IPDetailView.swift` + +- `Model3D(named: modelName, bundle: realityKitContentBundle)` +- `modelName` 由 `SelectView` 传入(`"IP1"` / `"IP2"`) + +`RealityKitContent` 内的 IP 资源(示例): + +- `Packages/RealityKitContent/.../IP1.usda` +- `Packages/RealityKitContent/.../IP2.usda` + +其中 `IP2.usda` 自带 `OnTap` / `OnAddedToScene` 行为(Timeline 动画),在体积窗口内可直接交互触发。 + +## 6. 沉浸空间与 RealityKit 运行时 + +### 6.1 ImmersiveView(RealityView 容器) + +文件:`DrumGo/GameManage/ImmersiveView.swift` + +- `RealityView` 加载: + - `Entity(named: "IntactDrumScene", in: realityKitContentBundle)`(主鼓场景) + - `content.add(gameManager.root)`:将 `GameManager.root` 插入场景(用于承载手部鼓棒、生成的 Circle 等) +- 启动: + - `.onAppear { gameManager.start() }` +- 手势: + - `SpatialTapGesture().targetedToAnyEntity()`:对任意实体点击,尝试按名称逻辑触发对应 `handle*Punch` + +碰撞订阅(在 `RealityView` builder 内调用): + +- `gameManager.handleCrashCymbal1Collision(content:)` +- `gameManager.handleCrashCymbal2Collision(content:)` +- `gameManager.handleRideCymbalCollision(content:)` +- `gameManager.handleTom1Collision(content:)` +- `gameManager.handleTom2Collision(content:)` +- `gameManager.handleSnareCollision(content:)` +- `gameManager.handleFloorTomCollision(content:)` +- `gameManager.handleCircleCollision(content:)` + +> 重要:当前 `GameManager` 仅用一个 `colisionSubs: EventSubscription?` 保存订阅句柄,连续调用多个 `handle*Collision` 会覆盖前一个订阅,可能导致只有最后一次订阅能持续生效(详见“已知问题”)。 + +### 6.2 GameManager(核心:手追踪 + 音频 + 谱面 + 碰撞) + +文件:`DrumGo/GameManage/ManageGame.swift` + +核心成员: + +- 单例:`static let shared` +- `let root = Entity()`:作为运行时根节点,挂载到 `RealityViewContent` +- 手追踪: + - `private let session = ARKitSession()` + - `private let handTracking = HandTrackingProvider()` + - `leftHand/rightHand`:分别加载 `DrumStickLeft` / `DrumStick` +- 音频资源: + - 鼓点:`AudioFileResource(named: "...")`(如 `MilitaryDrum.MP3`、`CrashCymbal.MP3`) + - 歌曲:`Song.player`(`AVAudioPlayer`,来自 `Song.swift`) +- 谱面调度: + - `Stage`(从 `Soundjson/*.json` 解码) + - `scheduleTasks(notes:startTime:)`:按 note 时间调度 `spawnBox(note:)` + - `boxTemplate`:`Entity.load(named: "CircleAnimation", in: realityKitContentBundle)` + +生命周期:`start()` + +1. 重置并播放歌曲:`selectedSong.player.play()` +2. 调度生成 Circle:`scheduleTasks(notes: selectedSong.stage.notes, startTime: .now())` +3. 启动手追踪:`session.run([handTracking])` 并 `processHandUpdates()` + +#### 6.2.1 手追踪实体绑定 + +`processHandUpdates()`: + +- 监听 `handTracking.anchorUpdates` +- 取 `.middleFingerKnuckle` 关节(骨骼 joint),将 `leftHand/rightHand` 的 transform 设为该关节变换 +- 若实体尚未加入场景:`root.addChild(entity)` + +碰撞检测依赖鼓棒末端球体实体名后缀: + +- `DRUMSTICK_ENTITY_NAME_SUFFIX = "StickSphere"` +- 对应资产:`Packages/RealityKitContent/.../DrumStick.usda` 中包含子节点 `StickSphere`(带 `Collider`) + +#### 6.2.2 鼓/镲实体命名约定(与资产强绑定) + +代码内用于判定“被击打的鼓/镲”的名称常量: + +- `SNAREDRUM_ENTITY_NAME = "Group"` +- `TOM1_ENTITY_NAME = "Group_1"` +- `TOM2_ENTITY_NAME = "Group_2"` +- `FLOORTOM_ENTITY_NAME = "Group_3"` +- `CRASHCYMBAL1_ENTITY_NAME = "cha1"` +- `CRASHCYMBAL2_ENTITY_NAME = "cha2"` +- `RIDECYMBAL_ENTITY_NAME = "cha3"` +- `CIRCLE_ENTITY_NAME = "Circle"` + +这些名称来自 `IntactDrumScene.usda`(鼓场景)与 `CircleAnimation.usda`(节奏圈模板)中的实体命名。若在 RCP 中重命名,会导致碰撞判定失效。 + +#### 6.2.3 碰撞 → 音频 → 行为动画通知 + +每个 `handle*Collision(content:)`: + +- 订阅 `CollisionEvents.Began` +- 检测 A/B 两个实体: + - 鼓棒:`name.hasSuffix("StickSphere")` + - 目标:`name == 常量实体名` +- 命中后调用对应 `handle*Punch(drum:)` + +每个 `handle*Punch(drum:)` 的共通结构: + +1. `parent.stopAllAnimations()` +2. 在 `parent` 下查找音频实体名(例如 `"Tom1"`、`"Snare"`、`"RideCymbal"`),调用 `playAudio(audioResource)` +3. 通过 `NotificationCenter.default.post(name: "RealityKit.NotificationTrigger", userInfo: ...)` 发布通知 + +通知的 `Identifier` 与 `IntactDrumScene.usda` 内 `OnNotification*` 行为触发器对齐,例如: + +- `"Tom1Collision"` / `"Tom2Collision"` / `"SnareCollision"` / `"FloorTomCollision"` +- `"CrashCymbal1Collision"` / `"CrashCymbal2Collision"` / `"RideCymbalCollision"` +- 同时也会发送 `"HeartAnimation"` / `"Heart1Animation"` / `"Heart2Animation"` / `"LightShowAnimation"`(用于场景特效) + +资产侧映射示例(位于 `Packages/RealityKitContent/.../IntactDrumScene.usda`): + +- `OnNotification`:identifier = `"Tom2Collision"` → PlayTimeline `/Root/Tom2Animation` +- `OnNotification4`:identifier = `"SnareCollision"` → PlayTimeline `/Root/SnareAnimation` +- `OnNotification11`:identifier = `"LightShowAnimation"` → PlayTimeline `/Root/LightShow` + +#### 6.2.4 谱面(Stage/Note)与 Circle 生成 + +谱面模型:`DrumGo/GameManage/Stage.swift` + +- `Stage` 对应 json 顶层:`_version` / `_events` / `_notes` / `_obstacles` +- `Note`:`_time`、`_lineIndex`、`_lineLayer` 等(类 Beat Saber 风格) + +`generateStart(note:)` 将 `(lineLayer, lineIndex)` 映射到一个 3D 坐标(单位以米为主,直接返回 `Point3D`)。 + +生成逻辑:`GameManager.spawnBox(note:)` + +- `boxTemplate`(`CircleAnimation`)clone 一份 +- 通过 `generateStart(note:)` 计算目标点,并向 `z` 额外偏移 `-2` +- 播放 `generateBoxMovementAnimation(start:)` 生成的 Transform FromTo 动画 +- 将 box(实际是 CircleAnimation 的 Root)加入 `root` + +调度逻辑:`scheduleTasks(notes:startTime:)` + +- 对每个 note,`DispatchQueue.main.asyncAfter(deadline:)` 触发生成 +- 销毁:`BoxSpawnParameters.lifeTime`(默认 3 秒)后移除 + +> 注意:调度使用 `startTime + note.time * 0.5 - lifeTime/2`,这里的 `0.5` 目前像是经验系数/对齐参数,未在文档或代码中解释。 + +#### 6.2.5 Circle 可击打物(ParticleEmitter burst) + +Circle 模板资产:`Packages/RealityKitContent/.../CircleAnimation.usda` + +- 子节点: + - `Circle`(带 `Collider` 与 `InputTarget`) + - `ParticleEmitter`(VFXEmitter,默认 `isEmitting = 0`) + +命中后:`handleCirclePunch(drum:)` + +- 在 `parent` 查找 `"ParticleEmitter"`,调用 `burst()` +- 移除 `Circle` 本体,0.5 秒后移除 parent(整个模板实例) + +## 7. 音频系统 + +### 7.1 歌曲播放(AVAudioPlayer) + +文件:`DrumGo/GameManage/Song.swift` + +- `AVAudioPlayer(contentsOf: Bundle.main.url(forResource: name, withExtension: "mp3")!)` +- 谱面 json:`Bundle.main.url(forResource: name, withExtension: "json")!` + +当前仅内置一首: + +- `Song(name: "Missing U", defaultGameScene: .painting)` + +对应资源: + +- `DrumGo/Missing U.mp3` +- `DrumGo/Soundjson/Missing U.json` + +### 7.2 鼓点音效(AudioFileResource) + +在 `GameManager.init()` 异步加载: + +- `MilitaryDrum.MP3`(Snare) +- `SoundDrum.MP3`(Tom1) +- `BassDrum.MP3`(Tom2 / FloorTom) +- `HiHat.MP3`、`CrashCymbal.MP3`、`RideCymbal.MP3` + +资源位于 `DrumGo/Sound/`,确保文件名大小写与扩展名完全匹配。 + +## 8. 权限与 Info.plist + +文件:`DrumGo/Info.plist` + +- `NSWorldSensingUsageDescription`:环境感知说明 +- `NSHandsTrackingUsageDescription`:手部追踪说明 +- `UIApplicationSceneManifest`: + - `UISceneSessionRoleImmersiveSpaceApplication` + - `UISceneInitialImmersionStyle = UIImmersionStyleFull` + +## 9. RealityKitContent 包(资产与 bundle) + +Swift Package:`Packages/RealityKitContent/Package.swift` + +- `platforms`:`visionOS(.v2)`, `macOS(.v15)`, `iOS(.v18)`(用于构建该资源包) +- `public let realityKitContentBundle = Bundle.module`:`Packages/RealityKitContent/Sources/RealityKitContent/RealityKitContent.swift` + +核心资产(部分): + +- `IntactDrumScene.usda`:主鼓场景(鼓、镲、灯光、心形特效、行为触发器) +- `DrumStick.usda` / `DrumStickLeft.usda`:鼓棒(含 `StickSphere` collider) +- `CircleAnimation.usda`:节奏圈(含 `ParticleEmitter`) +- `IP1.usda` / `IP2.usda`:体积窗口展示模型(部分含交互/动画) + +## 10. 构建、运行与测试 + +### 10.1 通过 Xcode + +1. 打开 `DrumGo.xcodeproj` +2. 选择 scheme:`DrumGo` +3. 选择运行目标:Vision Pro Simulator 或真机 +4. Run + +### 10.2 CLI(可选) + +在支持 xcodebuild 的环境下可使用(destination 需按本机可用设备调整): + +```bash +xcodebuild -project DrumGo.xcodeproj -scheme DrumGo -configuration Debug build +``` + +### 10.3 测试 + +测试 Target:`DrumGoTests/DrumGoTests.swift`(使用 Swift `Testing` 框架) + +当前为示例占位,尚未覆盖任何逻辑。 + +## 11. 扩展指南(如何加内容) + +### 11.1 添加新歌曲/谱面 + +1. 增加资源: + - `DrumGo/.mp3` + - `DrumGo/Soundjson/.json` +2. 更新 `GameManager`: + - 在 `DrumGo/GameManage/ManageGame.swift` 的 `songs = [...]` 中追加 `Song(name: "", defaultGameScene: ...)` +3. (建议)在 `ContentView` 的图片列表与 UI 上加入对应入口,并把选择结果写入 `GameManager.selectedSong` + +### 11.2 修改谱面到 3D 空间的映射 + +编辑 `DrumGo/GameManage/Stage.swift`: + +- `generateStart(note:)`:控制每个 `(lineIndex, lineLayer)` 对应的 3D 坐标 +- `BoxSpawnParameters`:控制生成提前量/寿命/偏移等参数 + +### 11.3 新增/替换鼓模型与命名 + +如果在 Reality Composer Pro 中替换鼓或改名: + +1. 确保鼓/镲实体的 `name` 与 `ManageGame.swift` 中常量一致,或同步更新常量: + - 例如 `TOM1_ENTITY_NAME = "Group_1"` +2. 确保每个鼓/镲的音频实体名匹配 `handle*Punch` 中 `findEntity(named:)`: + - 例如 `findEntity(named: "Tom1")` +3. 若希望碰撞触发 RCP 动画: + - `ManageGame` 发送的 `"RealityKit.NotificationTrigger.Identifier"` 必须与 `IntactDrumScene.usda` 中 `OnNotification*` 的 `identifier` 一致 + +### 11.4 新增 IP 模型(体积窗口) + +1. 将模型放入 `Packages/RealityKitContent/.../RealityKitContent.rkassets/`(RCP 或手动) +2. 确保资源名(例如 `"IP3"`)可被 `Model3D(named:)` 解析 +3. 在 `SelectView` 中追加按钮:`openWindow(id: "IPModelView", value: "IP3")` + +## 12. 已知问题与技术债(强烈建议优先处理) + +1. **碰撞订阅句柄被覆盖** + - `GameManager` 只有一个 `colisionSubs: EventSubscription?` + - `ImmersiveView` 依次调用多个 `handle*Collision`,每个方法都会 `colisionSubs = content.subscribe(...)` 覆盖前一个订阅 + - 结果可能是:只有最后一次订阅(例如 `handleCircleCollision`)能长期有效 +2. **ImmersiveView 内存在未使用的 ARKitSession/HandTrackingProvider 状态** + - `ImmersiveView` 自己声明了 `session/handTracking` 等,但实际手追踪在 `GameManager` 内完成 +3. **谱面调度时间系数缺少解释** + - `note.time * 0.5` 可能导致节奏与歌曲不同步,需要明确 BPM/时间基准 +4. **选择页未与实际内容联动** + - `SelectView` 的鼓/场景选择没有写入任何模型状态,也未影响沉浸场景 +5. **重复/未使用代码** + - `Stage.swift` 与 `ManageGame.swift` 均有 `generateBoxMovementAnimation`(后者未必需要) + - `View/GameView.swift` 当前为占位 +6. **测试缺失** + - `DrumGoTests` 仅有空示例,关键逻辑(谱面解码/调度、映射函数、状态机)无覆盖 + +--- + +## 附录 A:关键标识符速查 + +**ImmersiveSpace** + +- `AppModel.immersiveSpaceID`:`"DrumImmersive"` +- `DrumGoApp`:`ImmersiveSpace(id: "DrumImmersive")` + +**通知(Reality Composer Pro 行为触发)** + +- 通知名:`"RealityKit.NotificationTrigger"` +- userInfo keys: + - `"RealityKit.NotificationTrigger.Scene"`(`Scene`) + - `"RealityKit.NotificationTrigger.Identifier"`(字符串 identifier) + +**实体命名(碰撞判定)** + +- 鼓棒末端:`StickSphere`(后缀匹配) +- 鼓/镲:`Group` / `Group_1` / `Group_2` / `Group_3` / `cha1` / `cha2` / `cha3` +- 节奏圈:`Circle` + diff --git "a/.github/docs/DrumGo\344\270\232\345\212\241\345\205\250\345\261\200\345\234\260\345\233\276.md" "b/.github/docs/DrumGo\344\270\232\345\212\241\345\205\250\345\261\200\345\234\260\345\233\276.md" new file mode 100644 index 0000000..32bd405 --- /dev/null +++ "b/.github/docs/DrumGo\344\270\232\345\212\241\345\205\250\345\261\200\345\234\260\345\233\276.md" @@ -0,0 +1,169 @@ +# DrumGo — Business Logic Map(业务全局地图) + +> 面向「不读代码的 AI」:用最少上下文描述 DrumGo 做什么、为什么这样做、以及关键入口在哪里。 +> 代码与资源路径均为仓库相对路径,可直接定位。 + +## 1. 产品概述 + +DrumGo 是一款面向 **visionOS(Apple Vision Pro)** 的沉浸式架子鼓/节奏互动体验,面向想在沉浸空间里“用手打鼓、获得即时音效与特效反馈”的用户。核心体验是在 `ImmersiveSpace` 中看到一套 3D 鼓组,用户双手的鼓棒随手部追踪移动,击打鼓/镲会播放对应音效,并触发场景内的动画/灯光等特效。技术栈:SwiftUI + RealityKit + ARKit(Hand Tracking)+ Reality Composer Pro 资产包。 + +## 2. 架构分层 + +### 目录结构 → 职责 + +- `DrumGo/DrumGoApp.swift`:应用入口与 scene 定义(多窗口 + ImmersiveSpace) +- `DrumGo/View/`:**UI 层**(SwiftUI 窗口),负责入口、选择、打开沉浸空间/体积窗口 + - `DrumGo/View/ContentView.swift` + - `DrumGo/View/SelectView.swift` +- `DrumGo/GameManage/`:**交互/业务协调层** + - `DrumGo/GameManage/AppModel.swift`:全局状态(沉浸空间开关状态机) + - `DrumGo/GameManage/ImmersiveView.swift`:沉浸空间容器(RealityView + 交互手势) + - `DrumGo/GameManage/ManageGame.swift`:游戏管理器(手追踪、碰撞、音频、谱面调度) + - `DrumGo/GameManage/Song.swift`:歌曲(mp3)+ 谱面(json)加载与绑定 + - `DrumGo/GameManage/Stage.swift`:谱面数据结构与 3D 坐标映射 + - `DrumGo/GameManage/IPDetailView.swift`:体积窗口展示 RealityKitContent 内的模型 +- `Packages/RealityKitContent/`:**资源层(RCP)** + - `Packages/RealityKitContent/Sources/RealityKitContent/RealityKitContent.rkassets/IntactDrumScene.usda`:主鼓场景与行为触发器 + - `Packages/RealityKitContent/Sources/RealityKitContent/RealityKitContent.rkassets/DrumStick*.usda`:鼓棒与碰撞球体(`StickSphere`) + - `Packages/RealityKitContent/Sources/RealityKitContent/RealityKitContent.rkassets/CircleAnimation.usda`:节奏圈(含粒子) + - `Packages/RealityKitContent/Sources/RealityKitContent/RealityKitContent.rkassets/IP1.usda`、`IP2.usda`:IP 模型 + +### 依赖方向(约束) + +- `DrumGo/View/*` 只通过环境(`AppModel`)与系统环境值(`openImmersiveSpace/openWindow`)驱动导航;不直接操控 RealityKit 细节。 +- `DrumGo/GameManage/*` 可以依赖 `RealityKitContent`(资源 bundle)与系统框架(RealityKit/ARKit/AVFAudio)。 +- `Packages/RealityKitContent/*` 仅提供资源与 bundle 入口(`Bundle.module`),不应反向依赖 App 代码。 + +禁止(建议保持): + +- UI 层直接读取/修改 `.usda` 资源细节或硬编码实体名(这些应集中在 `ManageGame.swift`)。 + +## 3. 核心业务流程 + +### 流程 A:进入沉浸空间并打鼓 + +1. 用户打开应用,看到首页 UI:`DrumGo/View/ContentView.swift` +2. 用户点击按钮进入选择页:`openWindow(id: "SelectView")`(`DrumGo/View/ContentView.swift`) +3. 用户在选择页点击 “Go!” 打开沉浸空间: + - UI:`DrumGo/View/SelectView.swift` + - 状态机:`DrumGo/GameManage/AppModel.swift`(`immersiveSpaceState`) +4. 系统打开 `ImmersiveSpace(id: "DrumImmersive")`: + - `DrumGo/DrumGoApp.swift` → `DrumGo/GameManage/ImmersiveView.swift` +5. 沉浸空间加载 3D 鼓场景并启动逻辑: + - 加载:`Entity(named: "IntactDrumScene", in: realityKitContentBundle)`(`ImmersiveView`) + - 启动:`GameManager.shared.start()`(`DrumGo/GameManage/ManageGame.swift`) +6. 手部追踪开始:鼓棒实体跟随手部关节移动(`ManageGame.swift: processHandUpdates()`) +7. 用户击打鼓/镲: + - 碰撞事件:`CollisionEvents.Began`(`ManageGame.swift: handle*Collision(...)`) + - 播放音效:`audioEntity.playAudio(AudioFileResource)`(`ManageGame.swift: handle*Punch(...)`) + - 触发特效:发布 `"RealityKit.NotificationTrigger"` 通知(`ManageGame.swift`) + - 资源行为响应:`IntactDrumScene.usda` 内 `OnNotification*` 根据 identifier 播放 Timeline(例如鼓面弹跳/灯光/心形特效) + +### 流程 B:谱面驱动的节奏圈生成与击打 + +1. 启动时选择歌曲:`ManageGame.swift` 内置 `Song(name: "Missing U", ...)` +2. 读取谱面: + - json:`DrumGo/Soundjson/Missing U.json` + - 解码:`DrumGo/GameManage/Stage.swift` +3. 调度生成节奏圈: + - `ManageGame.swift: scheduleTasks(...)` 根据 note 时间 `asyncAfter` 触发 + - 模板:`CircleAnimation`(`RealityKitContent` 资源) +4. 击打节奏圈: + - 碰撞判定实体名:`"Circle"`(`ManageGame.swift` 常量) + - 命中后 burst 粒子并移除实体:`ManageGame.swift: handleCirclePunch(drum:)` + +### 流程 C:打开 IP 体积窗口并交互 + +1. 用户在选择页点击 IP 按钮: + - `DrumGo/View/SelectView.swift` → `openWindow(id: "IPModelView", value: "IP1"|"IP2")` +2. 体积窗口渲染模型: + - `DrumGo/DrumGoApp.swift` → `DrumGo/GameManage/IPDetailView.swift` + - `Model3D(named: modelName, bundle: realityKitContentBundle)` +3. 模型交互(资产内置行为): + - 例如 `IP2.usda` 自带 `OnTap` Timeline,可在窗口内点击触发动画 + +## 4. 模块详情 + +### 4.1 沉浸空间状态机(AppModel) + +- 做什么:统一管理沉浸空间是否打开/切换中,避免重复触发 open/dismiss。 +- 关键文件: + - `DrumGo/GameManage/AppModel.swift` + - `DrumGo/View/SelectView.swift`(驱动打开/关闭) + - `DrumGo/DrumGoApp.swift`(`onAppear/onDisappear` 作为状态最终写入点) + +### 4.2 GameManager(交互中枢) + +- 做什么: + - 资产加载:鼓场景模板、节奏圈模板、鼓棒实体 + - 音频资源加载:鼓点音效 + - 歌曲播放:`AVAudioPlayer` + - 谱面调度:根据 note 时间生成节奏圈 + - 手追踪:将鼓棒实体绑定到手部关节 + - 碰撞与点击:命中后播放音效并触发 RCP 行为 +- 关键文件: + - `DrumGo/GameManage/ManageGame.swift` + - `DrumGo/GameManage/Song.swift` + - `DrumGo/GameManage/Stage.swift` + +### 4.3 RealityKitContent(资产包) + +- 做什么:提供所有 Reality Composer Pro 资源(鼓场景、鼓棒、特效、IP 模型)。 +- 关键文件: + - `Packages/RealityKitContent/Sources/RealityKitContent/RealityKitContent.swift`(`Bundle.module`) + - `Packages/RealityKitContent/Sources/RealityKitContent/RealityKitContent.rkassets/IntactDrumScene.usda` + - `Packages/RealityKitContent/Sources/RealityKitContent/RealityKitContent.rkassets/CircleAnimation.usda` + +### 4.4 权限与系统配置 + +- 做什么:声明手追踪/环境感知权限,配置沉浸空间初始沉浸样式。 +- 关键文件: + - `DrumGo/Info.plist` + +## 5. 当前状态与待办 + +### 已完成 + +- [x] visionOS 多窗口结构(首页/选择页/体积窗口)与沉浸空间入口:`DrumGo/DrumGoApp.swift` +- [x] 加载主鼓场景 `IntactDrumScene` 并挂载运行时根实体:`DrumGo/GameManage/ImmersiveView.swift` +- [x] 手部追踪驱动鼓棒实体跟随:`DrumGo/GameManage/ManageGame.swift` +- [x] 鼓/镲碰撞触发音效播放:`DrumGo/GameManage/ManageGame.swift` +- [x] 通过 `"RealityKit.NotificationTrigger"` 触发 RCP 行为动画/灯光/特效:`ManageGame.swift` + `IntactDrumScene.usda` +- [x] 谱面 json 解码与按时间生成节奏圈:`Stage.swift` + `ManageGame.swift` +- [x] 节奏圈命中粒子 burst 与移除:`ManageGame.swift` + `CircleAnimation.usda` + +### 进行中 + +- [ ] 选择页“鼓/场景/歌曲”与实际沉浸内容的联动(目前仅 UI 选中态,不影响 `GameManager` 或资产) + +### 已知问题 + +- 碰撞订阅句柄覆盖:`ManageGame.swift` 只有一个 `colisionSubs`,多次订阅可能只保留最后一次(导致部分鼓不响应碰撞)。 +- 沉浸视图内存在未使用的 ARKit 会话状态:`DrumGo/GameManage/ImmersiveView.swift` 中 `session/handTracking` 等未参与逻辑。 +- 谱面时间基准不明确:`scheduleTasks` 中使用 `note.time * 0.5`,缺少同步依据(BPM/offset)。 +- 测试缺失:`DrumGoTests/DrumGoTests.swift` 仅占位。 + +### 下一步计划(建议优先级) + +1. 修复碰撞订阅的生命周期管理(改为数组/多订阅保存),确保所有鼓/镲都可稳定响应。 +2. 把 `SelectView` 的选择结果写入可共享状态(例如扩展 `AppModel` 或新增 `GameSettings`),并让 `GameManager` 根据选择切换: + - 不同鼓模型/不同主题场景/不同歌曲与谱面 +3. 明确谱面时间与歌曲的同步策略(offset、倍速、BPM),将魔法系数参数化。 +4. 补最小单测:`Stage` 解码、`generateStart(note:)` 映射、`ImmersiveSpaceState` 状态机转换。 + +## 6. 设计决策记录 + +### 决策:用 Reality Composer Pro 行为(NotificationTrigger)驱动场景动画,而不是在 Swift 中逐帧控制 + +- 背景:鼓面弹跳、灯光、特效等更适合在可视化工具中调参;代码只需触发事件。 +- 选项: + - A) Swift/RealityKit 代码中管理动画资源并手动播放 + - B) RCP 内定义 Timeline + 通过通知触发(当前方案) +- 决定:选择 B +- 原因:资产侧可快速迭代视觉效果;代码侧只维护 identifier 对齐与触发时机,耦合更低。 + +## 7. 构建与测试 + +- Build:用 Xcode 打开 `DrumGo.xcodeproj`,选择 scheme `DrumGo`,运行到 Vision Pro Simulator 或真机。 +- Test:`DrumGoTests/DrumGoTests.swift`(Testing 框架)目前为空,需要补用例后再作为质量门槛。 + diff --git "a/.github/docs/OpenvisionOS \344\270\232\345\212\241.md" "b/.github/docs/OpenvisionOS \344\270\232\345\212\241.md" new file mode 100644 index 0000000..ee48751 --- /dev/null +++ "b/.github/docs/OpenvisionOS \344\270\232\345\212\241.md" @@ -0,0 +1,156 @@ +# OpenvisionOS 业务全局地图(business-logic) + +## 产品概述 +OpenvisionOS 是一个面向 visionOS 开发者与设计探索者的 3D 体验示例应用集合。目标用户是想快速学习 SwiftUI + RealityKit 场景组织方式的人。核心体验是在同一应用内打开多个独立窗口,分别浏览不同主题的 3D 模型与动效展示。技术栈为 Swift 5 + SwiftUI + RealityKit + 本地 USDZ 资产(含本地 Swift Package `RealityKitContent`)。 + +## 架构分层 +目录结构 → 职责说明 → 依赖方向 + +- `OpenvisionOS/OpenvisionOSApp.swift` → 应用编排层:注册窗口与入口场景 +- `OpenvisionOS/AirPodsMax/`、`OpenvisionOS/Own3DModel/`、`OpenvisionOS/NewYearFireworks/` → 场景展示层:每个模块负责一个主题场景 UI + 模型加载 +- `OpenvisionOS/**/*.usdz`、`OpenvisionOS/Assets.xcassets` → 资源层:模型与视觉资源 +- `Packages/RealityKitContent/` → 内容包层:作为 RealityKit 内容包与未来复用入口 +- `OpenvisionOSTests/` → 测试层:预留测试入口(当前为模板) + +依赖方向: + +- 应用编排层 → 场景展示层 +- 场景展示层 → 资源层 / RealityKit / SwiftUI +- 测试层 → 应用编排层与场景展示层(`@testable import OpenvisionOS`) + +禁止(建议约束): + +- 禁止跨场景模块直接互相依赖(例如 AirPods 模块直接调用 Flower 模块实现) +- 禁止把业务流程写入资源层(资源层只放素材,不放逻辑) + +## 核心业务流程 + +### 流程 1:用户进入并浏览 AirPods Max 动画窗口 +1. 用户在系统中打开应用后,看到三个可用窗口入口(由 `OpenvisionOS/OpenvisionOSApp.swift` 注册)。 +2. 用户进入 “AirPods Max” 窗口,加载 `OpenvisionOS/AirPodsMax/AirPodsMaxAnimation.swift`。 +3. 视图调用 `Model3D(named: "Airpods_Max_Pink")` 查找并加载 `OpenvisionOS/AirPodsMax/Airpods_Max_Pink.usdz`。 +4. 加载期间显示 `ProgressView` 占位;加载成功后显示模型。 +5. `phaseAnimator` 驱动持续旋转,用户看到 3D 模型循环转动并显示底部电量装饰信息。 + +### 流程 2:用户浏览 Flower Pot 组合场景 +1. 用户进入 “Flower Pot View” 窗口,渲染 `OpenvisionOS/Own3DModel/FlowerPotView.swift`。 +2. 场景在 `ZStack` 中分层加载多个模型:`pointSparkle`、`Flower-Port`、`BgSparcle`。 +3. 通过缩放与偏移控制前景点缀位置,背景模型增强空间氛围。 +4. 用户最终看到前中后景叠加的组合式 3D 视觉效果。 + +### 流程 3:用户浏览 New Year 主题场景 +1. 用户进入 “New Year Fireworks” 窗口,渲染 `OpenvisionOS/NewYearFireworks/NewYearFireworksTwentyFour.swift`。 +2. 视图加载 `newYear` 模型(`OpenvisionOS/NewYearFireworks/newYear.usdz`)。 +3. 场景按适配比例展示并留白,形成单主题沉浸展示页。 + +## 模块详情 + +### 模块 A:应用编排模块 +- 做什么:定义应用入口与窗口结构,决定用户可访问的体验集合。 +- 关键实现:通过多个 `WindowGroup` 并行注册体验,而不是单 Tab 内切。 +- 相关文件: + - `OpenvisionOS/OpenvisionOSApp.swift` + - `OpenvisionOS/Info.plist` +- 单测:无专门单测。 + +### 模块 B:AirPods Max 场景模块 +- 做什么:展示 AirPods 模型并持续旋转,作为动效示例。 +- 关键实现:使用 `phaseAnimator + rotation3DEffect` 形成无限旋转;工具栏添加设备状态感 UI。 +- 相关文件: + - `OpenvisionOS/AirPodsMax/AirPodsMaxAnimation.swift` + - `OpenvisionOS/AirPodsMax/Airpods_Max_Pink.usdz` + - `OpenvisionOS/AirPodsMax/Airpods_Max.usdz` + - `OpenvisionOS/AirPodsMax/kulaklıksketchfab.usdz` +- 单测:无专门单测。 + +### 模块 C:Flower Pot 场景模块 +- 做什么:构建多模型叠加场景,演示组合式空间画面搭建。 +- 关键实现:使用 `ZStack` 构建前后景,前景粒子点缀 + 主体花盆 + 背景闪烁层。 +- 相关文件: + - `OpenvisionOS/Own3DModel/FlowerPotView.swift` + - `OpenvisionOS/Own3DModel/Flower-Port.usdz` + - `OpenvisionOS/Own3DModel/pointSparkle.usdz` + - `OpenvisionOS/Own3DModel/BgSparcle.usdz` +- 单测:无专门单测。 + +### 模块 D:New Year Fireworks 场景模块 +- 做什么:提供节日主题场景展示页。 +- 关键实现:单模型加载 + 基础缩放布局,当前不含复杂交互。 +- 相关文件: + - `OpenvisionOS/NewYearFireworks/NewYearFireworksTwentyFour.swift` + - `OpenvisionOS/NewYearFireworks/newYear.usdz` +- 单测:无专门单测。 + +### 模块 E:RealityKitContent 包模块 +- 做什么:提供 RealityKit 内容包与 bundle 访问点。 +- 关键实现:当前仅导出 `Bundle.module`,作为后续共享资源或工具层预留。 +- 相关文件: + - `Packages/RealityKitContent/Package.swift` + - `Packages/RealityKitContent/Sources/RealityKitContent/RealityKitContent.swift` +- 单测:无。 + +### 模块 F:测试模块 +- 做什么:承载未来自动化测试。 +- 关键实现:目前仍是 Xcode 默认模板,未落地业务测试。 +- 相关文件: + - `OpenvisionOSTests/OpenvisionOSTests.swift` +- 单测:当前文件本身为模板测试入口。 + +## 当前状态与待办 + +### 已完成 +- [x] 完成多窗口架构(AirPods Max、Flower Pot、New Year 三个独立窗口) +- [x] 完成三个基础 3D 场景视图与本地模型加载 +- [x] 完成 AirPods 模块的持续旋转动效实现 +- [x] 完成核心 USDZ 资源打包与工程资源绑定 + +### 进行中 +- [ ] 场景工程化改造(复用组件、资源常量化、错误态展示)— 当前仍以演示代码为主,尚未抽象公共层 + +### 已知问题 +- 缺少有效单测,场景正确性依赖手工验证 +- 模型名硬编码为字符串,资源重命名有运行时风险 +- 场景异常处理较弱,仅有加载占位,缺少失败态与可观测日志 +- 三个场景存在重复 `Model3D` 加载模式,后续维护成本会增加 + +### 下一步计划 +1. 建立统一模型资源索引(例如 `enum` 常量)并替换硬编码字符串 +2. 抽取通用 `Model3D` 渲染容器(统一占位、错误态、缩放策略) +3. 为每个场景补最小可运行测试(至少覆盖视图可构建与关键资源可解析) +4. 增加新场景模板,验证模块化扩展路径与代码复用效果 +5. 评估将窗口注册改为配置驱动,降低入口文件修改成本 + +## 设计决策记录 + +### 决策 1:采用“多 WindowGroup”而不是“单 Window + Tab”作为默认入口 +- 背景:项目定位为 visionOS 多体验示例集合,需支持并行展示多个主题场景。 +- 选项: + - A. 单窗口 + Tab 切换 + - B. 多窗口(多个 `WindowGroup`) +- 决定:选 B(多窗口)。 +- 原因:更贴近 visionOS 空间多窗口使用方式,也便于独立演示每个场景。 + +### 决策 2:采用“模块就近资源存放”,而不是“全局资源大目录” +- 背景:当前项目主要是主题化演示模块,单模块内强依赖专属模型。 +- 选项: + - A. 所有 USDZ 集中到一个全局目录 + - B. 模块目录内就近存放本模块资源 +- 决定:选 B(就近存放)。 +- 原因:提高可读性与迁移便利性,新增/删除场景时改动边界更清晰。 + +### 决策 3:优先使用 SwiftUI `Model3D` 直接渲染,而非先构建复杂 RealityKit ECS +- 背景:项目目标是快速验证视觉展示与动画效果,非复杂交互仿真。 +- 选项: + - A. 从一开始就搭建完整实体组件系统(ECS) + - B. 先用 `Model3D` + SwiftUI 组合实现核心体验 +- 决定:选 B(`Model3D` 优先)。 +- 原因:实现路径更短,便于快速迭代展示;后续若交互复杂再下沉到更底层 RealityKit 架构。 + +## 构建与测试 +- Build: + - `xcodebuild -project OpenvisionOS.xcodeproj -scheme OpenvisionOS -configuration Debug -destination 'generic/platform=visionOS' -derivedDataPath ./.derived build` +- Test: + - `xcodebuild -project OpenvisionOS.xcodeproj -scheme OpenvisionOS -destination 'generic/platform=visionOS Simulator' -derivedDataPath ./.derived test` +- Lint: + - 当前未配置专门 lint 工具(如 SwiftLint)。 + diff --git "a/.github/docs/OpenvisionOS \346\212\200\346\234\257\346\226\207\346\241\243.md" "b/.github/docs/OpenvisionOS \346\212\200\346\234\257\346\226\207\346\241\243.md" new file mode 100644 index 0000000..b0dda26 --- /dev/null +++ "b/.github/docs/OpenvisionOS \346\212\200\346\234\257\346\226\207\346\241\243.md" @@ -0,0 +1,211 @@ +# OpenvisionOS 技术文档 + +## 1. 项目定位与范围 + +OpenvisionOS 是一个 visionOS 示例应用集合,使用 SwiftUI + RealityKit 展示多个 3D 交互/展示场景。当前版本以多窗口方式暴露 3 个独立体验: + +- AirPods Max 模型展示与持续旋转动画 +- Flower Pot 组合场景(前景模型 + 背景粒子效果) +- New Year Fireworks 新年主题模型展示 + +该项目定位为「视觉/动效演示工程」,不是完整商业应用;重点在于空间内容展示、模型资源组织、以及 SwiftUI 与 `Model3D` 的集成方式。 + +## 2. 技术栈与运行环境 + +- 语言:Swift 5 +- UI:SwiftUI +- 3D 渲染:RealityKit(通过 `Model3D` 加载本地 USDZ 资源) +- 资源包:本地 Swift Package `RealityKitContent` +- 目标平台:`xros`、`xrsimulator`(visionOS) +- 最低部署:visionOS 1.0(`XROS_DEPLOYMENT_TARGET = 1.0`) + +## 3. 工程结构 + +### 3.1 顶层目录 + +- `OpenvisionOS/`:主应用源码与资源 +- `OpenvisionOSTests/`:单元测试 target(当前为模板) +- `Packages/RealityKitContent/`:本地 Swift Package(RealityKit 内容包) +- `OpenvisionOS.xcodeproj/`:Xcode 工程配置 +- `Img/`:README 展示用 GIF 资源 + +### 3.2 应用层结构 + +`OpenvisionOS/` 下核心结构: + +- `OpenvisionOSApp.swift`:应用入口,声明多个 `WindowGroup` +- `AirPodsMax/`:AirPods Max 场景与模型资源 +- `Own3DModel/`:Flower Pot 场景与模型资源 +- `NewYearFireworks/`:新年场景与模型资源 +- `Assets.xcassets/`:图标与通用视觉资源 +- `Info.plist`:应用基础配置(多场景支持) + +## 4. 应用启动与场景生命周期 + +### 4.1 启动入口 + +`OpenvisionOS/OpenvisionOSApp.swift` 通过 `@main` 声明应用入口,并在 `body` 中注册三个 `WindowGroup`: + +- `WindowGroup("AirPods Max")` -> `AirPodsMaxAnimation` +- `WindowGroup("Flower Pot View")` -> `FlowerPotView` +- `WindowGroup("New Year Fireworks")` -> `NewYearFireworksTwentyFour` + +这意味着系统可将三个体验作为独立窗口管理,符合 visionOS 的多窗口体验模式。 + +### 4.2 多场景支持 + +`OpenvisionOS/Info.plist` 中配置: + +- `UIApplicationSupportsMultipleScenes = YES` + +确保应用可同时托管多个场景实例,与入口中的多 `WindowGroup` 设计一致。 + +## 5. 关键模块实现解析 + +### 5.1 AirPods Max 动画模块 + +文件:`OpenvisionOS/AirPodsMax/AirPodsMaxAnimation.swift` + +核心逻辑: + +1. 使用 `Model3D(named: "Airpods_Max_Pink")` 加载 USDZ 模型 +2. 通过 `.resizable()` + `.aspectRatio(contentMode: .fit)` 控制模型布局 +3. 使用 `.phaseAnimator([false, true])` 驱动旋转状态 +4. 在动画块中通过 `.rotation3DEffect(... axis: y)` 进行 Y 轴连续旋转 +5. 动画参数为 `.linear(duration: 5).repeatForever(autoreverses: false)`,形成持续自转 +6. 底部工具栏使用 `bottomOrnament` 展示电量图标与文本(UI 装饰信息) + +设计特点: + +- UI 与 3D 渲染耦合度低,逻辑简单,便于演示动效 API +- 动画由视图层直接驱动,没有额外状态管理器 + +### 5.2 Flower Pot 组合场景模块 + +文件:`OpenvisionOS/Own3DModel/FlowerPotView.swift` + +核心逻辑: + +1. 以 `ZStack` 构建前后景层次 +2. 前景 `VStack` 先渲染 `pointSparkle`,再渲染 `Flower-Port` +3. `pointSparkle` 通过缩放与偏移形成高光/粒子点缀 +4. 背景层单独渲染 `BgSparcle` 模型以增强空间氛围 + +设计特点: + +- 通过多个独立 USDZ 叠加构建视觉合成,而不是单一大模型 +- 布局主要依赖 SwiftUI 变换(`scaleEffect`/`offset`),开发成本低 + +### 5.3 New Year Fireworks 模块 + +文件:`OpenvisionOS/NewYearFireworks/NewYearFireworksTwentyFour.swift` + +核心逻辑: + +1. 使用 `NavigationStack` 承载界面容器 +2. 加载 `newYear` 模型并应用基础显示参数(fit/scale/padding) +3. 提供 `ProgressView` 作为模型加载占位 + +设计特点: + +- 模块职责单一,便于扩展节日主题场景 +- 当前尚未加入交互与时序动画逻辑 + +## 6. 资源管理策略 + +### 6.1 USDZ 资源组织 + +- AirPods 模型:`OpenvisionOS/AirPodsMax/*.usdz` +- Flower Pot 模型:`OpenvisionOS/Own3DModel/*.usdz` +- New Year 模型:`OpenvisionOS/NewYearFireworks/newYear.usdz` + +资源按功能模块同目录放置,降低查找成本,符合「场景与资产共置」策略。 + +### 6.2 构建打包 + +`OpenvisionOS.xcodeproj/project.pbxproj` 中,所有相关 USDZ 资源都加入 `Resources` build phase,确保运行期可被 `Model3D(named:)` 直接按名称加载。 + +### 6.3 Package 角色 + +`Packages/RealityKitContent/` 当前只暴露 `realityKitContentBundle`,未被主逻辑深度使用,主要作为模板遗留的内容包与未来扩展预留位。 + +## 7. 依赖关系与约束 + +### 7.1 依赖关系 + +- 视图模块依赖:`SwiftUI`、`RealityKit`、`RealityKitContent` +- 业务层/服务层:当前未引入(项目尚未分层) +- 测试 target 依赖主应用 target(`@testable import OpenvisionOS`) + +### 7.2 架构约束(现状) + +- 当前代码以视图直连模型资源为主,不经过中间抽象层 +- 场景间没有共享状态,不存在跨模块通信复杂度 +- 可维护性优点:简单、直接;缺点:扩展后可能出现重复逻辑 + +## 8. 构建、运行与测试 + +## 8.1 构建命令 + +```bash +xcodebuild -project OpenvisionOS.xcodeproj -scheme OpenvisionOS -configuration Debug -destination 'generic/platform=visionOS' -derivedDataPath ./.derived build +``` + +说明:使用本地 `-derivedDataPath` 可避免默认目录权限问题。 + +## 8.2 测试命令 + +```bash +xcodebuild -project OpenvisionOS.xcodeproj -scheme OpenvisionOS -destination 'generic/platform=visionOS Simulator' -derivedDataPath ./.derived test +``` + +## 8.3 当前测试状态 + +- `OpenvisionOSTests/OpenvisionOSTests.swift` 为 Xcode 默认模板 +- 尚未覆盖任何场景加载、资源可用性、UI 渲染或动画行为验证 + +## 9. 已知技术风险与改进建议 + +### 9.1 主要风险 + +1. `Model3D(named:)` 依赖字符串资源名,重命名资源易引入运行时加载失败 +2. 场景内错误处理能力弱(占位视图固定为 `ProgressView`,无失败态展示) +3. 缺少自动化测试,回归依赖手工运行 +4. 动画参数硬编码,后续批量调整成本较高 + +### 9.2 建议改进 + +1. 引入集中化资源常量(如 `enum ModelAsset`)减少硬编码 +2. 增加加载失败降级视图与日志 +3. 为每个场景增加最小快照测试或 UI 可见性断言 +4. 提炼通用 `Model3D` 容器组件,统一 placeholder、缩放策略与装饰样式 + +## 10. 扩展路线(技术视角) + +### 10.1 短期(低成本) + +- 新增第 4 个场景模块,验证模块化目录扩展性 +- 将 `NavigationStack` 与标题/工具栏策略统一成可复用模板 + +### 10.2 中期(结构优化) + +- 引入 `ViewModel` 层承载场景配置数据(标题、资源名、动画参数) +- 将窗口注册改造成配置驱动(减少 `OpenvisionOSApp` 内硬编码) + +### 10.3 长期(产品化) + +- 引入交互手势、空间锚点、动态材质与音频反馈 +- 结合性能分析(帧率/内存)制定多模型场景预算 + +## 11. 文件清单(源码) + +- `OpenvisionOS/OpenvisionOSApp.swift` +- `OpenvisionOS/AirPodsMax/AirPodsMaxAnimation.swift` +- `OpenvisionOS/Own3DModel/FlowerPotView.swift` +- `OpenvisionOS/NewYearFireworks/NewYearFireworksTwentyFour.swift` +- `OpenvisionOS/Info.plist` +- `OpenvisionOSTests/OpenvisionOSTests.swift` +- `Packages/RealityKitContent/Package.swift` +- `Packages/RealityKitContent/Sources/RealityKitContent/RealityKitContent.swift` +- `OpenvisionOS.xcodeproj/project.pbxproj` + diff --git "a/.github/docs/SaluAVP-\346\211\213\345\212\250\345\233\236\345\275\222\346\270\205\345\215\225.md" "b/.github/docs/SaluAVP-\346\211\213\345\212\250\345\233\236\345\275\222\346\270\205\345\215\225.md" new file mode 100644 index 0000000..4fed77c --- /dev/null +++ "b/.github/docs/SaluAVP-\346\211\213\345\212\250\345\233\236\345\275\222\346\270\205\345\215\225.md" @@ -0,0 +1,85 @@ +# SaluAVP 手动回归清单(visionOS Simulator) + +> 目的:在不依赖存档/回放的前提下,覆盖 SaluAVP 当前已实现的核心玩法闭环,避免“功能能跑但交互/路由/动画回归”。 + +## 0. 前置条件 + +- 构建通过:`xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build` +- 约定:本清单不包含存档/读档/Continue、trace/replay(已明确不做)。 + +## 1. 启动与路由(控制面板 → Immersive) + +- 打开 `SaluAVP`。 +- 在 2D 控制面板点击 `New Run`(可用固定 seed,便于复现)。 +- 点击 `Show Immersive` 进入 ImmersiveSpace。 +- 退出 ImmersiveSpace 再进入一次,确认不会崩溃,且 run 状态仍可继续推进。 + +## 2. 地图推进(Map Loop) + +- 地图节点具备至少三态可视反馈:可达 / 当前 / 已完成(或等价状态)。 +- 只能点击可达节点;点击不可达节点无响应或有明确反馈。 +- 完成一个房间后回到地图,且正确解锁下一层可达节点。 +- 推进到 Boss 节点并完成,确认: + - Act1/Act2:进入下一幕地图(floor 增长)。 + - Act3:出现通关收束(run won)。 + +## 3. 战斗(动画闭环 + 多敌人 + 目标选择) + +- 进入普通战斗: + - 回合开始能看到抽牌动画(或至少可感知的抽牌过程)并进入手牌区。 + - 打出卡牌:卡牌从手牌进入弃牌堆/消耗堆的动画连续且无明显跳帧。 + - 伤害/格挡反馈可见(飘字、抖动、音效/粒子任一即可)。 + - 击杀敌人:敌人死亡反馈可见且不会导致整个战斗场景重建。 +- 多敌人战斗: + - 可明确选中目标(高亮/描边/抬升任一)并出牌命中正确敌人。 + - 在多敌人时,对需要目标的牌:不选择目标无法出牌(有明确提示)。 +- 结束回合: + - 手牌弃置、敌人行动、进入下一回合流程正确。 + +## 4. 房间:休息点(Rest) + +- 进入休息点: + - 有明确的交互入口(3D 可点或 2D HUD 均可)。 + - 回血行为生效且数值更新正确。 + - 升级卡牌入口(若已实现):升级结果在后续战斗生效。 +- 离开休息点回到地图,Immersive 保持开启(不应因为离开房间就退出 Immersive)。 + +## 5. 房间:事件(Event) + +- 进入事件房间: + - 出现事件内容(可为 2D 面板),可选择至少一个选项并对 run 产生可观察变化(金币/HP/卡牌/遗物等)。 + - 对需要二次选择的事件:能进入 follow-up 并完成选择后正常返回地图。 +- 若事件触发战斗:战斗结束后回到事件/奖励链路符合当前设计(不跳转错路由)。 + +## 6. 房间:商店(Shop,3D 场景 + 2D 商品信息面板) + +- 进入商店后,商店的核心内容在 3D 场景中(离开商店仍保持 Immersive)。 +- 点击某个商品一次: + - 只更新“商品信息 2D 面板”(建议右侧/右上角位置),不应导致整个商店场景刷新重建。 + - 面板显示该商品的信息,并提供明确的 `购买` 按钮。 +- 点击不同商品: + - 只切换面板内容,不应出现“购买成功”残留弹窗或错误提示串场。 +- 购买流程: + - 点击 `购买` 后才会购买;购买成功会扣金币并更新 deck/遗物/消耗品(按商品类型)。 + - 购买成功后仍停留在商店房间(不应自动退出回到地图)。 + - 余额不足时提示明确,且不会产生误购买/误提示。 +- 离开商店: + - 通过商店中的离开入口回到地图,节点完成逻辑正确。 + +## 7. 奖励链路(普通/精英/Boss) + +- 普通战斗胜利: + - 获得金币(可通过 UI/摘要确认变化)。 + - 进入卡牌奖励选择;选择 1 张会加入牌组,跳过则不加入。 +- 精英战斗胜利: + - 在普通奖励基础上,出现遗物奖励(可拿/可跳过),拿到后后续战斗生效。 +- Boss 战斗胜利: + - 走完奖励链路后进入章节收束(Act1/2 进入下一幕;Act3 通关)。 + +## 8. Removed Legacy(每批次必填) + +> 在每次阶段性变更后,把“删了什么旧逻辑、为何可删、如何验证无回归”补在这里,便于 review。 + +- [ ] 本批删除项: +- [ ] 验证方式(对应本清单条目编号): + diff --git "a/.github/docs/Salu\346\270\270\346\210\217\344\270\232\345\212\241\350\257\264\346\230\216.md" "b/.github/docs/Salu\346\270\270\346\210\217\344\270\232\345\212\241\350\257\264\346\230\216.md" index 6081c17..1fefa35 100644 --- "a/.github/docs/Salu\346\270\270\346\210\217\344\270\232\345\212\241\350\257\264\346\230\216.md" +++ "b/.github/docs/Salu\346\270\270\346\210\217\344\270\232\345\212\241\350\257\264\346\230\216.md" @@ -4,6 +4,7 @@ > **设定与剧情**请参阅:`.github/docs/Salu游戏设定与剧情v1.0.md` > **卡牌/敌人/遗物命名映射**已整合到各业务章节(第 4/6/11 章)的表格中。 +> 说明:本文档描述的是“业务规则与结算口径”,不要求各客户端(CLI/AVP)在同一时间点完成全部 UI/便利功能;例如 AVP 当前阶段刻意不实现存档/读档/Continue 与回放工具。 --- diff --git a/.github/plans/2026-02-09-saluavp-full-ui-animation-implementation-plan.md b/.github/plans/2026-02-09-saluavp-full-ui-animation-implementation-plan.md new file mode 100644 index 0000000..c73eb44 --- /dev/null +++ b/.github/plans/2026-02-09-saluavp-full-ui-animation-implementation-plan.md @@ -0,0 +1,533 @@ +# SaluAVP XR/3D 原生破坏性重构实施计划 + +> 执行方式:建议使用 `executing-plans` 按批次实现与验收。 + +**Goal(目标):** 以 XR 原生、3D 原生为唯一方向,对 `SaluAVP` 做破坏性重构:完整补齐战斗动画、目标选择、多敌人、房间 UI(休息/商店/事件)、奖励链路(含精英/Boss 遗物),并在每个阶段及时删除老旧实现与兼容层。 + +**Non-goals(非目标):** 不做写实资产管线重建;不引入网络同步;不修改 `GameCore` 既有规则语义;不把 `RealityKit` 代码抽到 `Sources/`;不保留旧 AVP UI/路由 API 的向后兼容桥接。 + +**Approach(方案):** +1) 先建立“状态推进(GameCore)”与“表现驱动(AVP)”之间的事件桥接层,替换当前 `ImmersiveRootView` 大一统渲染。 +2) 优先做可复现、可验证的动画闭环(抽牌、出牌、受击、死亡、牌堆变化),并直接移除旧静态重建路径。 +3) 房间与奖励用新路由与新面板实现,不做旧路由兼容。 +4) 每个阶段必须包含“实现 + 删除老代码 + 验证”三连动作,禁止新旧双轨长期并存。 + +**Acceptance(验收):** +1) 战斗可见“抽牌→出牌→命中/格挡→入弃牌/消耗堆”的连续动画。 +2) 支持多敌人并可明确选目标;`.singleEnemy` 卡在多敌人战斗中可稳定落点。 +3) 休息、商店、事件三类房间在 AVP 可完成完整交互并正确推进 `RunState`。 +4) 精英/Boss 胜利后奖励链路与 CLI 业务一致(金币/卡牌/遗物)。 +5) 旧 AVP 占位逻辑被删除(无旧 room panel 占位完成路径、无旧 battle 静态分支)。 +6) 修改 `SaluNative/**` 后 `xcodebuild` 可通过;修改 `Sources/**` 后 `swift test` 可通过。 + +--- + +## 重构硬约束(本计划强制) + +1) 不向后兼容旧 AVP 表现层 API:允许重命名 `RunSession.Route`、面板组件、渲染入口。 +2) 不保留“兼容桥接层”超过一个任务周期:新实现落地的同批次必须删除旧实现。 +3) 禁止新旧双轨渲染:同一能力只保留一条主路径。 +4) 2D Window 仅承担控制面板与调试入口;核心玩法必须在 Immersive 3D 中完成。 +5) 2D 面板允许作为 HUD(例如商店商品信息/购买按钮、奖励选择),但不做“厚面板”或伪 3D UI。 + +--- + +## Brainstorming 方案结论 + +### 方案 A(推荐):事件桥接 + 分层渲染 +- 核心:在 `RunSession` 上提供战斗“增量事件流”,`Immersive` 只消费事件并驱动动画系统。 +- 优点:可测试性高;动画与规则解耦;后续扩展多敌人/甩牌更稳。 +- 风险:需要先做一轮结构改造(短期开发速度略慢)。 + +### 方案 B:继续在 `ImmersiveRootView` 里增量打补丁 +- 核心:维持现有单文件渲染,直接塞动画和房间逻辑。 +- 优点:前两周看起来改动快。 +- 风险:复杂度迅速失控,后续多人协作和回归风险高。 + +### 方案 C:先做房间 UI,再回头补动画 +- 核心:业务闭环优先,表现层后置。 +- 优点:更快覆盖地图全流程。 +- 风险:后续动画接入要返工(状态切换点已固化)。 + +**推荐采用:方案 A(Plan A)** + +--- + +## Plan A(主方案) + +### P1(最高优先级):战斗表现基础层(事件桥接 + 动画骨架) + +### ✅Task 1: 建立 AVP 战斗事件消费接口(允许破坏旧 AVP 接口) +**Files:** +- Create: `SaluNative/SaluAVP/ViewModels/BattlePresentationEvent.swift` +- Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` + +**Step 1: 最小验收** +- `RunSession` 可返回“自上次消费后的新增 `BattleEvent` 列表”。 +- 不重复消费旧事件。 + +**Step 2: 最小实现** +- 在 `RunSession` 内维护 `lastConsumedBattleEventIndex`。 +- 提供 `consumeNewBattleEvents() -> [BattleEvent]`。 +- 删除旧的直接 UI 轮询依赖路径(若有)。 + +**Step 3: 测试覆盖** +- Modify: `Tests/GameCoreTests/BattleEventDescriptionTests.swift`(补充事件序列稳定性断言) +- Create: `Tests/GameCoreTests/BattleEngineFlowEventOrderTests.swift`(验证关键事件顺序) + +**Step 4: 验证** +- Run: `swift test --filter GameCoreTests/BattleEngineFlowEventOrderTests` + +**Step 5: 小步回归** +- Run: `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build` +- Run: `rg -n \"battleState.*直接驱动视图重建|legacy|deprecated\" SaluNative/SaluAVP` + +### ✅Task 2: 抽离战斗渲染器,降低 `ImmersiveRootView` 复杂度 +**Files:** +- Create: `SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift` +- Create: `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` +- Modify: `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` + +**Step 1: 最小验收** +- `ImmersiveRootView` 不再直接持有大段 battle 实体构建细节。 +- battle 渲染入口统一到 `BattleSceneRenderer.render(...)`。 + +**Step 2: 最小实现** +- 迁移 `renderBattle/renderPiles/renderPeek/clearBattle` 逻辑。 +- 同批删除 `ImmersiveRootView` 内已迁移的旧 battle 渲染代码,不保留 fallback。 + +**Step 3: 测试覆盖** +- 以构建和手动冒烟为主(SaluAVP 无现成单测 target)。 + +**Step 4: 验证** +- Run: `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build` +- Manual: 新开一局进入战斗,确认地图/战斗切换、出牌、回合结束可用。 + +### ❎Task 3: 引入动画队列(先占位,不改交互) +**Files:** +- Create: `SaluNative/SaluAVP/Immersive/BattleAnimationQueue.swift` +- Modify: `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` + +**Step 1: 最小验收** +- 可将 `BattleEvent` 转成动画任务并顺序执行(先支持日志输出级别)。 + +**Step 2: 最小实现** +- 定义 `AnimationJob`(draw/play/hit/die/pileUpdate)。 +- 定义去重规则(同帧多事件合并)。 +- 移除“状态变更即瞬时替换实体”的旧策略入口。 + +**Step 3: 验证** +- Run: `xcodebuild ... build` +- Manual: 打一回合,确认动画任务在控制台可见且顺序正确。 + +--- + +### P2:战斗动画闭环(抽牌/打牌/受击/死亡) + +### ✅Task 4: 抽牌动画(DrawPile -> Hand) +**Files:** +- Modify: `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` +- Modify: `SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift` + +**Step 1: 最小验收** +- 每次 `.drew` 事件触发可见卡牌飞入手牌扇形目标位。 + +**Step 2: 最小实现** +- 用临时卡实体从 draw pile 位置 `move(to:)` 到手牌槽位。 +- 结束后销毁临时实体并刷新手牌实体。 + +**Step 3: 验证** +- Manual: 连续抽牌与洗牌后抽牌,动画不丢帧、不重影。 + +### ✅Task 5: 出牌动画(Hand -> Enemy / Pile) +**Files:** +- Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` +- Modify: `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` + +**Step 1: 最小验收** +- 点牌后先播卡牌飞行动画,再落地到弃牌/消耗堆表现。 + +**Step 2: 最小实现** +- `RunSession.playCard` 前后记录出牌实体索引与结果事件。 +- 根据 `.played` + 卡类型决定终点(discard/exhaust)。 + +**Step 3: 验证** +- Manual: 普通卡与消耗性卡各打 3 次,路径正确。 + +### ✅Task 6: 受击、格挡、死亡反馈 +**Files:** +- Create: `SaluNative/SaluAVP/Immersive/FloatingTextFactory.swift` +- Modify: `SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift` +- Modify: `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` + +**Step 1: 最小验收** +- `.damageDealt` 有浮字和受击闪烁。 +- `.blockGained`、`blocked > 0` 有区分反馈。 +- `.entityDied` 有死亡动画与实体淡出。 + +**Step 2: 最小实现** +- 用 `UnlitMaterial` 颜色闪烁 + 缩放脉冲。 +- 浮字固定生命周期并自动回收。 + +**Step 3: 验证** +- Manual: 触发高伤、被格挡、击杀三种情形。 + +### ✅Task 7: 回合切换与 HUD 动效 +**Files:** +- Modify: `SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift` +- Modify: `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` + +**Step 1: 最小验收** +- `turnStarted/turnEnded` 有轻量过场提示。 +- 能量变化有可视化过渡。 + +**Step 2: 最小实现** +- HUD 显示最近一条“回合状态条”。 +- 能量文本/图标做简短动画。 +- 删除旧 HUD 中仅文本瞬时刷新的重复状态块。 + +**Step 3: 验证** +- Manual: 连续 3 回合确认提示稳定、无遮挡主交互。 + +--- + +### P3:多敌人与目标选择(战斗可玩性补齐) + +### ✅Task 8: RunSession 启用多敌人遭遇初始化 +**Files:** +- Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` + +**Step 1: 最小验收** +- `battle` 路由可创建 2-3 敌人战斗,不再只取 `first`。 + +**Step 2: 最小实现** +- `startBattle` 对 `.battle` 使用 `EnemyEncounter.enemyIds` 全量创建实体。 +- 删除单敌人 `first` 兜底路径。 + +**Step 3: 验证** +- Manual: Act1/Act2 各进入 5 次普通战斗,出现多敌人场景。 + +### Task 9: 战斗目标选择交互 +**Files:** +- Modify: `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` +- Modify: `SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift` +- Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` + +**Step 1: 最小验收** +- 点击敌人可锁定目标并有高亮。 +- 出牌时可将 `targetEnemyIndex` 传入 `PlayerAction.playCard`。 + +**Step 2: 最小实现** +- 新增 `selectedEnemyIndex` 状态。 +- 当牌 `targeting == .singleEnemy` 且多敌人时,要求目标已选。 + +**Step 3: 验证** +- Manual: 多敌人时攻击牌可定向命中,非目标牌不受影响。 + +### ✅Task 10: 目标选择边界处理与提示 +**Files:** +- Modify: `SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift` +- Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` + +**Step 1: 最小验收** +- 目标死亡后自动清空锁定。 +- 无目标出牌失败时给出用户可读提示。 + +**Step 2: 最小实现** +- 在 battle state 更新后校验 `selectedEnemyIndex` 有效性。 +- HUD 显示“需要选择目标”。 + +**Step 3: 验证** +- Manual: 锁定敌人后其死亡,再打单体牌,提示正确。 + +--- + +### ✅P4:房间 UI 闭环(Rest / Shop / Event) + +### ✅Task 11: Rest 房间交互(休息/升级/对话) +**Files:** +- Create: `SaluNative/SaluAVP/Immersive/RestRoomPanel.swift` +- Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` +- Modify: `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` + +**Step 1: 最小验收** +- 可执行休息回血。 +- 可选择可升级卡并升级。 + +**Step 2: 最小实现** +- 在 AVP 侧封装 rest 操作接口(调用 `runState.restAtNode/upgradeCard`)。 +- 删除 `RoomPanel` 中旧的统一 `Complete` 占位入口(rest 路径)。 + +**Step 3: 验证** +- Manual: 进入休息点完成“休息”与“升级”各一次。 + +### ✅Task 12: Shop 房间交互(买卡/买遗物/买消耗/删牌) +**Files:** +- Create: `SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift` +- Create: `SaluNative/SaluAVP/ViewModels/ShopRoomState.swift` +- Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` + +**Step 1: 最小验收** +- 商店库存可生成并展示价格。 +- 四类交易可正确改动 `RunState` 并处理金币不足。 + +**Step 2: 最小实现** +- 复用 `GameCore` 的 `ShopContext/ShopInventory/ShopPricing`。 +- 对外暴露按钮动作:buyCard/buyRelic/buyConsumable/removeCard。 +- 删除旧 room 占位流程在 shop 路径的分支。 + +**Step 3: 验证** +- Manual: 每类交易至少执行一次;金币不足路径触发一次。 + +### ✅Task 13: Event 房间交互(选项 + Follow-up) +**Files:** +- Create: `SaluNative/SaluAVP/Immersive/EventRoomPanel.swift` +- Create: `SaluNative/SaluAVP/ViewModels/EventRoomState.swift` +- Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` + +**Step 1: 最小验收** +- 事件可生成并展示选项。 +- 选择后可应用 `RunEffect` 并展示结果摘要。 + +**Step 2: 最小实现** +- 复用 `EventContext/EventGenerator`。 +- 支持 `followUp.chooseUpgradeableCard`。 +- 删除旧 room 占位流程在 event 路径的分支。 + +**Step 3: 验证** +- Manual: 触发 3 个不同事件,验证数值和牌组变化。 + +### ✅Task 14: Event 触发精英战(followUp.startEliteBattle) +**Files:** +- Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` +- Modify: `SaluNative/SaluAVP/Immersive/EventRoomPanel.swift` + +**Step 1: 最小验收** +- 事件可跳转到指定精英战并在胜负后回收正确路由。 + +**Step 2: 最小实现** +- 新增事件来源 battle 上下文(用于结算返回事件结果页或 run over)。 + +**Step 3: 验证** +- Manual: 触发一次事件精英战并完成胜利链路。 + +--- + +### ✅P5:奖励链路一致性(尤其精英/Boss 遗物) + +### ✅Task 15: 统一 AVP 奖励路由模型 +**Files:** +- Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` +- Create: `SaluNative/SaluAVP/ViewModels/RewardRouteState.swift` + +**Step 1: 最小验收** +- 奖励路由支持“金币 + 卡牌 + 遗物”组合。 + +**Step 2: 最小实现** +- 将当前 `.cardReward(...)` 扩展为组合奖励结构。 +- 清理旧奖励路由字段与已废弃面板状态。 + +**Step 3: 验证** +- Manual: 普通战、精英战、Boss 战分别走一遍奖励。 + +### ✅Task 16: 遗物奖励面板(精英/Boss) +**Files:** +- Create: `SaluNative/SaluAVP/Immersive/RelicRewardPanel.swift` +- Modify: `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` + +**Step 1: 最小验收** +- 可选择遗物或跳过;结果正确写入 `runState.relicManager`。 + +**Step 2: 最小实现** +- HUD attachment 增加 relic reward 面板。 + +**Step 3: 验证** +- Manual: 精英和 Boss 奖励各测一次选择与跳过。 + +### ✅Task 17: Boss 章节收束和下一幕衔接 +**Files:** +- Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` +- Create: `SaluNative/SaluAVP/Immersive/ChapterEndPanel.swift` + +**Step 1: 最小验收** +- Boss 胜利后有章节收束文本;非最终幕进入下一幕地图。 + +**Step 2: 最小实现** +- 对齐 `GameCore.RunState.completeCurrentNode()` 的 multi-act 逻辑。 + +**Step 3: 验证** +- Manual: 至少通关一幕并进入下一幕。 + +--- + +### P6(Dropped):存档与 Continue(控制面板能力补齐) + +> 2026-02-09 决策:当前阶段不实现 AVP 存档/读档/Continue。相关实现已在仓库中回滚;本计划后续仅保留说明,不再执行本阶段任务。 + +### Task 18(Dropped): AVP 快照存储层(RunSnapshot) + +> Dropped:本阶段不实现;相关代码已回滚。以下内容仅保留为历史设计记录。 +**Files:** +- Create: `SaluNative/SaluAVP/Persistence/AVPRunSnapshotStore.swift` +- Create: `SaluNative/SaluAVP/Persistence/AVPDataDirectory.swift` +- Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` + +**Step 1: 最小验收** +- 可保存/读取/删除当前 run 快照。 + +**Step 2: 最小实现** +- 使用 `RunSnapshot` + JSON 文件持久化(以当前实现为准,不额外维护旧 AVP 私有格式兼容)。 + +**Step 3: 验证** +- Manual: 新开 run -> 保存 -> 关闭重开 -> Continue 恢复。 + +### Task 19(Dropped): 控制面板 Continue / Save / Reset UI + +> Dropped:本阶段不实现;相关代码已回滚。以下内容仅保留为历史设计记录。 +**Files:** +- Modify: `SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift` +- Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` + +**Step 1: 最小验收** +- 控制面板出现 Continue 和 Save 状态提示。 + +**Step 2: 最小实现** +- 增加按钮和错误提示;无存档时禁用 Continue。 + +**Step 3: 验证** +- Manual: 有存档和无存档两条路径。 + +### Task 20(Dropped): 自动保存策略 + +> Dropped:本阶段不实现;相关代码已回滚。以下内容仅保留为历史设计记录。 +**Files:** +- Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` + +**Step 1: 最小验收** +- 关键节点自动保存:进入房间、战斗结算、奖励确认后。 + +**Step 2: 最小实现** +- 封装 `autosaveIfNeeded()`,失败仅记录错误不阻塞流程。 + +**Step 3: 验证** +- Manual: 强制关闭 App 后恢复到最近关键节点。 + +--- + +### P7(Dropped):可观测性与回放(Determinism + Replay) + +> 2026-02-09 决策:当前阶段不实现 AVP trace/replay。相关实现已在仓库中回滚;本计划后续仅保留说明,不再执行本阶段任务。 + +### Task 21(Dropped): 选择路径记录模型 + +> Dropped:本阶段不实现;相关代码已回滚。以下内容仅保留为历史设计记录。 +**Files:** +- Create: `SaluNative/SaluAVP/ViewModels/RunTrace.swift` +- Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` + +**Step 1: 最小验收** +- 记录节点选择、出牌、目标、奖励选择等关键动作。 + +**Step 2: 最小实现** +- `RunSession` 在各入口动作追加 trace entry。 + +**Step 3: 验证** +- Manual: 完成 1 场战斗后导出 trace,内容完整。 + +### Task 22(Dropped): Trace 导出与重放模式(开发向) + +> Dropped:本阶段不实现;相关代码已回滚。以下内容仅保留为历史设计记录。 +**Files:** +- Create: `SaluNative/SaluAVP/ControlPanel/ReplayPanel.swift` +- Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` + +**Step 1: 最小验收** +- 可加载 trace 并按步回放到同结果(允许表现差异)。 + +**Step 2: 最小实现** +- 仅实现开发模式 replay(UI 可简化)。 + +**Step 3: 验证** +- Manual: 同 seed + trace 重放 2 次,比较关键状态一致。 + +--- + +### P8:测试、回归与文档收口 + +### Task 23: GameCore 相关新增/变更测试补齐 +**Files:** +- Modify: `Tests/GameCoreTests/BattleEngineFlowTests.swift` + +**Step 1: 最小验收** +- 新增/变更逻辑(多敌人目标、奖励链路关键生成器)有单测覆盖。 +- 不新增 AVP 存档/回放相关测试要求(本阶段已明确 Drop)。 + +**Step 2: 验证** +- Run: `swift test --filter GameCoreTests` + +### Task 24: AVP 手动回归清单固化 +**Files:** +- Create: `.github/docs/SaluAVP-手动回归清单.md` +- Modify: `.github/plans/Apple Vision Pro 原生 3D 实现(SaluAVP).md` + +**Step 1: 最小验收** +- 覆盖地图、战斗动画、多敌人、房间、奖励五大路径(明确不含存档/回放)。 + +**Step 2: 验证** +- Run: `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build` +- Manual: 按回归文档逐条走查并记录结果。 + +### Task 25: 全量验证与交付前收敛 +**Files:** +- Modify: `README.md` +- Modify: `README-en.md` +- Modify: `.github/docs/Salu游戏业务说明.md` + +**Step 1: 最小验收** +- 文档与现状一致;命令可执行;无明显死链。 +- 不存在未引用的旧 AVP 视图/状态/兼容桥接代码。 + +**Step 2: 验证** +- Run: `swift test` +- Run: `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build` +- Run: `rg -n \"legacy|deprecated|TODO_COMPAT|oldRoute|oldPanel\" SaluNative/SaluAVP` + +### Task 26: 老旧代码清理闸门(每阶段结束必做) +**Files:** +- Modify: `.github/docs/SaluAVP-手动回归清单.md` +- Modify: `.github/plans/2026-02-09-saluavp-full-ui-animation-implementation-plan.md` + +**Step 1: 最小验收** +- 每个阶段明确“删除了什么旧代码、为何可删、如何验证无回归”。 + +**Step 2: 最小实现** +- 在阶段验收记录中追加“Removed Legacy”小节。 +- 若发现无法删除的遗留分支,必须在当阶段内继续拆任务清理,不允许延期到“未来某次”。 + +**Step 3: 验证** +- Manual: 逐阶段检查回归清单包含删除记录。 + +--- + +## 分阶段回归策略 + +1) 完成 P1-P2 后:重点战斗回归 +- `xcodebuild ... build` +- 手动:开局 3 场战斗,验证抽牌/打牌/受击/死亡动画。 + +2) 完成 P3-P5 后:重点业务闭环回归 +- `swift test --filter GameCoreTests` +- 手动:普通/精英/Boss 各 1 场,验证奖励一致性。 + +3) 完成 P8 后:交付回归 +- `swift test` +- `xcodebuild ... build` +- 手动:新开局、地图推进到各房间、完成一幕并继续下一幕(无存档/回放)。 + +--- + +## 不确定项(执行前建议确认) + +1) 已确认:本轮不纳入“甩牌/投掷命中(B1)”,留在后续迭代。 +2) 事件房间中的“文本演出深度”目标(仅功能可用 vs 有完整剧情排版和动效)。 +3) 商店“删牌服务”的交互形式:2D HUD 面板选择卡牌列表 vs 3D 牌堆实体点选(后者更沉浸但工期更长)。 diff --git "a/.github/plans/Apple Vision Pro \345\216\237\347\224\237 3D \345\256\236\347\216\260\357\274\210SaluAVP\357\274\211.md" "b/.github/plans/Apple Vision Pro \345\216\237\347\224\237 3D \345\256\236\347\216\260\357\274\210SaluAVP\357\274\211.md" index 3b5f0f4..c5428dc 100644 --- "a/.github/plans/Apple Vision Pro \345\216\237\347\224\237 3D \345\256\236\347\216\260\357\274\210SaluAVP\357\274\211.md" +++ "b/.github/plans/Apple Vision Pro \345\216\237\347\224\237 3D \345\256\236\347\216\260\357\274\210SaluAVP\357\274\211.md" @@ -139,7 +139,7 @@ SaluNative/ ### 2D 控制面板(MVP) - ✅ New Run:输入 seed(或随机生成后展示)并开始。 -- Continue:从 App 层持久化恢复(如果 P3 之前未做存档,可先隐藏/置灰)。 +- Continue:本阶段不实现(刻意不做存档/读档/Continue);建议在 UI 中隐藏或置灰并标注“Not in scope”。 - ✅ Immersive 控制:进入/退出 ImmersiveSpace,显示当前 run 的关键摘要(楼层/金币/HP)。 --- diff --git a/README-en.md b/README-en.md index b298882..adf3d26 100644 --- a/README-en.md +++ b/README-en.md @@ -32,4 +32,4 @@ This project is layered by architecture, and each module follows its own guideli - `GameCore`: pure logic layer (rules/state/battle/cards/enemies/map/save snapshot models), see [GameCore guidelines](Sources/GameCore/AGENTS.md) - `GameCLI`: CLI/TUI presentation layer (terminal rendering/input/room flow/persistence), see [GameCLI guidelines](Sources/GameCLI/AGENTS.md) -- `SaluNative/SaluAVP`: native app (visionOS, ImmersiveSpace + RealityKit), see `.github/plans/Plan AVP - Apple Vision Pro 原生 3D 实现(SaluAVP).md` +- `SaluNative/SaluAVP`: native app (visionOS, ImmersiveSpace + RealityKit), see `.github/plans/Apple Vision Pro 原生 3D 实现(SaluAVP).md` diff --git a/README.md b/README.md index 9a8712d..5961edb 100644 --- a/README.md +++ b/README.md @@ -34,4 +34,4 @@ swift run - `GameCore`:纯逻辑层(规则/状态/战斗/卡牌/敌人/地图/存档快照模型),见 [GameCore 开发规范](Sources/GameCore/AGENTS.md) - `GameCLI`:CLI/TUI 表现层(终端渲染/输入/房间流程/持久化落盘实现),见 [GameCLI 开发规范](Sources/GameCLI/AGENTS.md) -- `SaluNative/SaluAVP`:原生 App(visionOS,ImmersiveSpace + RealityKit),见 `.github/plans/Plan AVP - Apple Vision Pro 原生 3D 实现(SaluAVP).md` +- `SaluNative/SaluAVP`:原生 App(visionOS,ImmersiveSpace + RealityKit),见 `.github/plans/Apple Vision Pro 原生 3D 实现(SaluAVP).md` diff --git a/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift b/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift index 2b6152b..a702da8 100644 --- a/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift +++ b/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift @@ -83,8 +83,10 @@ struct ControlPanelView: View { return "room(\(roomType.rawValue))" case .battle(_, let roomType): return "battle(\(roomType.rawValue))" - case .cardReward(_, let roomType, _, _): - return "cardReward(\(roomType.rawValue))" + case .reward(let rewardState): + return "reward(\(rewardState.roomType.rawValue)#\(rewardState.phase))" + case .chapterEnd(let previousFloor, let nextFloor): + return "chapterEnd(\(previousFloor)->\(nextFloor))" case .runOver(_, let won, let floor): return "runOver(won:\(won), floor:\(floor))" } diff --git a/SaluNative/SaluAVP/Immersive/BattleAnimationQueue.swift b/SaluNative/SaluAVP/Immersive/BattleAnimationQueue.swift new file mode 100644 index 0000000..0e259f0 --- /dev/null +++ b/SaluNative/SaluAVP/Immersive/BattleAnimationQueue.swift @@ -0,0 +1,161 @@ +import GameCore + +@MainActor +struct BattleAnimationQueue { + enum AnimationKind: String, Sendable, Equatable { + case draw + case play + case hit + case block + case die + case pileUpdate + case turnStart + case turnEnd + case energyPulse + } + + struct AnimationJob: Sendable, Equatable { + let sequence: Int + let kind: AnimationKind + let summary: String + var cardId: CardID? = nil + var playedCardContext: PlayedCardPresentationContext? = nil + var amount: Int? = nil + var blocked: Int? = nil + var entityId: String? = nil + var turn: Int? = nil + var energy: Int? = nil + } + + private var pendingJobs: [AnimationJob] = [] + + mutating func enqueue(events: [BattlePresentationEvent]) { + let mapped = events.flatMap(mapToJobs) + let deduped = dedupeJobsInFrame(mapped) + pendingJobs.append(contentsOf: deduped) + pendingJobs.sort { $0.sequence < $1.sequence } + } + + mutating func drainAll() -> [AnimationJob] { + let jobs = pendingJobs + pendingJobs.removeAll(keepingCapacity: true) + return jobs + } + + mutating func clear() { + pendingJobs.removeAll(keepingCapacity: true) + } + + private func mapToJobs(_ event: BattlePresentationEvent) -> [AnimationJob] { + switch event.event { + case .drew(let cardId): + return [ + AnimationJob( + sequence: event.sequence, + kind: .draw, + summary: "drew:\(cardId.rawValue)", + cardId: cardId + ), + AnimationJob(sequence: event.sequence, kind: .pileUpdate, summary: "pileUpdate:draw") + ] + + case .played(_, let cardId, _): + return [ + AnimationJob( + sequence: event.sequence, + kind: .play, + summary: "played:\(cardId.rawValue)", + cardId: cardId, + playedCardContext: event.playedCardContext + ), + AnimationJob(sequence: event.sequence, kind: .pileUpdate, summary: "pileUpdate:play") + ] + + case .damageDealt(_, _, let targetEntityId, _, let amount, let blocked): + return [ + AnimationJob( + sequence: event.sequence, + kind: .hit, + summary: "damageDealt", + amount: amount, + blocked: blocked, + entityId: targetEntityId + ) + ] + + case .blockGained(let targetEntityId, _, let amount): + return [ + AnimationJob( + sequence: event.sequence, + kind: .block, + summary: "blockGained", + amount: amount, + entityId: targetEntityId + ) + ] + + case .entityDied(let entityId, _): + return [ + AnimationJob( + sequence: event.sequence, + kind: .die, + summary: "entityDied:\(entityId)", + entityId: entityId + ) + ] + + case .turnStarted(let turn): + return [ + AnimationJob( + sequence: event.sequence, + kind: .turnStart, + summary: "turnStarted:\(turn)", + turn: turn + ) + ] + + case .turnEnded(let turn): + return [ + AnimationJob( + sequence: event.sequence, + kind: .turnEnd, + summary: "turnEnded:\(turn)", + turn: turn + ) + ] + + case .energyReset(let amount): + return [ + AnimationJob( + sequence: event.sequence, + kind: .energyPulse, + summary: "energyReset:\(amount)", + energy: amount + ) + ] + + case .shuffled: + return [AnimationJob(sequence: event.sequence, kind: .pileUpdate, summary: "pileUpdate:shuffled")] + + case .handDiscarded: + return [AnimationJob(sequence: event.sequence, kind: .pileUpdate, summary: "pileUpdate:discard")] + + default: + return [] + } + } + + private func dedupeJobsInFrame(_ jobs: [AnimationJob]) -> [AnimationJob] { + var merged: [AnimationJob] = [] + var pileUpdateFrames = Set() + + for job in jobs { + if job.kind == .pileUpdate { + guard pileUpdateFrames.insert(job.sequence).inserted else { continue } + } + merged.append(job) + } + + return merged + } +} diff --git a/SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift b/SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift new file mode 100644 index 0000000..ba98717 --- /dev/null +++ b/SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift @@ -0,0 +1,492 @@ +import RealityKit +import OSLog +import UIKit +import GameCore + +@MainActor +final class BattleAnimationSystem { + private enum Names { + static let animationRoot = "battleAnimationRoot" + static let floatingTextRoot = "battleFloatingTextRoot" + static let cardPrefix = "card:" + static let pilePrefix = "pile:" + static let enemyPrefix = "enemy:" + static let battleFloor = "battleFloor" + } + + private struct HandCardSnapshot { + let index: Int + let transform: Transform + } + + private var queue = BattleAnimationQueue() + private let logger = Logger(subsystem: "com.chiimagnus.SaluAVP", category: "BattleAnimation") + private var previousHandTransforms: [Int: Transform] = [:] + private var previousEnemyTransforms: [String: Transform] = [:] + + func enqueue(events: [BattlePresentationEvent]) { + queue.enqueue(events: events) + } + + func beginRenderPass( + in battleLayer: RealityKit.Entity, + handRoot: RealityKit.Entity?, + enemyRoot: RealityKit.Entity? + ) { + capturePreviousHandState(handRoot: handRoot, relativeTo: battleLayer) + capturePreviousEnemyState(enemyRoot: enemyRoot, relativeTo: battleLayer) + } + + func endRenderPass( + in battleLayer: RealityKit.Entity, + handRoot: RealityKit.Entity?, + enemyRoot: RealityKit.Entity?, + pilesRoot: RealityKit.Entity? + ) { + processQueuedJobs( + in: battleLayer, + handRoot: handRoot, + enemyRoot: enemyRoot, + pilesRoot: pilesRoot + ) + } + + func clear(in battleLayer: RealityKit.Entity) { + queue.clear() + previousHandTransforms = [:] + previousEnemyTransforms = [:] + battleLayer.findEntity(named: Names.animationRoot)?.removeFromParent() + } + + private func processQueuedJobs( + in battleLayer: RealityKit.Entity, + handRoot: RealityKit.Entity?, + enemyRoot: RealityKit.Entity?, + pilesRoot: RealityKit.Entity? + ) { + let jobs = queue.drainAll() + guard !jobs.isEmpty else { return } + + var drawTargets = drawTargetQueue(from: handRoot, drawCount: jobs.filter { $0.kind == .draw }.count) + + for job in jobs { + logger.info("[AnimationQueue] seq=\(job.sequence, privacy: .public) kind=\(job.kind.rawValue, privacy: .public) summary=\(job.summary, privacy: .public)") + switch job.kind { + case .draw: + let target = drawTargets.isEmpty ? nil : drawTargets.removeFirst() + playDrawAnimation( + job: job, + target: target, + in: battleLayer, + handRoot: handRoot, + pilesRoot: pilesRoot + ) + case .play: + playCardAnimation( + job: job, + in: battleLayer, + handRoot: handRoot, + enemyRoot: enemyRoot, + pilesRoot: pilesRoot + ) + case .hit: + playDamageFeedback(job: job, in: battleLayer, handRoot: handRoot, enemyRoot: enemyRoot) + case .block: + playBlockFeedback(job: job, in: battleLayer, handRoot: handRoot, enemyRoot: enemyRoot) + case .die: + playDeathAnimation(job: job, in: battleLayer, enemyRoot: enemyRoot) + case .turnStart: + pulseBattleFloor(in: battleLayer, color: UIColor.systemTeal.withAlphaComponent(0.48)) + case .turnEnd: + pulseBattleFloor(in: battleLayer, color: UIColor.systemOrange.withAlphaComponent(0.46)) + case .energyPulse: + pulseHandRoot(handRoot, in: battleLayer) + case .pileUpdate: + continue + } + } + } + + private func capturePreviousHandState(handRoot: RealityKit.Entity?, relativeTo battleLayer: RealityKit.Entity) { + previousHandTransforms = [:] + guard let handRoot else { return } + for child in handRoot.children { + guard let index = handIndex(from: child.name) else { continue } + previousHandTransforms[index] = transform(of: child, relativeTo: battleLayer) + } + } + + private func capturePreviousEnemyState(enemyRoot: RealityKit.Entity?, relativeTo battleLayer: RealityKit.Entity) { + previousEnemyTransforms = [:] + guard let enemyRoot else { return } + for child in enemyRoot.children { + guard let enemyId = enemyId(from: child.name) else { continue } + previousEnemyTransforms[enemyId] = transform(of: child, relativeTo: battleLayer) + } + } + + private func drawTargetQueue(from handRoot: RealityKit.Entity?, drawCount: Int) -> [HandCardSnapshot] { + guard drawCount > 0, let handRoot else { return [] } + let cards = handRoot.children.compactMap { child -> HandCardSnapshot? in + guard let index = handIndex(from: child.name) else { return nil } + return HandCardSnapshot(index: index, transform: child.transform) + } + guard !cards.isEmpty else { return [] } + let sorted = cards.sorted { $0.index < $1.index } + let suffixCount = min(drawCount, sorted.count) + return Array(sorted.suffix(suffixCount)) + } + + private func playDrawAnimation( + job: BattleAnimationQueue.AnimationJob, + target: HandCardSnapshot?, + in battleLayer: RealityKit.Entity, + handRoot: RealityKit.Entity?, + pilesRoot: RealityKit.Entity? + ) { + guard let target else { return } + let animationRoot = ensureAnimationRoot(in: battleLayer) + + let targetEntityName = "\(Names.cardPrefix)\(target.index)" + let targetEntity = handRoot?.findEntity(named: targetEntityName) + let drawPile = pilesRoot?.findEntity(named: "\(Names.pilePrefix)draw") + + let tempCard: RealityKit.Entity + if let targetEntity { + tempCard = targetEntity.clone(recursive: true) + } else if let fallback = makeFallbackCardEntity(cardId: job.cardId) { + tempCard = fallback + } else { + return + } + + tempCard.name = "drawAnim:\(job.sequence)" + tempCard.components.remove(InputTargetComponent.self) + tempCard.components.remove(CollisionComponent.self) + + animationRoot.addChild(tempCard) + if let targetEntity { + targetEntity.components.set(OpacityComponent(opacity: 0)) + } + + let startTransform: Transform + if let drawPile { + startTransform = transform(of: drawPile, relativeTo: battleLayer) + } else { + startTransform = target.transform + } + tempCard.transform = startTransform + + let endTransform = target.transform + tempCard.move(to: endTransform, relativeTo: battleLayer, duration: 0.30, timingFunction: .easeInOut) + + Task { @MainActor [weak tempCard, weak targetEntity] in + try? await Task.sleep(nanoseconds: 340_000_000) + if let targetEntity { + targetEntity.components.set(OpacityComponent(opacity: 1)) + } + tempCard?.removeFromParent() + } + } + + private func playCardAnimation( + job: BattleAnimationQueue.AnimationJob, + in battleLayer: RealityKit.Entity, + handRoot: RealityKit.Entity?, + enemyRoot: RealityKit.Entity?, + pilesRoot: RealityKit.Entity? + ) { + guard let context = job.playedCardContext else { return } + + let animationRoot = ensureAnimationRoot(in: battleLayer) + let sourceEntity = handRoot?.findEntity(named: "\(Names.cardPrefix)\(context.sourceHandIndex)") + let sourceTransform = previousHandTransforms[context.sourceHandIndex] + ?? sourceEntity.map { transform(of: $0, relativeTo: battleLayer) } + guard let sourceTransform else { return } + + let tempCard: RealityKit.Entity + if let sourceEntity { + tempCard = sourceEntity.clone(recursive: true) + sourceEntity.components.set(OpacityComponent(opacity: 0)) + } else if let fallback = makeFallbackCardEntity(cardId: job.cardId) { + tempCard = fallback + } else { + return + } + + tempCard.name = "playAnim:\(job.sequence)" + tempCard.components.remove(InputTargetComponent.self) + tempCard.components.remove(CollisionComponent.self) + tempCard.transform = sourceTransform + animationRoot.addChild(tempCard) + + let pileEntity = pilesRoot?.findEntity(named: "\(Names.pilePrefix)\(context.destinationPile.rawValue)") + let pileTransform = pileEntity.map { transform(of: $0, relativeTo: battleLayer) } ?? sourceTransform + + var endTransform = pileTransform + endTransform.translation += [0, 0.05, 0] + endTransform.scale = SIMD3(repeating: 0.58) + endTransform.rotation = pileTransform.rotation * simd_quatf(angle: .pi * 0.75, axis: [0, 1, 0]) + + if let targetId = context.targetEnemyEntityId, + let targetEntity = enemyRoot?.findEntity(named: "\(Names.enemyPrefix)\(targetId)") { + var midTransform = transform(of: targetEntity, relativeTo: battleLayer) + midTransform.translation += [0, 0.10, 0] + midTransform.scale = SIMD3(repeating: 0.92) + tempCard.move(to: midTransform, relativeTo: battleLayer, duration: 0.13, timingFunction: .easeIn) + + Task { @MainActor [weak tempCard, weak sourceEntity] in + try? await Task.sleep(nanoseconds: 150_000_000) + tempCard?.move(to: endTransform, relativeTo: battleLayer, duration: 0.18, timingFunction: .easeOut) + try? await Task.sleep(nanoseconds: 220_000_000) + if let sourceEntity { + sourceEntity.components.set(OpacityComponent(opacity: 1)) + } + tempCard?.removeFromParent() + } + } else { + tempCard.move(to: endTransform, relativeTo: battleLayer, duration: 0.25, timingFunction: .easeIn) + Task { @MainActor [weak tempCard, weak sourceEntity] in + try? await Task.sleep(nanoseconds: 280_000_000) + if let sourceEntity { + sourceEntity.components.set(OpacityComponent(opacity: 1)) + } + tempCard?.removeFromParent() + } + } + } + + private func playDamageFeedback( + job: BattleAnimationQueue.AnimationJob, + in battleLayer: RealityKit.Entity, + handRoot: RealityKit.Entity?, + enemyRoot: RealityKit.Entity? + ) { + guard let target = resolveFeedbackTarget(job.entityId, handRoot: handRoot, enemyRoot: enemyRoot) else { return } + pulseEntity(target, relativeTo: battleLayer, amplitude: 1.10) + + if let amount = job.amount, amount > 0 { + spawnFloatingText( + text: "-\(amount)", + style: .damage, + near: target, + in: battleLayer + ) + } + + if let blocked = job.blocked, blocked > 0 { + spawnFloatingText( + text: "BLOCK \(blocked)", + style: .block, + near: target, + in: battleLayer + ) + } + } + + private func playBlockFeedback( + job: BattleAnimationQueue.AnimationJob, + in battleLayer: RealityKit.Entity, + handRoot: RealityKit.Entity?, + enemyRoot: RealityKit.Entity? + ) { + guard let target = resolveFeedbackTarget(job.entityId, handRoot: handRoot, enemyRoot: enemyRoot) else { return } + pulseEntity(target, relativeTo: battleLayer, amplitude: 1.06) + if let amount = job.amount, amount > 0 { + spawnFloatingText( + text: "+\(amount) BLOCK", + style: .block, + near: target, + in: battleLayer + ) + } + } + + private func playDeathAnimation( + job: BattleAnimationQueue.AnimationJob, + in battleLayer: RealityKit.Entity, + enemyRoot: RealityKit.Entity? + ) { + guard let entityId = job.entityId else { return } + + let entityName = "\(Names.enemyPrefix)\(entityId)" + let targetEntity = enemyRoot?.findEntity(named: entityName) + + if let targetEntity { + fadeOutAndRemove(targetEntity, relativeTo: battleLayer) + return + } + + guard let previous = previousEnemyTransforms[entityId] else { return } + let ghost = ModelEntity( + mesh: .generateSphere(radius: 0.14), + materials: [SimpleMaterial(color: UIColor.systemRed.withAlphaComponent(0.72), isMetallic: true)] + ) + ghost.name = "deathGhost:\(entityId)" + ghost.transform = previous + ensureAnimationRoot(in: battleLayer).addChild(ghost) + fadeOutAndRemove(ghost, relativeTo: battleLayer) + } + + private func fadeOutAndRemove(_ entity: RealityKit.Entity, relativeTo battleLayer: RealityKit.Entity) { + let initial = transform(of: entity, relativeTo: battleLayer) + var final = initial + final.translation += [0, -0.04, 0] + final.scale *= SIMD3(repeating: 0.72) + entity.move(to: final, relativeTo: battleLayer, duration: 0.36, timingFunction: .easeIn) + + Task { @MainActor [weak entity] in + let steps = 5 + for step in 1...steps { + try? await Task.sleep(nanoseconds: 60_000_000) + guard let entity else { return } + let opacity = max(0, 1 - (Float(step) / Float(steps))) + entity.components.set(OpacityComponent(opacity: opacity)) + } + entity?.removeFromParent() + } + } + + private func resolveFeedbackTarget( + _ entityId: String?, + handRoot: RealityKit.Entity?, + enemyRoot: RealityKit.Entity? + ) -> RealityKit.Entity? { + guard let entityId else { return enemyRoot?.children.first ?? handRoot } + if let enemy = enemyRoot?.findEntity(named: "\(Names.enemyPrefix)\(entityId)") { return enemy } + return handRoot ?? enemyRoot?.children.first + } + + private func pulseBattleFloor(in battleLayer: RealityKit.Entity, color: UIColor) { + guard let floor = battleLayer.findEntity(named: Names.battleFloor) as? ModelEntity else { return } + guard let originalMaterials = floor.model?.materials else { return } + + floor.model?.materials = [SimpleMaterial(color: color, isMetallic: false)] + Task { @MainActor [weak floor] in + try? await Task.sleep(nanoseconds: 160_000_000) + floor?.model?.materials = originalMaterials + } + } + + private func pulseHandRoot(_ handRoot: RealityKit.Entity?, in battleLayer: RealityKit.Entity) { + guard let handRoot else { return } + let initial = transform(of: handRoot, relativeTo: battleLayer) + var peak = initial + peak.translation += [0, 0.008, 0] + peak.scale *= SIMD3(repeating: 1.04) + + handRoot.move(to: peak, relativeTo: battleLayer, duration: 0.10, timingFunction: .easeInOut) + Task { @MainActor [weak handRoot] in + try? await Task.sleep(nanoseconds: 120_000_000) + handRoot?.move(to: initial, relativeTo: battleLayer, duration: 0.16, timingFunction: .easeInOut) + } + } + + private func pulseEntity( + _ entity: RealityKit.Entity, + relativeTo battleLayer: RealityKit.Entity, + amplitude: Float + ) { + let initial = transform(of: entity, relativeTo: battleLayer) + var peak = initial + peak.scale *= SIMD3(repeating: amplitude) + entity.move(to: peak, relativeTo: battleLayer, duration: 0.08, timingFunction: .easeOut) + + Task { @MainActor [weak entity] in + try? await Task.sleep(nanoseconds: 90_000_000) + entity?.move(to: initial, relativeTo: battleLayer, duration: 0.16, timingFunction: .easeInOut) + } + } + + private func spawnFloatingText( + text: String, + style: FloatingTextFactory.Style, + near target: RealityKit.Entity, + in battleLayer: RealityKit.Entity + ) { + guard let label = FloatingTextFactory.makeEntity(text: text, style: style) else { return } + + let animationRoot = ensureAnimationRoot(in: battleLayer) + let textRoot = ensureFloatingTextRoot(in: animationRoot) + textRoot.addChild(label) + + let targetTransform = transform(of: target, relativeTo: battleLayer) + var start = targetTransform + start.translation += [0, 0.18, 0] + start.scale = SIMD3(repeating: 0.52) + label.transform = start + + var end = start + end.translation += [0, 0.07, 0] + end.scale *= SIMD3(repeating: 1.08) + label.move(to: end, relativeTo: battleLayer, duration: 0.42, timingFunction: .easeOut) + + Task { @MainActor [weak label] in + try? await Task.sleep(nanoseconds: 520_000_000) + label?.removeFromParent() + } + } + + private func ensureAnimationRoot(in battleLayer: RealityKit.Entity) -> RealityKit.Entity { + if let existing = battleLayer.findEntity(named: Names.animationRoot) { + return existing + } + let root = RealityKit.Entity() + root.name = Names.animationRoot + battleLayer.addChild(root) + return root + } + + private func ensureFloatingTextRoot(in animationRoot: RealityKit.Entity) -> RealityKit.Entity { + if let existing = animationRoot.findEntity(named: Names.floatingTextRoot) { + return existing + } + let root = RealityKit.Entity() + root.name = Names.floatingTextRoot + animationRoot.addChild(root) + return root + } + + private func makeFallbackCardEntity(cardId: CardID?) -> ModelEntity? { + let color: UIColor + if let cardId { + let definition = CardRegistry.require(cardId) + switch definition.type { + case .attack: + color = UIColor.systemRed.withAlphaComponent(0.88) + case .skill: + color = UIColor.systemBlue.withAlphaComponent(0.88) + case .power: + color = UIColor.systemPurple.withAlphaComponent(0.88) + case .consumable: + color = UIColor.systemGreen.withAlphaComponent(0.88) + } + } else { + color = UIColor(white: 0.72, alpha: 0.88) + } + + let entity = ModelEntity( + mesh: .generateBox(size: [0.06, 0.002, 0.09]), + materials: [SimpleMaterial(color: color, isMetallic: false)] + ) + return entity + } + + private func handIndex(from name: String) -> Int? { + guard name.hasPrefix(Names.cardPrefix) else { return nil } + return Int(name.dropFirst(Names.cardPrefix.count)) + } + + private func enemyId(from name: String) -> String? { + guard name.hasPrefix(Names.enemyPrefix) else { return nil } + return String(name.dropFirst(Names.enemyPrefix.count)) + } + + private func transform(of entity: RealityKit.Entity, relativeTo reference: RealityKit.Entity?) -> Transform { + Transform( + scale: entity.scale(relativeTo: reference), + rotation: entity.orientation(relativeTo: reference), + translation: entity.position(relativeTo: reference) + ) + } +} diff --git a/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift b/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift index db891c1..dd85d71 100644 --- a/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift +++ b/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift @@ -7,12 +7,21 @@ struct BattleHUDPanel: View { @Environment(\.openWindow) private var openWindow @State private var isLogExpanded: Bool = false + @State private var turnBannerText: String? + @State private var turnBannerToken: UUID = UUID() + @State private var displayedEnergy: Int = 0 + @State private var displayedMaxEnergy: Int = 0 + @State private var energyPulse: Bool = false + @State private var energyPulseToken: UUID = UUID() + @State private var processedEventCount: Int = 0 var body: some View { @Bindable var runSession = runSession let battleState = runSession.battleState let engine = runSession.battleEngine + let events = engine?.events ?? [] + let eventCount = events.count VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { @@ -28,16 +37,75 @@ struct BattleHUDPanel: View { .buttonStyle(.bordered) } + if let turnBannerText { + Text(turnBannerText) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.accentColor.opacity(0.82)) + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } + if let state = battleState { VStack(alignment: .leading, spacing: 6) { Text("Player HP \(state.player.currentHP)/\(state.player.maxHP) Block \(state.player.block)") .font(.caption) - let enemy = state.enemies.first - Text("Enemy: \(enemy.map { $0.name.resolved(for: .zhHans) } ?? "-") HP \(enemy?.currentHP ?? 0)/\(enemy?.maxHP ?? 0) Block \(enemy?.block ?? 0)") - .font(.caption) - Text("Turn \(state.turn) Energy \(state.energy)/\(state.maxEnergy) \(state.isPlayerTurn ? "Player" : "Enemy")") - .font(.caption2) - .foregroundStyle(.secondary) + + if state.enemies.count == 1, let enemy = state.enemies.first { + let marker = (runSession.selectedEnemyIndex == 0) ? "[Target] " : "" + Text("\(marker)Enemy: \(enemy.name.resolved(for: .zhHans)) HP \(enemy.currentHP)/\(enemy.maxHP) Block \(enemy.block)") + .font(.caption) + } else { + ForEach(Array(state.enemies.enumerated()), id: \.offset) { index, enemy in + let marker = (runSession.selectedEnemyIndex == index) ? "[Target] " : "" + let alive = enemy.isAlive ? "" : " [DEAD]" + Text("\(marker)E\(index + 1): \(enemy.name.resolved(for: .zhHans))\(alive) HP \(enemy.currentHP)/\(enemy.maxHP) Block \(enemy.block)") + .font(.caption2) + .foregroundStyle(enemy.isAlive ? .primary : .secondary) + } + } + + if let selectedEnemyDisplayName = runSession.selectedEnemyDisplayName { + Text("Target Locked: \(selectedEnemyDisplayName)") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.yellow) + } + + if let battleTargetHint = runSession.battleTargetHint { + Text(battleTargetHint) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.red) + } + + HStack(spacing: 8) { + Text("Turn \(state.turn) · \(state.isPlayerTurn ? "Player" : "Enemy")") + .font(.caption2) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + HStack(spacing: 4) { + Image(systemName: "bolt.fill") + .font(.caption2.weight(.bold)) + Text("\(displayedEnergy)/\(displayedMaxEnergy)") + .font(.caption2.monospacedDigit().weight(.semibold)) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(Color.yellow.opacity(0.20)) + ) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(Color.yellow.opacity(0.36), lineWidth: 1) + ) + .scaleEffect(energyPulse ? 1.12 : 1.0) + .animation(.spring(response: 0.24, dampingFraction: 0.68), value: energyPulse) + } Text("Draw \(state.drawPile.count) Discard \(state.discardPile.count) Exhaust \(state.exhaustPile.count)") .font(.caption2) .foregroundStyle(.secondary) @@ -110,6 +178,20 @@ struct BattleHUDPanel: View { .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) .frame(width: 260) + .onAppear { + syncEnergyFromState(battleState) + processNewEvents(events) + } + .onChange(of: eventCount) { _, _ in + processNewEvents(engine?.events ?? []) + } + .onChange(of: battleState?.energy ?? 0) { _, _ in + syncEnergyFromState(battleState, animate: true) + } + .onChange(of: battleState?.maxEnergy ?? 0) { _, _ in + syncEnergyFromState(battleState) + } + .animation(.easeInOut(duration: 0.18), value: turnBannerText != nil) } private func pendingLabel(_ pending: BattlePendingInput) -> String { @@ -118,4 +200,69 @@ struct BattleHUDPanel: View { return "Foresight(\(fromCount))" } } + + private func processNewEvents(_ events: [BattleEvent]) { + if events.count < processedEventCount { + processedEventCount = 0 + } + guard processedEventCount < events.count else { return } + + for event in events[processedEventCount.. RealityKit.Entity { + let battleLayer = RealityKit.Entity() + battleLayer.name = Names.battleLayer + battleLayer.isEnabled = false + + addBattleFloor(to: battleLayer) + + let enemyRoot = RealityKit.Entity() + enemyRoot.name = Names.battleEnemyRoot + enemyRoot.position = [0, 0.14, -1.0] + battleLayer.addChild(enemyRoot) + + let headAnchor = AnchorEntity(.head) + headAnchor.name = Names.battleHeadAnchor + battleLayer.addChild(headAnchor) + + let handRoot = RealityKit.Entity() + handRoot.name = Names.battleHandRoot + handRoot.position = [0, -0.12, -0.35] + headAnchor.addChild(handRoot) + + return battleLayer + } + + func clear(in battleLayer: RealityKit.Entity) { + battleLayer.findEntity(named: Names.battleEnemyRoot)?.children.forEach { $0.removeFromParent() } + if let headAnchor = battleLayer.findEntity(named: Names.battleHeadAnchor) { + headAnchor.findEntity(named: Names.battleHandRoot)?.children.forEach { $0.removeFromParent() } + headAnchor.findEntity(named: Names.battleInspectRoot)?.children.forEach { $0.removeFromParent() } + } + battleLayer.findEntity(named: Names.battlePilesRoot)?.children.forEach { $0.removeFromParent() } + animationSystem.clear(in: battleLayer) + } + + func render( + engine: BattleEngine, + in battleLayer: RealityKit.Entity, + cardDisplayMode: CardDisplayMode, + language: GameLanguage, + peekedHandIndex: Int?, + selectedEnemyIndex: Int?, + newEvents: [BattlePresentationEvent] + ) { + let enemyRoot = ensureEnemyRoot(in: battleLayer) + let headAnchor = ensureHeadAnchor(in: battleLayer) + let handRoot = ensureHandRoot(in: headAnchor) + let pilesRoot = ensurePilesRoot(in: battleLayer) + + animationSystem.enqueue(events: newEvents) + animationSystem.beginRenderPass( + in: battleLayer, + handRoot: handRoot, + enemyRoot: enemyRoot + ) + + renderEnemies(enemies: engine.state.enemies, selectedEnemyIndex: selectedEnemyIndex, in: enemyRoot) + + let hand = engine.state.hand + let playable = Set(engine.playableCardIndices) + + if hand.isEmpty { + handRoot.children.forEach { $0.removeFromParent() } + clearPeek(in: headAnchor) + renderPiles(state: engine.state, in: pilesRoot) + animationSystem.endRenderPass( + in: battleLayer, + handRoot: handRoot, + enemyRoot: enemyRoot, + pilesRoot: pilesRoot + ) + return + } + + let signature = StableHash.fnv1a64( + hand.map(\.cardId.rawValue).joined(separator: "|") + + "#" + + playable.sorted().map(String.init).joined(separator: ",") + + "#" + + cardDisplayMode.rawValue + + "#" + + language.rawValue + ) + + let needsHandRebuild = (handRoot.components[HandRenderStateComponent.self]?.signature != signature) + if needsHandRebuild { + handRoot.components.set(HandRenderStateComponent(signature: signature)) + handRoot.children.forEach { $0.removeFromParent() } + + let center = Float(hand.count - 1) / 2.0 + for (index, card) in hand.enumerated() { + let isPlayable = playable.contains(index) + let entity = makeCardEntity( + card: card, + isPlayable: isPlayable, + displayMode: cardDisplayMode, + language: language + ) + entity.name = "\(Names.cardNamePrefix)\(index)" + + let dx = Float(index) - center + let x = dx * 0.07 + let z = abs(dx) * 0.02 + entity.position = [x, 0, z] + + let yaw = -dx * 0.22 + let pitch: Float = 0.18 + entity.orientation = simd_quatf(angle: yaw, axis: [0, 1, 0]) * simd_quatf(angle: pitch, axis: [1, 0, 0]) + + handRoot.addChild(entity) + } + } + + renderPeek(handRoot: handRoot, in: headAnchor, peekedHandIndex: peekedHandIndex) + renderPiles(state: engine.state, in: pilesRoot) + animationSystem.endRenderPass( + in: battleLayer, + handRoot: handRoot, + enemyRoot: enemyRoot, + pilesRoot: pilesRoot + ) + } + + func renderReward( + state: BattleState, + in battleLayer: RealityKit.Entity, + newEvents: [BattlePresentationEvent] + ) { + let enemyRoot = ensureEnemyRoot(in: battleLayer) + let pilesRoot = ensurePilesRoot(in: battleLayer) + let handRoot = battleLayer + .findEntity(named: Names.battleHeadAnchor)? + .findEntity(named: Names.battleHandRoot) + + animationSystem.enqueue(events: newEvents) + animationSystem.beginRenderPass( + in: battleLayer, + handRoot: handRoot, + enemyRoot: enemyRoot + ) + + renderEnemies(enemies: state.enemies, selectedEnemyIndex: nil, in: enemyRoot) + + if let headAnchor = battleLayer.findEntity(named: Names.battleHeadAnchor) { + headAnchor.findEntity(named: Names.battleHandRoot)? + .children + .forEach { $0.removeFromParent() } + clearPeek(in: headAnchor) + } + + renderPiles(state: state, in: pilesRoot) + animationSystem.endRenderPass( + in: battleLayer, + handRoot: handRoot, + enemyRoot: enemyRoot, + pilesRoot: pilesRoot + ) + } + + private func addBattleFloor(to root: RealityKit.Entity) { + let floorEntity = ModelEntity( + mesh: .generateBox(size: [2.8, 0.01, 2.8]), + materials: [SimpleMaterial(color: .black.withAlphaComponent(0.15), isMetallic: false)] + ) + floorEntity.name = "battleFloor" + floorEntity.position = [0, -0.01, -1.0] + root.addChild(floorEntity) + } + + private func ensureEnemyRoot(in battleLayer: RealityKit.Entity) -> RealityKit.Entity { + if let root = battleLayer.findEntity(named: Names.battleEnemyRoot) { + return root + } + + let root = RealityKit.Entity() + root.name = Names.battleEnemyRoot + root.position = [0, 0.14, -1.0] + battleLayer.addChild(root) + return root + } + + private func ensureHeadAnchor(in battleLayer: RealityKit.Entity) -> AnchorEntity { + if let anchor = battleLayer.findEntity(named: Names.battleHeadAnchor) as? AnchorEntity { + return anchor + } + + let anchor = AnchorEntity(.head) + anchor.name = Names.battleHeadAnchor + battleLayer.addChild(anchor) + return anchor + } + + private func ensureHandRoot(in headAnchor: AnchorEntity) -> RealityKit.Entity { + if let handRoot = headAnchor.findEntity(named: Names.battleHandRoot) { + return handRoot + } + + let handRoot = RealityKit.Entity() + handRoot.name = Names.battleHandRoot + handRoot.position = [0, -0.12, -0.35] + headAnchor.addChild(handRoot) + return handRoot + } + + private func ensurePilesRoot(in battleLayer: RealityKit.Entity) -> RealityKit.Entity { + if let pilesRoot = battleLayer.findEntity(named: Names.battlePilesRoot) { + return pilesRoot + } + + let pilesRoot = RealityKit.Entity() + pilesRoot.name = Names.battlePilesRoot + pilesRoot.position = [0, 0.045, -0.72] + pilesRoot.orientation = simd_quatf(angle: -0.55, axis: [1, 0, 0]) + battleLayer.addChild(pilesRoot) + return pilesRoot + } + + private func renderPeek( + handRoot: RealityKit.Entity, + in headAnchor: AnchorEntity, + peekedHandIndex: Int? + ) { + let inspectRoot = headAnchor.findEntity(named: Names.battleInspectRoot) ?? { + let root = RealityKit.Entity() + root.name = Names.battleInspectRoot + root.position = [0, 0.02, -0.22] + headAnchor.addChild(root) + return root + }() + + guard let peekedHandIndex else { + inspectRoot.children.forEach { $0.removeFromParent() } + return + } + + let signature = StableHash.fnv1a64("peek#\(peekedHandIndex)") + if let state = inspectRoot.components[PileRenderStateComponent.self], state.signature == signature, !inspectRoot.children.isEmpty { + return + } + inspectRoot.components.set(PileRenderStateComponent(signature: signature)) + inspectRoot.children.forEach { $0.removeFromParent() } + + guard let cardEntity = handRoot.findEntity(named: "\(Names.cardNamePrefix)\(peekedHandIndex)") else { return } + let clone = cardEntity.clone(recursive: true) + clone.components.remove(InputTargetComponent.self) + clone.components.remove(CollisionComponent.self) + clone.name = "peekCard" + clone.position = .zero + clone.scale = [3.2, 3.2, 3.2] + clone.orientation = simd_quatf(angle: 0.35, axis: [1, 0, 0]) + inspectRoot.addChild(clone) + } + + private func clearPeek(in headAnchor: RealityKit.Entity) { + guard let inspectRoot = headAnchor.findEntity(named: Names.battleInspectRoot) else { return } + inspectRoot.children.forEach { $0.removeFromParent() } + } + + private func renderPiles(state: BattleState, in pilesRoot: RealityKit.Entity) { + let signature = StableHash.fnv1a64("piles#\(state.drawPile.count)#\(state.discardPile.count)#\(state.exhaustPile.count)") + if let stateComponent = pilesRoot.components[PileRenderStateComponent.self], stateComponent.signature == signature { + return + } + pilesRoot.components.set(PileRenderStateComponent(signature: signature)) + pilesRoot.children.forEach { $0.removeFromParent() } + + let draw = PileEntityFactory.makePileEntity(kind: .draw, count: state.drawPile.count) + draw.position = [-0.22, 0, 0] + pilesRoot.addChild(draw) + + let exhaust = PileEntityFactory.makePileEntity(kind: .exhaust, count: state.exhaustPile.count) + exhaust.position = [0.0, 0, 0] + pilesRoot.addChild(exhaust) + + let discard = PileEntityFactory.makePileEntity(kind: .discard, count: state.discardPile.count) + discard.position = [0.22, 0, 0] + pilesRoot.addChild(discard) + } + + private func renderEnemies( + enemies: [GameCore.Entity], + selectedEnemyIndex: Int?, + in enemyRoot: RealityKit.Entity + ) { + let aliveEnemies = enemies.enumerated().filter { $0.element.isAlive } + guard !aliveEnemies.isEmpty else { return } + let count = aliveEnemies.count + + let aliveIds = Set(aliveEnemies.map(\.element.id)) + let existingEnemyEntities = enemyRoot.children.compactMap { child -> (id: String, entity: ModelEntity)? in + guard child.name.hasPrefix(Names.enemyNamePrefix) else { return nil } + guard let model = child as? ModelEntity else { return nil } + let id = String(child.name.dropFirst(Names.enemyNamePrefix.count)) + return (id: id, entity: model) + } + + // Remove entities for enemies that are no longer alive/visible. + for (id, entity) in existingEnemyEntities where !aliveIds.contains(id) { + _ = id + entity.removeFromParent() + } + + var entityById: [String: ModelEntity] = Dictionary(uniqueKeysWithValues: existingEnemyEntities.map { ($0.id, $0.entity) }) + + for (visibleIndex, pair) in aliveEnemies.enumerated() { + let (stateIndex, enemy) = pair + let isSelected = (selectedEnemyIndex == stateIndex) + let enemyEntity: ModelEntity = entityById[enemy.id] ?? { + let created = makeEnemyEntity(enemy: enemy, isSelected: isSelected) + enemyRoot.addChild(created) + entityById[enemy.id] = created + return created + }() + + applySelectionState(enemyEntity, isSelected: isSelected) + enemyEntity.position = enemyPosition(at: visibleIndex, total: count) + } + } + + private func applySelectionState(_ entity: ModelEntity, isSelected: Bool) { + let baseColor = isSelected ? UIColor.systemYellow : UIColor.systemRed + entity.model?.materials = [SimpleMaterial(color: baseColor.withAlphaComponent(0.85), isMetallic: true)] + + let existingMarker = entity.children.first(where: { $0.name == "enemySelectionMarker" }) + if isSelected { + if existingMarker == nil { + let marker = ModelEntity( + mesh: .generateCylinder(height: 0.008, radius: 0.19), + materials: [SimpleMaterial(color: UIColor.systemYellow.withAlphaComponent(0.5), isMetallic: false)] + ) + marker.name = "enemySelectionMarker" + marker.position = [0, -0.145, 0] + entity.addChild(marker) + } + } else { + existingMarker?.removeFromParent() + } + } + + private func enemyPosition(at index: Int, total: Int) -> SIMD3 { + switch total { + case 1: + return [0, 0, 0] + case 2: + return [ + index == 0 ? -0.22 : 0.22, + 0, + 0.03 + ] + case 3: + return [ + Float(index - 1) * 0.24, + 0, + index == 1 ? 0 : 0.06 + ] + default: + let center = Float(total - 1) / 2.0 + let dx = Float(index) - center + return [dx * 0.2, 0, abs(dx) * 0.05] + } + } + + private func makeEnemyEntity(enemy: GameCore.Entity, isSelected: Bool) -> ModelEntity { + let baseColor = isSelected ? UIColor.systemYellow : UIColor.systemRed + let material = SimpleMaterial(color: baseColor.withAlphaComponent(0.85), isMetallic: true) + let mesh = MeshResource.generateSphere(radius: 0.14) + let entity = ModelEntity(mesh: mesh, materials: [material]) + entity.name = "\(Names.enemyNamePrefix)\(enemy.id)" + if enemy.isAlive { + entity.components.set(CollisionComponent(shapes: [.generateSphere(radius: 0.14)])) + entity.components.set(InputTargetComponent()) + } + + if isSelected { + let marker = ModelEntity( + mesh: .generateCylinder(height: 0.008, radius: 0.19), + materials: [SimpleMaterial(color: UIColor.systemYellow.withAlphaComponent(0.5), isMetallic: false)] + ) + marker.name = "enemySelectionMarker" + marker.position = [0, -0.145, 0] + entity.addChild(marker) + } + return entity + } + + private func makeCardEntity( + card: Card, + isPlayable: Bool, + displayMode: CardDisplayMode, + language: GameLanguage + ) -> ModelEntity { + let def = CardRegistry.require(card.cardId) + let baseColor: UIColor + switch def.type { + case .attack: + baseColor = .systemRed + case .skill: + baseColor = .systemBlue + case .power: + baseColor = .systemPurple + case .consumable: + baseColor = .systemGreen + } + + let color = isPlayable ? baseColor.withAlphaComponent(0.9) : baseColor.withAlphaComponent(0.25) + let material = SimpleMaterial(color: color, isMetallic: false) + let entity = ModelEntity(mesh: .generateBox(size: [0.06, 0.002, 0.09]), materials: [material]) + entity.components.set(CollisionComponent(shapes: [.generateBox(size: [0.065, 0.02, 0.095])])) + entity.components.set(InputTargetComponent()) + + if let texture = cardFaceTextureCache.texture(for: card.cardId, displayMode: displayMode, language: language) { + let faceWidth: Float = 0.056 + let faceHeight: Float = 0.086 + let faceMesh = MeshResource.generatePlane(width: faceWidth, height: faceHeight) + + var faceMaterial = UnlitMaterial() + faceMaterial.color = .init(tint: .white) + faceMaterial.color.texture = .init(texture) + + let face = ModelEntity(mesh: faceMesh, materials: [faceMaterial]) + face.name = "cardFace" + face.position = [0, 0.0012, 0] + entity.addChild(face) + } + + return entity + } +} diff --git a/SaluNative/SaluAVP/Immersive/ChapterEndPanel.swift b/SaluNative/SaluAVP/Immersive/ChapterEndPanel.swift new file mode 100644 index 0000000..565c6bf --- /dev/null +++ b/SaluNative/SaluAVP/Immersive/ChapterEndPanel.swift @@ -0,0 +1,34 @@ +import SwiftUI + +struct ChapterEndPanel: View { + let previousFloor: Int + let nextFloor: Int + let onContinue: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Chapter Cleared") + .font(.headline) + + Text("Act \(previousFloor) complete") + .font(.caption) + .foregroundStyle(.secondary) + + Text("Next: Act \(nextFloor)") + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 10) { + Button("Continue") { + onContinue() + } + .buttonStyle(.borderedProminent) + } + } + .padding(12) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .frame(width: 320) + } +} + diff --git a/SaluNative/SaluAVP/Immersive/EventRoomPanel.swift b/SaluNative/SaluAVP/Immersive/EventRoomPanel.swift new file mode 100644 index 0000000..14dd71c --- /dev/null +++ b/SaluNative/SaluAVP/Immersive/EventRoomPanel.swift @@ -0,0 +1,157 @@ +import SwiftUI +import GameCore + +struct EventRoomPanel: View { + @Environment(RunSession.self) private var runSession + + let nodeId: String + + var body: some View { + if let eventState = runSession.eventRoomState, + eventState.nodeId == nodeId { + VStack(alignment: .leading, spacing: 10) { + Text("\(eventState.offer.icon) \(eventState.offer.name.resolved(for: .zhHans))") + .font(.headline) + + Text(eventState.offer.description.resolved(for: .zhHans)) + .font(.caption) + .foregroundStyle(.secondary) + + Divider() + + switch eventState.phase { + case .choosing: + choosingView(eventState: eventState) + + case .chooseUpgrade(_, let indices, _): + upgradeChoiceView(indices: indices) + + case .awaitingBattleResolution(_, let enemyId, _): + let enemyName = EnemyRegistry.require(enemyId).name.resolved(for: .zhHans) + Text("即将进入战斗:\(enemyName)") + .font(.body) + Text("战斗结束后将返回事件结果页。") + .font(.caption) + .foregroundStyle(.secondary) + + case .resolved(let optionIndex, let resultLines): + resolvedView(eventState: eventState, optionIndex: optionIndex, resultLines: resultLines) + } + + if let message = eventState.message, !message.isEmpty { + Text(message) + .font(.caption) + .foregroundStyle(.red) + } + + Text("Node: \(nodeId)") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(12) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .frame(width: 380) + } else { + EmptyView() + } + } + + @ViewBuilder + private func choosingView(eventState: EventRoomState) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("请选择一个选项") + .font(.subheadline) + + ForEach(Array(eventState.offer.options.enumerated()), id: \.offset) { index, option in + Button { + runSession.chooseEventOption(index) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(option.title.resolved(for: .zhHans)) + .font(.body) + if let preview = option.preview?.resolved(for: .zhHans), !preview.isEmpty { + Text(preview) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.bordered) + } + } + } + + @ViewBuilder + private func upgradeChoiceView(indices: [Int]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("选择要升级的卡牌") + .font(.subheadline) + + if let runState = runSession.runState { + ForEach(indices, id: \.self) { deckIndex in + if runState.deck.indices.contains(deckIndex) { + let card = runState.deck[deckIndex] + let cardDef = CardRegistry.require(card.cardId) + let upgradedName = cardDef.upgradedId.map { + CardRegistry.require($0).name.resolved(for: .zhHans) + } ?? "不可升级" + + Button { + runSession.chooseEventUpgradeCard(deckIndex: deckIndex) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text("\(cardDef.name.resolved(for: .zhHans)) -> \(upgradedName)") + .font(.body) + Text("牌组位置 \(deckIndex + 1)") + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.bordered) + } + } + } else { + Text("运行状态不可用,无法升级卡牌。") + .font(.caption) + .foregroundStyle(.secondary) + } + + Button("跳过升级") { + runSession.skipEventUpgradeChoice() + } + .buttonStyle(.bordered) + } + } + + @ViewBuilder + private func resolvedView(eventState: EventRoomState, optionIndex: Int, resultLines: [String]) -> some View { + VStack(alignment: .leading, spacing: 8) { + if eventState.offer.options.indices.contains(optionIndex) { + Text("你选择了:\(eventState.offer.options[optionIndex].title.resolved(for: .zhHans))") + .font(.subheadline) + } else { + Text("事件结算") + .font(.subheadline) + } + + if resultLines.isEmpty { + Text("没有发生任何事。") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(Array(resultLines.enumerated()), id: \.offset) { _, line in + Text("• \(line)") + .font(.caption) + } + } + + Button("继续前进") { + runSession.completeEventRoom() + } + .buttonStyle(.borderedProminent) + } + } +} diff --git a/SaluNative/SaluAVP/Immersive/FloatingTextFactory.swift b/SaluNative/SaluAVP/Immersive/FloatingTextFactory.swift new file mode 100644 index 0000000..a8e4fa6 --- /dev/null +++ b/SaluNative/SaluAVP/Immersive/FloatingTextFactory.swift @@ -0,0 +1,92 @@ +import CoreGraphics +import RealityKit +import UIKit + +@MainActor +enum FloatingTextFactory { + enum Style: Sendable { + case damage + case block + case neutral + + var textColor: UIColor { + switch self { + case .damage: + return UIColor(red: 0.98, green: 0.28, blue: 0.24, alpha: 1.0) + case .block: + return UIColor(red: 0.26, green: 0.68, blue: 0.96, alpha: 1.0) + case .neutral: + return UIColor(white: 0.94, alpha: 1.0) + } + } + + var backgroundColor: UIColor { + switch self { + case .damage: + return UIColor(red: 0.18, green: 0.05, blue: 0.04, alpha: 0.90) + case .block: + return UIColor(red: 0.04, green: 0.10, blue: 0.20, alpha: 0.90) + case .neutral: + return UIColor(white: 0.10, alpha: 0.88) + } + } + } + + static func makeEntity(text: String, style: Style) -> ModelEntity? { + guard let cgImage = renderLabel(text: text, style: style) else { return nil } + do { + let texture = try TextureResource( + image: cgImage, + withName: nil, + options: .init(semantic: .color) + ) + var material = UnlitMaterial() + material.color = .init(tint: .white) + material.color.texture = .init(texture) + + let entity = ModelEntity(mesh: .generatePlane(width: 0.18, height: 0.08), materials: [material]) + entity.name = "floatingText:\(text)" + return entity + } catch { + return nil + } + } + + private static func renderLabel(text: String, style: Style) -> CGImage? { + let size = CGSize(width: 512, height: 256) + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { ctx in + let bounds = CGRect(origin: .zero, size: size) + + UIColor.clear.setFill() + ctx.fill(bounds) + + let bubbleRect = bounds.insetBy(dx: 16, dy: 30) + let bubblePath = UIBezierPath(roundedRect: bubbleRect, cornerRadius: 28) + style.backgroundColor.setFill() + bubblePath.fill() + + UIColor(white: 1.0, alpha: 0.16).setStroke() + bubblePath.lineWidth = 4 + bubblePath.stroke() + + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + + let font = UIFont.monospacedDigitSystemFont(ofSize: 92, weight: .heavy) + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: style.textColor, + .paragraphStyle: paragraph, + ] + let textRect = CGRect( + x: bubbleRect.minX + 12, + y: bubbleRect.minY + 30, + width: bubbleRect.width - 24, + height: bubbleRect.height - 40 + ) + (text as NSString).draw(in: textRect, withAttributes: attributes) + } + return image.cgImage + } +} diff --git a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift index a84fafd..bafae7b 100644 --- a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift +++ b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift @@ -9,15 +9,17 @@ struct ImmersiveRootView: View { @Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace @Environment(\.openWindow) private var openWindow - @State private var cardFaceTextureCache = CardFaceTextureCache() + @State private var battleSceneRenderer = BattleSceneRenderer() + @State private var roomSceneRenderer = RoomSceneRenderer() @State private var peekedHandIndex: Int? = nil @State private var peekedPile: PileKind? = nil @State private var didPeekInCurrentPress: Bool = false @State private var suppressNextTap: Bool = false + @State private var shownShopMessageSequence: UInt64 = 0 + @State private var shopFeedbackTask: Task? = nil + @State private var selectedShopItem: ShopItemSelection? = nil private let nodeNamePrefix = "node:" - private let cardNamePrefix = "card:" - private let pileNamePrefix = "pile:" private let roomPanelAttachmentId = "roomPanel" private let battleHudAttachmentId = "battleHUD" private let mapHudAttachmentId = "mapHUD" @@ -25,13 +27,7 @@ struct ImmersiveRootView: View { private let pilePeekAttachmentId = "pilePeek" private let mapLayerPrefix = "mapLayer_floor_" private let uiLayerName = "uiLayer" - private let battleLayerName = "battleLayer" private let hudAnchorName = "hudAnchor" - private let battleHeadAnchorName = "battleHeadAnchor" - private let battleHandRootName = "battleHandRoot" - private let battleEnemyRootName = "battleEnemyRoot" - private let battleInspectRootName = "battleInspectRoot" - private let battlePilesRootName = "battlePilesRoot" var body: some View { let isBattleRoute: Bool = { @@ -56,12 +52,24 @@ struct ImmersiveRootView: View { runSession.selectAccessibleNode(nodeId) case .battle: - guard value.entity.name.hasPrefix(cardNamePrefix) else { return } - let suffix = value.entity.name.dropFirst(cardNamePrefix.count) + if value.entity.name.hasPrefix(BattleSceneRenderer.Names.enemyNamePrefix) { + let enemyId = String( + value.entity.name.dropFirst(BattleSceneRenderer.Names.enemyNamePrefix.count) + ) + runSession.selectEnemyTarget(entityId: enemyId) + return + } + guard value.entity.name.hasPrefix(BattleSceneRenderer.Names.cardNamePrefix) else { return } + let suffix = value.entity.name.dropFirst(BattleSceneRenderer.Names.cardNamePrefix.count) guard let handIndex = Int(suffix) else { return } runSession.playCard(handIndex: handIndex) - case .cardReward, .room, .runOver: + case .room(_, let roomType): + if roomType == .shop { + handleShopSceneTap(named: value.entity.name) + } + + case .reward, .chapterEnd, .runOver: break } } @@ -77,8 +85,8 @@ struct ImmersiveRootView: View { guard translation.height < -22 else { return } let name = value.entity.name - if name.hasPrefix(cardNamePrefix) { - let suffix = name.dropFirst(cardNamePrefix.count) + if name.hasPrefix(BattleSceneRenderer.Names.cardNamePrefix) { + let suffix = name.dropFirst(BattleSceneRenderer.Names.cardNamePrefix.count) guard let handIndex = Int(suffix) else { return } didPeekInCurrentPress = true peekedPile = nil @@ -86,8 +94,8 @@ struct ImmersiveRootView: View { return } - if name.hasPrefix(pileNamePrefix) { - let suffix = String(name.dropFirst(pileNamePrefix.count)) + if name.hasPrefix(BattleSceneRenderer.Names.pileNamePrefix) { + let suffix = String(name.dropFirst(BattleSceneRenderer.Names.pileNamePrefix.count)) guard let kind = PileKind(rawValue: suffix) else { return } didPeekInCurrentPress = true peekedHandIndex = nil @@ -119,26 +127,10 @@ struct ImmersiveRootView: View { hudAnchor.name = hudAnchorName mapRoot.addChild(hudAnchor) - let battleLayer = RealityKit.Entity() - battleLayer.name = battleLayerName - battleLayer.isEnabled = false - - addBattleFloor(to: battleLayer) - - let enemyRoot = RealityKit.Entity() - enemyRoot.name = battleEnemyRootName - enemyRoot.position = [0, 0.14, -1.0] - battleLayer.addChild(enemyRoot) - - let headAnchor = AnchorEntity(.head) - headAnchor.name = battleHeadAnchorName - battleLayer.addChild(headAnchor) - - let handRoot = RealityKit.Entity() - handRoot.name = battleHandRootName - handRoot.position = [0, -0.12, -0.35] - headAnchor.addChild(handRoot) + let roomLayer = roomSceneRenderer.makeRoomLayer() + mapRoot.addChild(roomLayer) + let battleLayer = battleSceneRenderer.makeBattleLayer() mapRoot.addChild(battleLayer) content.add(mapRoot) } update: { content, attachments in @@ -162,7 +154,8 @@ struct ImmersiveRootView: View { guard let run = runSession.runState else { mapRoot.children.first(where: { $0.name.hasPrefix(mapLayerPrefix) })?.removeFromParent() - mapRoot.findEntity(named: battleLayerName)?.isEnabled = false + mapRoot.findEntity(named: RoomSceneRenderer.Names.roomLayer)?.isEnabled = false + mapRoot.findEntity(named: BattleSceneRenderer.Names.battleLayer)?.isEnabled = false return } @@ -184,69 +177,126 @@ struct ImmersiveRootView: View { mapLayer = newLayer } - let battleLayer = mapRoot.findEntity(named: battleLayerName) ?? { - let battleLayer = RealityKit.Entity() - battleLayer.name = battleLayerName - battleLayer.isEnabled = false - addBattleFloor(to: battleLayer) - - let enemyRoot = RealityKit.Entity() - enemyRoot.name = battleEnemyRootName - enemyRoot.position = [0, 0.14, -1.0] - battleLayer.addChild(enemyRoot) - - let headAnchor = AnchorEntity(.head) - headAnchor.name = battleHeadAnchorName - battleLayer.addChild(headAnchor) - - let handRoot = RealityKit.Entity() - handRoot.name = battleHandRootName - handRoot.position = [0, -0.12, -0.35] - headAnchor.addChild(handRoot) - + let battleLayer = mapRoot.findEntity(named: BattleSceneRenderer.Names.battleLayer) ?? { + let battleLayer = battleSceneRenderer.makeBattleLayer() mapRoot.addChild(battleLayer) return battleLayer }() + let roomLayer = mapRoot.findEntity(named: RoomSceneRenderer.Names.roomLayer) ?? { + let roomLayer = roomSceneRenderer.makeRoomLayer() + mapRoot.addChild(roomLayer) + return roomLayer + }() + let isInBattle: Bool = { switch runSession.route { - case .battle, .cardReward: + case .battle, .reward: return true - case .map, .room, .runOver: + case .map, .room, .chapterEnd, .runOver: return false } }() - mapLayer.isEnabled = !isInBattle + let isInRoom: Bool = { + if case .room = runSession.route { + return true + } + return false + }() + + mapLayer.isEnabled = !isInBattle && !isInRoom + roomLayer.isEnabled = isInRoom battleLayer.isEnabled = isInBattle switch runSession.route { + case .room(let nodeId, let roomType): + roomSceneRenderer.render( + nodeId: nodeId, + roomType: roomType, + shopState: runSession.shopRoomState, + runState: runSession.runState, + in: roomLayer + ) + if roomType == .shop { + sanitizeShopSelection() + updateShopFeedback(in: roomLayer) + } else { + selectedShopItem = nil + clearShopFeedback(in: roomLayer) + } + battleSceneRenderer.clear(in: battleLayer) + case .battle: + roomSceneRenderer.clear(in: roomLayer) + selectedShopItem = nil + clearShopFeedback(in: roomLayer) if let engine = runSession.battleEngine { - renderBattle(engine: engine, in: battleLayer) + let newEvents = runSession.consumeNewBattlePresentationEvents() + battleSceneRenderer.render( + engine: engine, + in: battleLayer, + cardDisplayMode: appModel.cardDisplayMode, + language: .zhHans, + peekedHandIndex: peekedHandIndex, + selectedEnemyIndex: runSession.selectedEnemyIndex, + newEvents: newEvents + ) } else { - clearBattle(in: battleLayer) + battleSceneRenderer.clear(in: battleLayer) } - case .cardReward: + case .reward: + roomSceneRenderer.clear(in: roomLayer) + selectedShopItem = nil + clearShopFeedback(in: roomLayer) if let state = runSession.battleState { - renderBattleReward(state: state, in: battleLayer) + let newEvents = runSession.consumeNewBattlePresentationEvents() + battleSceneRenderer.renderReward(state: state, in: battleLayer, newEvents: newEvents) } else { - clearBattle(in: battleLayer) + battleSceneRenderer.clear(in: battleLayer) } - case .map, .room, .runOver: - clearBattle(in: battleLayer) + case .chapterEnd, .map, .runOver: + roomSceneRenderer.clear(in: roomLayer) + selectedShopItem = nil + clearShopFeedback(in: roomLayer) + battleSceneRenderer.clear(in: battleLayer) } + uiLayer.children.first(where: { $0.name == roomPanelAttachmentId })?.removeFromParent() + roomLayer.children.first(where: { $0.name == roomPanelAttachmentId })?.removeFromParent() + hudAnchor.children.first(where: { $0.name == roomPanelAttachmentId })?.removeFromParent() + if let panel = attachments.entity(for: roomPanelAttachmentId) { panel.name = roomPanelAttachmentId panel.components.set(BillboardComponent()) panel.components.set(InputTargetComponent()) - let (isVisible, position) = roomPanelPlacement(mapRoot: mapLayer, route: runSession.route) - panel.isEnabled = isVisible - panel.position = position - uiLayer.addChild(panel) + + switch runSession.route { + case .room(_, let roomType): + if roomType == .shop { + if selectedShopItem == nil { + panel.isEnabled = false + } else { + panel.isEnabled = true + panel.position = [0.30, 0.15, -0.50] + hudAnchor.addChild(panel) + } + } else { + panel.isEnabled = true + panel.position = roomSceneRenderer.panelPosition(for: roomType) + roomLayer.addChild(panel) + } + + case .runOver: + panel.isEnabled = true + panel.position = [0, 0.25, -0.55] + uiLayer.addChild(panel) + + case .map, .battle, .reward, .chapterEnd: + panel.isEnabled = false + } } if let hud = attachments.entity(for: battleHudAttachmentId) { @@ -269,7 +319,8 @@ struct ImmersiveRootView: View { hud.name = mapHudAttachmentId hud.components.set(BillboardComponent()) hud.components.set(InputTargetComponent()) - hud.isEnabled = !isInBattle + let isInChapterEnd: Bool = { if case .chapterEnd = runSession.route { return true } else { return false } }() + hud.isEnabled = !isInBattle && !isInRoom && !isInChapterEnd hudAnchor.children.first(where: { $0.name == mapHudAttachmentId })?.removeFromParent() hud.position = [0.18, 0.17, -0.50] @@ -280,7 +331,15 @@ struct ImmersiveRootView: View { panel.name = cardRewardAttachmentId panel.components.set(BillboardComponent()) panel.components.set(InputTargetComponent()) - if case .cardReward = runSession.route { + let isRewardVisible: Bool = { + switch runSession.route { + case .reward, .chapterEnd: + return true + default: + return false + } + }() + if isRewardVisible { panel.isEnabled = true } else { panel.isEnabled = false @@ -305,18 +364,38 @@ struct ImmersiveRootView: View { } } attachments: { Attachment(id: roomPanelAttachmentId) { - RoomPanel( - route: runSession.route, - onCompleteRoom: { runSession.completeCurrentRoomAndReturnToMap() }, - onNewRun: { runSession.startNewRun() }, - onClose: { - Task { @MainActor in - runSession.resetToControlPanel() - await dismissImmersiveSpace() - openWindow(id: AppModel.controlPanelWindowID) + switch runSession.route { + case .room(let nodeId, let roomType): + switch roomType { + case .rest: + RestRoomPanel(nodeId: nodeId) + case .shop: + ShopRoomPanel(nodeId: nodeId, selection: $selectedShopItem) + case .event: + EventRoomPanel(nodeId: nodeId) + default: + GenericRoomPanel(nodeId: nodeId, roomType: roomType) { + runSession.completeCurrentRoomAndReturnToMap() } } - ) + + case .runOver(_, let won, let floor): + RunOverPanel( + won: won, + floor: floor, + onNewRun: { runSession.startNewRun() }, + onClose: { + Task { @MainActor in + runSession.resetToControlPanel() + await dismissImmersiveSpace() + openWindow(id: AppModel.controlPanelWindowID) + } + } + ) + + case .map, .battle, .reward, .chapterEnd: + EmptyView() + } } Attachment(id: battleHudAttachmentId) { @@ -341,7 +420,7 @@ struct ImmersiveRootView: View { .applyIf(isBattleRoute) { view in view.highPriorityGesture(dragPeekGesture) } - .onChange(of: isBattleRoute) { newValue in + .onChange(of: isBattleRoute) { _, newValue in if !newValue { suppressNextTap = false didPeekInCurrentPress = false @@ -351,281 +430,126 @@ struct ImmersiveRootView: View { } } - private func roomPanelPlacement(mapRoot: RealityKit.Entity, route: RunSession.Route) -> (isVisible: Bool, position: SIMD3) { - switch route { - case .map: - return (false, .zero) + private func handleShopSceneTap(named entityName: String) { + guard entityName.hasPrefix(RoomSceneRenderer.Names.shopActionPrefix) else { return } - case .room(let nodeId, _): - let nodeName = "\(nodeNamePrefix)\(nodeId)" - if let node = mapRoot.findEntity(named: nodeName) { - // Place the panel above the selected node; billboard will face the user. - return (true, node.position + [0, 0.18, 0]) + let suffix = String(entityName.dropFirst(RoomSceneRenderer.Names.shopActionPrefix.count)) + if suffix == "leave" { + if selectedShopItem != nil { + selectedShopItem = nil + runSession.clearShopTransientMessage() + return } - - // Fallback: place in front of the map origin. - return (true, [0, 0.25, -0.55]) - - case .battle: - return (false, .zero) - - case .cardReward: - return (false, .zero) - - case .runOver(let lastNodeId, _, _): - // End-of-run panel should be easy to find: keep it near the map origin instead of far away at the Boss node. - _ = lastNodeId - return (true, [0, 0.25, -0.55]) + selectedShopItem = nil + runSession.leaveShopRoom() + return } - } - - private func addFloor(to root: RealityKit.Entity) { - let floor = RealityKit.Entity() - floor.name = "floor" - floor.components.set(CollisionComponent(shapes: [.generateBox(size: [2.8, 0.01, 4.2])])) - floor.components.set(InputTargetComponent()) - root.addChild(floor) - } - private func addBattleFloor(to root: RealityKit.Entity) { - let floorEntity = ModelEntity( - mesh: .generateBox(size: [2.8, 0.01, 2.8]), - materials: [SimpleMaterial(color: .black.withAlphaComponent(0.15), isMetallic: false)] - ) - floorEntity.name = "battleFloor" - floorEntity.position = [0, -0.01, -1.0] - root.addChild(floorEntity) - } - - private func clearBattle(in battleLayer: RealityKit.Entity) { - battleLayer.findEntity(named: battleEnemyRootName)?.children.forEach { $0.removeFromParent() } - if let headAnchor = battleLayer.findEntity(named: battleHeadAnchorName) { - headAnchor.findEntity(named: battleHandRootName)?.children.forEach { $0.removeFromParent() } - headAnchor.findEntity(named: battleInspectRootName)?.children.forEach { $0.removeFromParent() } + guard let nextSelection = parseShopSelection(from: suffix) else { return } + runSession.clearShopTransientMessage() + if selectedShopItem == nextSelection { + selectedShopItem = nil + } else { + selectedShopItem = nextSelection } - battleLayer.findEntity(named: battlePilesRootName)?.children.forEach { $0.removeFromParent() } } - private func renderBattle(engine: BattleEngine, in battleLayer: RealityKit.Entity) { - let enemyRoot = battleLayer.findEntity(named: battleEnemyRootName) ?? { - let root = RealityKit.Entity() - root.name = battleEnemyRootName - root.position = [0, 0.14, -1.0] - battleLayer.addChild(root) - return root - }() - - enemyRoot.children.forEach { $0.removeFromParent() } - - if let enemy = engine.state.enemies.first { - let enemyEntity = makeEnemyEntity(enemy: enemy) - enemyEntity.position = .zero - enemyRoot.addChild(enemyEntity) + private func parseShopSelection(from suffix: String) -> ShopItemSelection? { + let parts = suffix.split(separator: ":", omittingEmptySubsequences: true) + guard parts.count == 2, let index = Int(parts[1]) else { return nil } + + switch parts[0] { + case "card": + return .card(index) + case "relic": + return .relic(index) + case "consumable": + return .consumable(index) + case "remove": + return .removeCard(index) + default: + return nil } + } - let headAnchor = battleLayer.findEntity(named: battleHeadAnchorName) ?? { - let anchor = AnchorEntity(.head) - anchor.name = battleHeadAnchorName - battleLayer.addChild(anchor) - return anchor - }() - - let handRoot = headAnchor.findEntity(named: battleHandRootName) ?? { - let root = RealityKit.Entity() - root.name = battleHandRootName - root.position = [0, -0.12, -0.35] - headAnchor.addChild(root) - return root - }() - - let hand = engine.state.hand - guard !hand.isEmpty else { - clearPeek(in: headAnchor) + private func sanitizeShopSelection() { + guard let selectedShopItem else { return } + guard let runState = runSession.runState, let shopState = runSession.shopRoomState else { + self.selectedShopItem = nil return } - let displayMode = appModel.cardDisplayMode - let language: GameLanguage = .zhHans - let playable = Set(engine.playableCardIndices) - let signature = StableHash.fnv1a64( - hand.map(\.cardId.rawValue).joined(separator: "|") - + "#" - + playable.sorted().map(String.init).joined(separator: ",") - + "#" - + displayMode.rawValue - + "#" - + language.rawValue - ) - - let needsHandRebuild = (handRoot.components[HandRenderStateComponent.self]?.signature != signature) - if needsHandRebuild { - handRoot.components.set(HandRenderStateComponent(signature: signature)) - handRoot.children.forEach { $0.removeFromParent() } - - let count = hand.count - let center = Float(count - 1) / 2.0 - - for (index, card) in hand.enumerated() { - let isPlayable = playable.contains(index) - let entity = makeCardEntity(card: card, isPlayable: isPlayable, displayMode: displayMode, language: language) - entity.name = "\(cardNamePrefix)\(index)" - - let dx = Float(index) - center - let x = dx * 0.07 - // Outer cards should come slightly closer to the user. - let z = abs(dx) * 0.02 - entity.position = [x, 0, z] - - // Fan the cards toward the user (arc center facing the player). - let yaw = -dx * 0.22 - let pitch: Float = 0.18 - entity.orientation = simd_quatf(angle: yaw, axis: [0, 1, 0]) * simd_quatf(angle: pitch, axis: [1, 0, 0]) - - handRoot.addChild(entity) - } - } - - renderPeek(handRoot: handRoot, in: headAnchor) - renderPiles(state: engine.state, in: battleLayer) - } - - private func renderPeek(handRoot: RealityKit.Entity, in headAnchor: RealityKit.Entity) { - let inspectRoot = headAnchor.findEntity(named: battleInspectRootName) ?? { - let root = RealityKit.Entity() - root.name = battleInspectRootName - root.position = [0, 0.02, -0.22] - headAnchor.addChild(root) - return root - }() - - guard let peekedHandIndex else { - inspectRoot.children.forEach { $0.removeFromParent() } - return + let isValid: Bool + switch selectedShopItem { + case .card(let index): + isValid = shopState.inventory.cardOffers.indices.contains(index) + case .relic(let index): + isValid = shopState.inventory.relicOffers.indices.contains(index) + case .consumable(let index): + isValid = shopState.inventory.consumableOffers.indices.contains(index) + case .removeCard(let deckIndex): + isValid = runState.deck.indices.contains(deckIndex) } - let signature = StableHash.fnv1a64("peek#\(peekedHandIndex)") - if let state = inspectRoot.components[PileRenderStateComponent.self], state.signature == signature, !inspectRoot.children.isEmpty { - return + if !isValid { + self.selectedShopItem = nil } - inspectRoot.components.set(PileRenderStateComponent(signature: signature)) - inspectRoot.children.forEach { $0.removeFromParent() } - - guard let cardEntity = handRoot.findEntity(named: "\(cardNamePrefix)\(peekedHandIndex)") else { return } - let clone = cardEntity.clone(recursive: true) - clone.components.remove(InputTargetComponent.self) - clone.components.remove(CollisionComponent.self) - clone.name = "peekCard" - clone.position = .zero - clone.scale = [3.2, 3.2, 3.2] - clone.orientation = simd_quatf(angle: 0.35, axis: [1, 0, 0]) - inspectRoot.addChild(clone) } - private func clearPeek(in headAnchor: RealityKit.Entity) { - guard let inspectRoot = headAnchor.findEntity(named: battleInspectRootName) else { return } - inspectRoot.children.forEach { $0.removeFromParent() } - } + private func updateShopFeedback(in roomLayer: RealityKit.Entity) { + guard let shopState = runSession.shopRoomState, + let message = shopState.message, + !message.isEmpty else { return } + guard shopState.messageSequence > shownShopMessageSequence else { return } - private func renderPiles(state: BattleState, in battleLayer: RealityKit.Entity) { - let pilesRoot = battleLayer.findEntity(named: battlePilesRootName) ?? { - let root = RealityKit.Entity() - root.name = battlePilesRootName - // Keep piles fixed on the "table" (world space), not attached to the user's head. - // Position is relative to battleLayer. - root.position = [0, 0.045, -0.72] - root.orientation = simd_quatf(angle: -0.55, axis: [1, 0, 0]) - battleLayer.addChild(root) - return root - }() + shownShopMessageSequence = shopState.messageSequence + roomLayer.children + .filter { $0.name.hasPrefix("shopFeedback:") } + .forEach { $0.removeFromParent() } - let signature = StableHash.fnv1a64("piles#\(state.drawPile.count)#\(state.discardPile.count)#\(state.exhaustPile.count)") - if let stateComponent = pilesRoot.components[PileRenderStateComponent.self], stateComponent.signature == signature { - return + let style = shopFeedbackStyle(for: message) + guard let feedback = FloatingTextFactory.makeEntity(text: message, style: style) else { return } + feedback.name = "shopFeedback:\(shopState.messageSequence)" + feedback.position = [0, 0.42, -0.76] + feedback.scale = [0.60, 0.60, 1.0] + feedback.components.set(BillboardComponent()) + roomLayer.addChild(feedback) + + shopFeedbackTask?.cancel() + shopFeedbackTask = Task { @MainActor in + var toTransform = feedback.transform + toTransform.translation = [feedback.position.x, feedback.position.y + 0.10, feedback.position.z] + feedback.move(to: toTransform, relativeTo: feedback.parent, duration: 0.85, timingFunction: .easeOut) + try? await Task.sleep(nanoseconds: 1_000_000_000) + if Task.isCancelled { return } + feedback.removeFromParent() } - pilesRoot.components.set(PileRenderStateComponent(signature: signature)) - pilesRoot.children.forEach { $0.removeFromParent() } - - let draw = PileEntityFactory.makePileEntity(kind: .draw, count: state.drawPile.count) - draw.position = [-0.22, 0, 0] - pilesRoot.addChild(draw) - - let exhaust = PileEntityFactory.makePileEntity(kind: .exhaust, count: state.exhaustPile.count) - exhaust.position = [0.0, 0, 0] - pilesRoot.addChild(exhaust) - - let discard = PileEntityFactory.makePileEntity(kind: .discard, count: state.discardPile.count) - discard.position = [0.22, 0, 0] - pilesRoot.addChild(discard) } - private func renderBattleReward(state: BattleState, in battleLayer: RealityKit.Entity) { - let enemyRoot = battleLayer.findEntity(named: battleEnemyRootName) ?? { - let root = RealityKit.Entity() - root.name = battleEnemyRootName - root.position = [0, 0.14, -1.0] - battleLayer.addChild(root) - return root - }() - - enemyRoot.children.forEach { $0.removeFromParent() } - if let enemy = state.enemies.first { - let enemyEntity = makeEnemyEntity(enemy: enemy) - enemyEntity.position = .zero - enemyRoot.addChild(enemyEntity) - } - - battleLayer.findEntity(named: battleHeadAnchorName)? - .findEntity(named: battleHandRootName)? - .children + private func clearShopFeedback(in roomLayer: RealityKit.Entity) { + roomLayer.children + .filter { $0.name.hasPrefix("shopFeedback:") } .forEach { $0.removeFromParent() } + shopFeedbackTask?.cancel() + shopFeedbackTask = nil } - private func makeEnemyEntity(enemy: GameCore.Entity) -> ModelEntity { - let material = SimpleMaterial(color: UIColor.systemRed.withAlphaComponent(0.85), isMetallic: true) - let mesh = MeshResource.generateSphere(radius: 0.14) - let entity = ModelEntity(mesh: mesh, materials: [material]) - entity.name = "enemy:0" - entity.components.set(CollisionComponent(shapes: [.generateSphere(radius: 0.14)])) - entity.components.set(InputTargetComponent()) - return entity - } - - private func makeCardEntity(card: Card, isPlayable: Bool, displayMode: CardDisplayMode, language: GameLanguage) -> ModelEntity { - let def = CardRegistry.require(card.cardId) - let baseColor: UIColor - switch def.type { - case .attack: - baseColor = .systemRed - case .skill: - baseColor = .systemBlue - case .power: - baseColor = .systemPurple - case .consumable: - baseColor = .systemGreen - } - - let color = isPlayable ? baseColor.withAlphaComponent(0.9) : baseColor.withAlphaComponent(0.25) - let material = SimpleMaterial(color: color, isMetallic: false) - let entity = ModelEntity(mesh: .generateBox(size: [0.06, 0.002, 0.09]), materials: [material]) - entity.components.set(CollisionComponent(shapes: [.generateBox(size: [0.065, 0.02, 0.095])])) - entity.components.set(InputTargetComponent()) - - if let texture = cardFaceTextureCache.texture(for: card.cardId, displayMode: displayMode, language: language) { - let faceWidth: Float = 0.056 - let faceHeight: Float = 0.086 - let faceMesh = MeshResource.generatePlane(width: faceWidth, height: faceHeight) - - var faceMaterial = UnlitMaterial() - faceMaterial.color = .init(tint: .white) - faceMaterial.color.texture = .init(texture) - - let face = ModelEntity(mesh: faceMesh, materials: [faceMaterial]) - face.name = "cardFace" - face.position = [0, 0.0012, 0] - entity.addChild(face) + private func shopFeedbackStyle(for message: String) -> FloatingTextFactory.Style { + if message.contains("不足") + || message.contains("无效") + || message.contains("失败") + || message.contains("已满") { + return .damage } + return .block + } - return entity + private func addFloor(to root: RealityKit.Entity) { + let floor = RealityKit.Entity() + floor.name = "floor" + floor.components.set(CollisionComponent(shapes: [.generateBox(size: [2.8, 0.01, 4.2])])) + floor.components.set(InputTargetComponent()) + root.addChild(floor) } private func updateMapState(run: RunState, in mapLayer: RealityKit.Entity) { @@ -766,60 +690,56 @@ private extension View { } } -private struct RoomPanel: View { - let route: RunSession.Route +private struct GenericRoomPanel: View { + let nodeId: String + let roomType: RoomType let onCompleteRoom: () -> Void - let onNewRun: () -> Void - let onClose: () -> Void var body: some View { - Group { - switch route { - case .map: - EmptyView() - - case .room(let nodeId, let roomType): - VStack(alignment: .leading, spacing: 10) { - Text("\(roomType.icon) \(roomType.displayName(language: .zhHans))") - .font(.headline) + VStack(alignment: .leading, spacing: 10) { + Text("\(roomType.icon) \(roomType.displayName(language: .zhHans))") + .font(.headline) - Text("Node: \(nodeId)") - .font(.caption) - .foregroundStyle(.secondary) + Text("Node: \(nodeId)") + .font(.caption) + .foregroundStyle(.secondary) - Button("Complete") { - onCompleteRoom() - } - .buttonStyle(.borderedProminent) - } - - case .battle: - EmptyView() + Button("继续") { + onCompleteRoom() + } + .buttonStyle(.borderedProminent) + } + .padding(12) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} - case .cardReward: - EmptyView() +private struct RunOverPanel: View { + let won: Bool + let floor: Int + let onNewRun: () -> Void + let onClose: () -> Void - case .runOver(_, let won, let floor): - VStack(alignment: .leading, spacing: 10) { - Text(won ? "🎉 Victory" : "💀 Defeat") - .font(.headline) + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(won ? "Victory" : "Defeat") + .font(.headline) - Text("Run ended at Act \(floor)") - .font(.caption) - .foregroundStyle(.secondary) + Text("Run ended at Act \(floor)") + .font(.caption) + .foregroundStyle(.secondary) - HStack(spacing: 10) { - Button("New Run") { - onNewRun() - } - .buttonStyle(.borderedProminent) + HStack(spacing: 10) { + Button("New Run") { + onNewRun() + } + .buttonStyle(.borderedProminent) - Button("Close") { - onClose() - } - .buttonStyle(.bordered) - } + Button("Close") { + onClose() } + .buttonStyle(.bordered) } } .padding(12) @@ -833,18 +753,35 @@ private struct CardRewardAttachment: View { var body: some View { switch runSession.route { - case .cardReward(let nodeId, let roomType, let offer, let goldEarned): - CardRewardPanel(nodeId: nodeId, roomType: roomType, offer: offer, goldEarned: goldEarned) + case .reward(let rewardState): + switch rewardState.phase { + case .relic: + if let relicReward = rewardState.relicReward { + RelicRewardPanel(rewardState: rewardState, relicReward: relicReward) + } else { + CardRewardPanel( + nodeId: rewardState.nodeId, + roomType: rewardState.roomType, + offer: rewardState.cardOffer, + goldEarned: rewardState.goldEarned + ) + } + case .card: + CardRewardPanel( + nodeId: rewardState.nodeId, + roomType: rewardState.roomType, + offer: rewardState.cardOffer, + goldEarned: rewardState.goldEarned + ) + } + + case .chapterEnd(let previousFloor, let nextFloor): + ChapterEndPanel(previousFloor: previousFloor, nextFloor: nextFloor) { + runSession.continueAfterChapterEnd() + } default: EmptyView() } } } - -private extension RunSession.Route { - var isRoom: Bool { - if case .room = self { return true } - return false - } -} diff --git a/SaluNative/SaluAVP/Immersive/RelicRewardPanel.swift b/SaluNative/SaluAVP/Immersive/RelicRewardPanel.swift new file mode 100644 index 0000000..ce55874 --- /dev/null +++ b/SaluNative/SaluAVP/Immersive/RelicRewardPanel.swift @@ -0,0 +1,65 @@ +import SwiftUI +import GameCore + +struct RelicRewardPanel: View { + @Environment(RunSession.self) private var runSession + + let rewardState: RewardRouteState + let relicReward: RewardRouteState.RelicReward + + var body: some View { + let def = RelicRegistry.require(relicReward.relicId) + + VStack(alignment: .leading, spacing: 10) { + Text("Relic Reward") + .font(.headline) + + Text("Gold +\(rewardState.goldEarned)") + .font(.caption) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 6) { + Text("\(def.icon) \(def.name.resolved(for: .zhHans))") + .font(.body) + Text(sourceLabel(relicReward.source)) + .font(.caption2) + .foregroundStyle(.secondary) + Text(def.description.resolved(for: .zhHans)) + .font(.caption) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(spacing: 10) { + Button("Take") { + runSession.chooseRelicReward(take: true) + } + .buttonStyle(.borderedProminent) + + Button("Skip") { + runSession.chooseRelicReward(take: false) + } + .buttonStyle(.bordered) + + Spacer(minLength: 0) + + Text("\(rewardState.roomType.icon) \(rewardState.nodeId)") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(12) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .frame(width: 320) + } + + private func sourceLabel(_ source: RelicDropSource) -> String { + switch source { + case .elite: + return "Elite Relic" + case .boss: + return "Boss Relic" + } + } +} + diff --git a/SaluNative/SaluAVP/Immersive/RestRoomPanel.swift b/SaluNative/SaluAVP/Immersive/RestRoomPanel.swift new file mode 100644 index 0000000..2fa1442 --- /dev/null +++ b/SaluNative/SaluAVP/Immersive/RestRoomPanel.swift @@ -0,0 +1,88 @@ +import SwiftUI +import GameCore + +struct RestRoomPanel: View { + @Environment(RunSession.self) private var runSession + + let nodeId: String + + var body: some View { + if let runState = runSession.runState { + let upgradeableIndices = runState.upgradeableCardIndices + + VStack(alignment: .leading, spacing: 10) { + Text("\(RoomType.rest.icon) 休息点") + .font(.headline) + + Text("HP: \(runState.player.currentHP)/\(runState.player.maxHP)") + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + Button("休息恢复") { + runSession.restHeal() + } + .buttonStyle(.borderedProminent) + + Button("与艾拉对话") { + runSession.restTalkToAira() + } + .buttonStyle(.bordered) + } + + Divider() + + Text("升级卡牌") + .font(.subheadline) + + if upgradeableIndices.isEmpty { + Text("当前没有可升级的卡牌") + .font(.caption) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(upgradeableIndices, id: \.self) { deckIndex in + let card = runState.deck[deckIndex] + let def = CardRegistry.require(card.cardId) + let upgradedName = def.upgradedId.map { + CardRegistry.require($0).name.resolved(for: .zhHans) + } ?? "不可升级" + + Button { + runSession.restUpgradeCard(deckIndex: deckIndex) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text("\(def.name.resolved(for: .zhHans)) -> \(upgradedName)") + .font(.body) + Text("牌组位置 \(deckIndex + 1)") + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.bordered) + } + } + } + + if let restRoomMessage = runSession.restRoomMessage, !restRoomMessage.isEmpty { + Text(restRoomMessage) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 4) + } + + Text("Node: \(nodeId)") + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.top, 2) + } + .padding(12) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .frame(width: 360) + } else { + EmptyView() + } + } +} diff --git a/SaluNative/SaluAVP/Immersive/RoomSceneRenderer.swift b/SaluNative/SaluAVP/Immersive/RoomSceneRenderer.swift new file mode 100644 index 0000000..1970f93 --- /dev/null +++ b/SaluNative/SaluAVP/Immersive/RoomSceneRenderer.swift @@ -0,0 +1,518 @@ +import GameCore +import RealityKit +import UIKit + +@MainActor +final class RoomSceneRenderer { + enum Names { + static let roomLayer = "roomLayer" + static let roomRoot = "roomRoot" + static let npcPrefix = "roomNpc:" + static let shopActionPrefix = "shopAction:" + + static func shopActionName(_ action: String, index: Int? = nil) -> String { + if let index { + return "\(shopActionPrefix)\(action):\(index)" + } + return "\(shopActionPrefix)\(action)" + } + } + + private struct ShopVisualKey: Equatable { + let cardOffers: [ShopCardOffer] + let relicOffers: [ShopRelicOffer] + let consumableOffers: [ShopConsumableOffer] + let removeCardPrice: Int + let deckCardInstanceIds: [String] + let gold: Int + } + + private struct RenderKey: Equatable { + let nodeId: String + let roomType: RoomType + let shopVisual: ShopVisualKey? + } + + private struct RoomRenderStateComponent: Component { + let signature: UInt64 + } + + func makeRoomLayer() -> RealityKit.Entity { + let layer = RealityKit.Entity() + layer.name = Names.roomLayer + return layer + } + + func render( + nodeId: String, + roomType: RoomType, + shopState: ShopRoomState?, + runState: RunState?, + in layer: RealityKit.Entity + ) { + let shopVisual = makeShopVisualKey(roomType: roomType, shopState: shopState, runState: runState) + let key = RenderKey(nodeId: nodeId, roomType: roomType, shopVisual: shopVisual) + let signature = renderSignature(for: key) + if let state = layer.components[RoomRenderStateComponent.self], + state.signature == signature, + layer.findEntity(named: Names.roomRoot) != nil { + return + } + rebuildScene( + nodeId: nodeId, + roomType: roomType, + shopState: shopState, + runState: runState, + in: layer + ) + layer.components.set(RoomRenderStateComponent(signature: signature)) + } + + func clear(in layer: RealityKit.Entity) { + guard !layer.children.isEmpty || layer.components[RoomRenderStateComponent.self] != nil else { return } + layer.children.forEach { $0.removeFromParent() } + layer.components.remove(RoomRenderStateComponent.self) + } + + func panelPosition(for roomType: RoomType) -> SIMD3 { + switch roomType { + case .rest: + return [0.34, 0.14, -0.62] + case .event: + return [0.34, 0.14, -0.62] + default: + return [0.32, 0.14, -0.62] + } + } + + private func makeShopVisualKey( + roomType: RoomType, + shopState: ShopRoomState?, + runState: RunState? + ) -> ShopVisualKey? { + guard roomType == .shop, let shopState, let runState else { return nil } + return ShopVisualKey( + cardOffers: shopState.inventory.cardOffers, + relicOffers: shopState.inventory.relicOffers, + consumableOffers: shopState.inventory.consumableOffers, + removeCardPrice: shopState.inventory.removeCardPrice, + deckCardInstanceIds: runState.deck.map(\.id), + gold: runState.gold + ) + } + + private func rebuildScene( + nodeId: String, + roomType: RoomType, + shopState: ShopRoomState?, + runState: RunState?, + in layer: RealityKit.Entity + ) { + layer.children.forEach { $0.removeFromParent() } + + let root = RealityKit.Entity() + root.name = Names.roomRoot + layer.addChild(root) + + addCommonEnvironment(roomType: roomType, to: root) + + switch roomType { + case .rest: + buildRestScene(nodeId: nodeId, in: root) + case .shop: + buildShopScene(nodeId: nodeId, shopState: shopState, runState: runState, in: root) + case .event: + buildEventScene(nodeId: nodeId, in: root) + default: + buildGenericScene(nodeId: nodeId, roomType: roomType, in: root) + } + } + + private func renderSignature(for key: RenderKey) -> UInt64 { + var parts: [String] = [ + "node:\(key.nodeId)", + "type:\(key.roomType.rawValue)" + ] + + if let shopVisual = key.shopVisual { + parts.append("gold:\(shopVisual.gold)") + parts.append("remove:\(shopVisual.removeCardPrice)") + parts.append("deck:\(shopVisual.deckCardInstanceIds.joined(separator: ","))") + parts.append( + "cards:\(shopVisual.cardOffers.map { "\($0.cardId.rawValue)-\($0.price)" }.joined(separator: ","))" + ) + parts.append( + "relics:\(shopVisual.relicOffers.map { "\($0.relicId.rawValue)-\($0.price)" }.joined(separator: ","))" + ) + parts.append( + "consumables:\(shopVisual.consumableOffers.map { "\($0.cardId.rawValue)-\($0.price)" }.joined(separator: ","))" + ) + } else { + parts.append("shop:nil") + } + + return StableHash.fnv1a64(parts.joined(separator: "#")) + } + + private func addCommonEnvironment(roomType: RoomType, to root: RealityKit.Entity) { + let floorColor: UIColor + switch roomType { + case .rest: + floorColor = UIColor.systemGreen.withAlphaComponent(0.12) + case .shop: + floorColor = UIColor.systemTeal.withAlphaComponent(0.12) + case .event: + floorColor = UIColor.systemPurple.withAlphaComponent(0.12) + default: + floorColor = UIColor.systemGray.withAlphaComponent(0.10) + } + + let floor = ModelEntity( + mesh: .generateBox(size: [2.2, 0.02, 2.8]), + materials: [SimpleMaterial(color: floorColor, isMetallic: false)] + ) + floor.name = "roomFloor" + floor.position = [0, -0.02, -0.75] + root.addChild(floor) + + let backWall = ModelEntity( + mesh: .generateBox(size: [2.2, 1.1, 0.02]), + materials: [SimpleMaterial(color: UIColor.white.withAlphaComponent(0.06), isMetallic: false)] + ) + backWall.name = "backWall" + backWall.position = [0, 0.52, -1.35] + root.addChild(backWall) + } + + private func buildRestScene(nodeId: String, in root: RealityKit.Entity) { + _ = nodeId + + let campBase = ModelEntity( + mesh: .generateCylinder(height: 0.08, radius: 0.18), + materials: [SimpleMaterial(color: UIColor.brown.withAlphaComponent(0.8), isMetallic: false)] + ) + campBase.position = [0.0, 0.04, -0.86] + root.addChild(campBase) + + let fire = ModelEntity( + mesh: .generateSphere(radius: 0.07), + materials: [SimpleMaterial(color: UIColor.systemOrange, isMetallic: false)] + ) + fire.position = [0.0, 0.14, -0.86] + root.addChild(fire) + + let aira = makeNPC( + name: "\(Names.npcPrefix)aira", + color: UIColor.systemMint, + accessoryColor: UIColor.systemBlue.withAlphaComponent(0.7) + ) + aira.position = [-0.28, 0, -0.78] + root.addChild(aira) + + let seat = ModelEntity( + mesh: .generateBox(size: [0.28, 0.06, 0.12]), + materials: [SimpleMaterial(color: UIColor.darkGray, isMetallic: false)] + ) + seat.position = [0.34, 0.03, -0.86] + root.addChild(seat) + } + + private func buildShopScene( + nodeId: String, + shopState: ShopRoomState?, + runState: RunState?, + in root: RealityKit.Entity + ) { + guard let shopState, let runState, shopState.nodeId == nodeId else { + buildGenericScene(nodeId: nodeId, roomType: .shop, in: root) + return + } + + let merchant = makeNPC( + name: "\(Names.npcPrefix)merchant", + color: UIColor.systemTeal, + accessoryColor: UIColor.systemOrange.withAlphaComponent(0.7) + ) + merchant.position = [0, 0, -0.98] + root.addChild(merchant) + + renderShopGoldBalance(runState: runState, in: root) + renderShopCardOffers(shopState: shopState, runState: runState, in: root) + renderShopRelicOffers(shopState: shopState, runState: runState, in: root) + renderShopConsumables(shopState: shopState, runState: runState, in: root) + renderShopRemoveCardOffers(shopState: shopState, runState: runState, in: root) + renderShopLeaveAction(in: root) + } + + private func renderShopGoldBalance(runState: RunState, in root: RealityKit.Entity) { + let wallet = ModelEntity( + mesh: .generateCylinder(height: 0.04, radius: 0.06), + materials: [SimpleMaterial(color: UIColor.systemYellow.withAlphaComponent(0.88), isMetallic: true)] + ) + wallet.name = "shopGoldWallet" + wallet.position = [0.50, 0.14, -0.88] + root.addChild(wallet) + + if let badge = makeBadgeEntity(text: "\(runState.gold)", affordable: true) { + badge.name = "shopGoldLabel" + badge.position = [0.50, 0.24, -0.88] + root.addChild(badge) + } + } + + private func renderShopCardOffers( + shopState: ShopRoomState, + runState: RunState, + in root: RealityKit.Entity + ) { + for (index, offer) in shopState.inventory.cardOffers.enumerated() { + let affordable = runState.gold >= offer.price + let x = -0.46 + Float(index) * 0.13 + let itemPosition: SIMD3 = [x, 0.18, -0.70] + let entity = makeShopActionEntity( + mesh: .generateBox(size: [0.09, 0.11, 0.05]), + color: actionColor(base: UIColor.systemBlue, affordable: affordable), + position: itemPosition, + name: Names.shopActionName("card", index: index), + collisionSize: [0.12, 0.14, 0.10] + ) + root.addChild(entity) + root.addChild(makeAffordabilityMarker(affordable: affordable, near: itemPosition)) + if let tag = makeBadgeEntity(text: "\(offer.price)", affordable: affordable) { + tag.name = "shopCardPrice:\(index)" + tag.position = [itemPosition.x, itemPosition.y + 0.10, itemPosition.z + 0.02] + root.addChild(tag) + } + } + } + + private func renderShopRelicOffers( + shopState: ShopRoomState, + runState: RunState, + in root: RealityKit.Entity + ) { + for (index, offer) in shopState.inventory.relicOffers.enumerated() { + let affordable = runState.gold >= offer.price + let x = -0.18 + Float(index) * 0.18 + let itemPosition: SIMD3 = [x, 0.22, -0.58] + let entity = makeShopActionEntity( + mesh: .generateSphere(radius: 0.055), + color: actionColor(base: UIColor.systemPurple, affordable: affordable), + position: itemPosition, + name: Names.shopActionName("relic", index: index), + collisionSize: [0.14, 0.14, 0.14] + ) + root.addChild(entity) + root.addChild(makeAffordabilityMarker(affordable: affordable, near: itemPosition)) + if let tag = makeBadgeEntity(text: "\(offer.price)", affordable: affordable) { + tag.name = "shopRelicPrice:\(index)" + tag.position = [itemPosition.x, itemPosition.y + 0.11, itemPosition.z + 0.02] + root.addChild(tag) + } + } + } + + private func renderShopConsumables( + shopState: ShopRoomState, + runState: RunState, + in root: RealityKit.Entity + ) { + for (index, offer) in shopState.inventory.consumableOffers.enumerated() { + let affordable = runState.gold >= offer.price + let x = 0.12 + Float(index) * 0.14 + let itemPosition: SIMD3 = [x, 0.20, -0.72] + let entity = makeShopActionEntity( + mesh: .generateCylinder(height: 0.12, radius: 0.045), + color: actionColor(base: UIColor.systemGreen, affordable: affordable), + position: itemPosition, + name: Names.shopActionName("consumable", index: index), + collisionSize: [0.12, 0.15, 0.12] + ) + root.addChild(entity) + root.addChild(makeAffordabilityMarker(affordable: affordable, near: itemPosition)) + if let tag = makeBadgeEntity(text: "\(offer.price)", affordable: affordable) { + tag.name = "shopConsumablePrice:\(index)" + tag.position = [itemPosition.x, itemPosition.y + 0.11, itemPosition.z + 0.02] + root.addChild(tag) + } + } + } + + private func renderShopRemoveCardOffers( + shopState: ShopRoomState, + runState: RunState, + in root: RealityKit.Entity + ) { + let isAffordable = runState.gold >= shopState.inventory.removeCardPrice + if let removeTag = makeBadgeEntity(text: "\(shopState.inventory.removeCardPrice)", affordable: isAffordable) { + removeTag.name = "shopRemovePrice" + removeTag.position = [0.06, 0.16, -0.92] + root.addChild(removeTag) + } + + let maxCount = min(runState.deck.count, Self.maxRemoveCardDisplayCount) + for deckIndex in 0.. = [x, 0.10, z] + let entity = makeShopActionEntity( + mesh: .generateBox(size: [0.08, 0.04, 0.10]), + color: actionColor(base: UIColor.systemPink, affordable: isAffordable), + position: itemPosition, + name: Names.shopActionName("remove", index: deckIndex), + collisionSize: [0.11, 0.08, 0.13], + metallic: false + ) + root.addChild(entity) + root.addChild(makeAffordabilityMarker(affordable: isAffordable, near: itemPosition)) + } + + if runState.deck.count > maxCount { + let more = ModelEntity( + mesh: .generateSphere(radius: 0.035), + materials: [SimpleMaterial(color: UIColor.white.withAlphaComponent(0.85), isMetallic: false)] + ) + more.position = [0.02, 0.10, -1.00] + root.addChild(more) + } + } + + private func renderShopLeaveAction(in root: RealityKit.Entity) { + let leavePosition: SIMD3 = [0.44, 0.09, -0.58] + let leave = makeShopActionEntity( + mesh: .generateCone(height: 0.14, radius: 0.06), + color: UIColor.systemGray, + position: leavePosition, + name: Names.shopActionName("leave"), + collisionSize: [0.14, 0.18, 0.14], + metallic: false + ) + root.addChild(leave) + if let leaveTag = makeBadgeEntity(text: "EXIT", affordable: true) { + leaveTag.name = "shopLeaveLabel" + leaveTag.position = [leavePosition.x, leavePosition.y + 0.14, leavePosition.z] + root.addChild(leaveTag) + } + } + + private func buildEventScene(nodeId: String, in root: RealityKit.Entity) { + _ = nodeId + + let monolith = ModelEntity( + mesh: .generateBox(size: [0.2, 0.52, 0.12]), + materials: [SimpleMaterial(color: UIColor.systemPurple.withAlphaComponent(0.75), isMetallic: true)] + ) + monolith.position = [0, 0.26, -0.96] + root.addChild(monolith) + + let aura = ModelEntity( + mesh: .generateSphere(radius: 0.09), + materials: [SimpleMaterial(color: UIColor.systemPink.withAlphaComponent(0.8), isMetallic: false)] + ) + aura.position = [0, 0.52, -0.96] + root.addChild(aura) + + let witness = makeNPC( + name: "\(Names.npcPrefix)witness", + color: UIColor.systemIndigo, + accessoryColor: UIColor.systemPurple.withAlphaComponent(0.7) + ) + witness.position = [-0.34, 0, -0.78] + root.addChild(witness) + } + + private func buildGenericScene(nodeId: String, roomType: RoomType, in root: RealityKit.Entity) { + let marker = ModelEntity( + mesh: .generateSphere(radius: 0.1), + materials: [SimpleMaterial(color: UIColor.systemGray.withAlphaComponent(0.8), isMetallic: false)] + ) + marker.position = [0, 0.12, -0.82] + root.addChild(marker) + + let plate = ModelEntity( + mesh: .generateBox(size: [0.6, 0.04, 0.12]), + materials: [SimpleMaterial(color: UIColor.systemGray3.withAlphaComponent(0.9), isMetallic: false)] + ) + plate.position = [0, 0.03, -0.62] + plate.name = "roomLabel:\(roomType.rawValue):\(nodeId)" + root.addChild(plate) + } + + private func actionColor(base: UIColor, affordable: Bool) -> UIColor { + if affordable { return base } + return UIColor.systemRed.withAlphaComponent(0.85) + } + + private func makeAffordabilityMarker(affordable: Bool, near position: SIMD3) -> ModelEntity { + let color = affordable + ? UIColor.systemGreen.withAlphaComponent(0.70) + : UIColor.systemRed.withAlphaComponent(0.72) + let marker = ModelEntity( + mesh: .generateCylinder(height: 0.004, radius: 0.06), + materials: [SimpleMaterial(color: color, isMetallic: false)] + ) + marker.position = [position.x, max(0.01, position.y - 0.08), position.z] + return marker + } + + private func makeBadgeEntity(text: String, affordable: Bool) -> ModelEntity? { + let style: FloatingTextFactory.Style = affordable ? .neutral : .damage + guard let badge = FloatingTextFactory.makeEntity(text: text, style: style) else { return nil } + badge.scale = [0.34, 0.34, 0.34] + badge.components.set(BillboardComponent()) + return badge + } + + private func makeShopActionEntity( + mesh: MeshResource, + color: UIColor, + position: SIMD3, + name: String, + collisionSize: SIMD3, + metallic: Bool = true + ) -> ModelEntity { + let entity = ModelEntity( + mesh: mesh, + materials: [SimpleMaterial(color: color, isMetallic: metallic)] + ) + entity.name = name + entity.position = position + entity.components.set(CollisionComponent(shapes: [.generateBox(size: collisionSize)])) + entity.components.set(InputTargetComponent()) + return entity + } + + private func makeNPC(name: String, color: UIColor, accessoryColor: UIColor) -> RealityKit.Entity { + let npc = RealityKit.Entity() + npc.name = name + npc.components.set(CollisionComponent(shapes: [.generateBox(size: [0.22, 0.42, 0.22])])) + npc.components.set(InputTargetComponent()) + + let body = ModelEntity( + mesh: .generateCylinder(height: 0.24, radius: 0.06), + materials: [SimpleMaterial(color: color, isMetallic: false)] + ) + body.position = [0, 0.13, 0] + npc.addChild(body) + + let head = ModelEntity( + mesh: .generateSphere(radius: 0.055), + materials: [SimpleMaterial(color: UIColor.white.withAlphaComponent(0.9), isMetallic: false)] + ) + head.position = [0, 0.30, 0] + npc.addChild(head) + + let accessory = ModelEntity( + mesh: .generateBox(size: [0.16, 0.02, 0.08]), + materials: [SimpleMaterial(color: accessoryColor, isMetallic: false)] + ) + accessory.position = [0, 0.08, 0.06] + npc.addChild(accessory) + + return npc + } + + private static let maxRemoveCardDisplayCount = 12 +} diff --git a/SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift b/SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift new file mode 100644 index 0000000..a74605b --- /dev/null +++ b/SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift @@ -0,0 +1,172 @@ +import SwiftUI +import GameCore + +enum ShopItemSelection: Equatable, Sendable { + case card(Int) + case relic(Int) + case consumable(Int) + case removeCard(Int) +} + +struct ShopRoomPanel: View { + @Environment(RunSession.self) private var runSession + + let nodeId: String + @Binding var selection: ShopItemSelection? + + var body: some View { + if let selection, + let shopState = runSession.shopRoomState, + let runState = runSession.runState, + shopState.nodeId == nodeId, + let descriptor = makeDescriptor( + selection: selection, + shopState: shopState, + runState: runState + ) { + VStack(alignment: .leading, spacing: 10) { + Text("\(descriptor.icon) \(descriptor.title)") + .font(.headline) + + Text(descriptor.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + + Text(descriptor.details) + .font(.caption) + .fixedSize(horizontal: false, vertical: true) + + Divider() + + Text("价格:\(descriptor.price)G · 当前金币:\(runState.gold)G") + .font(.caption) + .foregroundStyle(descriptor.canAfford ? Color.secondary : Color.red) + + if let message = shopState.message, !message.isEmpty { + Text(message) + .font(.caption2) + .foregroundStyle(shopMessageColor(for: message)) + } + + HStack(spacing: 8) { + Button(descriptor.purchaseButtonTitle) { + executePurchase(for: selection) + self.selection = nil + } + .buttonStyle(.borderedProminent) + .disabled(!descriptor.canAfford) + + Button("关闭") { + runSession.clearShopTransientMessage() + self.selection = nil + } + .buttonStyle(.bordered) + } + } + .padding(12) + .frame(width: 320, alignment: .leading) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } else { + EmptyView() + } + } + + private func makeDescriptor( + selection: ShopItemSelection, + shopState: ShopRoomState, + runState: RunState + ) -> ShopPanelDescriptor? { + switch selection { + case .card(let index): + guard shopState.inventory.cardOffers.indices.contains(index) else { return nil } + let offer = shopState.inventory.cardOffers[index] + let def = CardRegistry.require(offer.cardId) + return ShopPanelDescriptor( + icon: "🃏", + title: def.name.resolved(for: .zhHans), + subtitle: "\(def.type.displayName(language: .zhHans)) · 消耗 \(def.cost)", + details: def.rulesText.resolved(for: .zhHans), + price: offer.price, + canAfford: runState.gold >= offer.price, + purchaseButtonTitle: "购买卡牌" + ) + + case .relic(let index): + guard shopState.inventory.relicOffers.indices.contains(index) else { return nil } + let offer = shopState.inventory.relicOffers[index] + let def = RelicRegistry.require(offer.relicId) + return ShopPanelDescriptor( + icon: def.icon, + title: def.name.resolved(for: .zhHans), + subtitle: "\(def.rarity.displayName(language: .zhHans))遗物", + details: def.description.resolved(for: .zhHans), + price: offer.price, + canAfford: runState.gold >= offer.price, + purchaseButtonTitle: "购买遗物" + ) + + case .consumable(let index): + guard shopState.inventory.consumableOffers.indices.contains(index) else { return nil } + let offer = shopState.inventory.consumableOffers[index] + let def = CardRegistry.require(offer.cardId) + return ShopPanelDescriptor( + icon: "🧪", + title: def.name.resolved(for: .zhHans), + subtitle: "消耗性卡牌 · 消耗 \(def.cost)", + details: def.rulesText.resolved(for: .zhHans), + price: offer.price, + canAfford: runState.gold >= offer.price, + purchaseButtonTitle: "购买消耗牌" + ) + + case .removeCard(let deckIndex): + guard runState.deck.indices.contains(deckIndex) else { return nil } + let card = runState.deck[deckIndex] + let def = CardRegistry.require(card.cardId) + let price = shopState.inventory.removeCardPrice + return ShopPanelDescriptor( + icon: "🗑️", + title: "删除:\(def.name.resolved(for: .zhHans))", + subtitle: "牌组位置 \(deckIndex + 1)", + details: "从牌组永久移除该卡牌。", + price: price, + canAfford: runState.gold >= price, + purchaseButtonTitle: "支付并删除" + ) + } + } + + private func executePurchase(for selection: ShopItemSelection) { + switch selection { + case .card(let index): + runSession.buyShopCard(at: index) + case .relic(let index): + runSession.buyShopRelic(at: index) + case .consumable(let index): + runSession.buyShopConsumable(at: index) + case .removeCard(let deckIndex): + runSession.removeCardInShop(deckIndex: deckIndex) + } + } + + private func shopMessageColor(for message: String) -> Color { + if message.contains("不足") + || message.contains("无效") + || message.contains("失败") + || message.contains("已满") { + return .red + } + return .green + } +} + +private struct ShopPanelDescriptor { + let icon: String + let title: String + let subtitle: String + let details: String + let price: Int + let canAfford: Bool + let purchaseButtonTitle: String +} diff --git a/SaluNative/SaluAVP/ViewModels/BattlePresentationEvent.swift b/SaluNative/SaluAVP/ViewModels/BattlePresentationEvent.swift new file mode 100644 index 0000000..db61648 --- /dev/null +++ b/SaluNative/SaluAVP/ViewModels/BattlePresentationEvent.swift @@ -0,0 +1,30 @@ +import Foundation +import GameCore + +enum PlayedCardDestinationPile: String, Sendable, Equatable { + case discard + case exhaust +} + +struct PlayedCardPresentationContext: Sendable, Equatable { + let sourceHandIndex: Int + let destinationPile: PlayedCardDestinationPile + let targetEnemyEntityId: String? +} + +/// AVP 表现层使用的战斗事件包装,包含稳定序号以支持动画队列消费。 +struct BattlePresentationEvent: Sendable, Equatable { + let sequence: Int + let event: BattleEvent + let playedCardContext: PlayedCardPresentationContext? + + init( + sequence: Int, + event: BattleEvent, + playedCardContext: PlayedCardPresentationContext? = nil + ) { + self.sequence = sequence + self.event = event + self.playedCardContext = playedCardContext + } +} diff --git a/SaluNative/SaluAVP/ViewModels/EventRoomState.swift b/SaluNative/SaluAVP/ViewModels/EventRoomState.swift new file mode 100644 index 0000000..a2ddba2 --- /dev/null +++ b/SaluNative/SaluAVP/ViewModels/EventRoomState.swift @@ -0,0 +1,27 @@ +import GameCore + +struct EventRoomState: Sendable, Equatable { + enum Phase: Sendable, Equatable { + case choosing + case chooseUpgrade(optionIndex: Int, indices: [Int], baseResultLines: [String]) + case awaitingBattleResolution(optionIndex: Int, enemyId: EnemyID, baseResultLines: [String]) + case resolved(optionIndex: Int, resultLines: [String]) + } + + let nodeId: String + let offer: EventOffer + var phase: Phase + var message: String? + + init( + nodeId: String, + offer: EventOffer, + phase: Phase = .choosing, + message: String? = nil + ) { + self.nodeId = nodeId + self.offer = offer + self.phase = phase + self.message = message + } +} diff --git a/SaluNative/SaluAVP/ViewModels/RewardRouteState.swift b/SaluNative/SaluAVP/ViewModels/RewardRouteState.swift new file mode 100644 index 0000000..da85a5d --- /dev/null +++ b/SaluNative/SaluAVP/ViewModels/RewardRouteState.swift @@ -0,0 +1,36 @@ +import GameCore + +struct RewardRouteState: Sendable, Equatable { + enum Phase: Sendable, Equatable { + case relic + case card + } + + struct RelicReward: Sendable, Equatable { + let relicId: RelicID + let source: RelicDropSource + } + + let nodeId: String + let roomType: RoomType + let goldEarned: Int + let cardOffer: CardRewardOffer + let relicReward: RelicReward? + var phase: Phase + + init( + nodeId: String, + roomType: RoomType, + goldEarned: Int, + cardOffer: CardRewardOffer, + relicReward: RelicReward? + ) { + self.nodeId = nodeId + self.roomType = roomType + self.goldEarned = goldEarned + self.cardOffer = cardOffer + self.relicReward = relicReward + self.phase = (relicReward == nil) ? .card : .relic + } +} + diff --git a/SaluNative/SaluAVP/ViewModels/RunSession.swift b/SaluNative/SaluAVP/ViewModels/RunSession.swift index a530b64..fecb969 100644 --- a/SaluNative/SaluAVP/ViewModels/RunSession.swift +++ b/SaluNative/SaluAVP/ViewModels/RunSession.swift @@ -9,10 +9,23 @@ final class RunSession { case map case room(nodeId: String, roomType: RoomType) case battle(nodeId: String, roomType: RoomType) - case cardReward(nodeId: String, roomType: RoomType, offer: CardRewardOffer, goldEarned: Int) + case reward(RewardRouteState) + case chapterEnd(previousFloor: Int, nextFloor: Int) case runOver(lastNodeId: String, won: Bool, floor: Int) } + private enum BattleSource: Sendable, Equatable { + case mapNode + case eventFollowUp + } + + private struct EventBattleContext: Sendable { + let nodeId: String + let optionIndex: Int + let enemyId: EnemyID + let baseResultLines: [String] + } + var seedText: String = "" private(set) var seed: UInt64? var lastError: String? @@ -20,8 +33,25 @@ final class RunSession { var route: Route = .map private(set) var battleEngine: BattleEngine? private(set) var battleState: BattleState? + private(set) var battleEvents: [BattleEvent] = [] private var battleNodeId: String? private var battleRoomType: RoomType? + private var lastConsumedBattleEventIndex: Int = 0 + private var playedCardContextsBySequence: [Int: PlayedCardPresentationContext] = [:] + private(set) var selectedEnemyIndex: Int? + private(set) var battleTargetHint: String? + private(set) var restRoomMessage: String? + private(set) var shopRoomState: ShopRoomState? + private(set) var eventRoomState: EventRoomState? + private var shopMessageSequence: UInt64 = 0 + private var battleSource: BattleSource = .mapNode + private var eventBattleContext: EventBattleContext? + + var selectedEnemyDisplayName: String? { + guard let battleState, let selectedEnemyIndex else { return nil } + guard battleState.enemies.indices.contains(selectedEnemyIndex) else { return nil } + return battleState.enemies[selectedEnemyIndex].name.resolved(for: .zhHans) + } func startNewRun() { let seed: UInt64 @@ -40,8 +70,19 @@ final class RunSession { lastError = nil battleEngine = nil battleState = nil + battleEvents = [] battleNodeId = nil battleRoomType = nil + lastConsumedBattleEventIndex = 0 + playedCardContextsBySequence = [:] + selectedEnemyIndex = nil + battleTargetHint = nil + restRoomMessage = nil + shopRoomState = nil + eventRoomState = nil + shopMessageSequence = 0 + battleSource = .mapNode + eventBattleContext = nil route = .map } @@ -58,8 +99,10 @@ final class RunSession { switch node.roomType { case .battle, .elite, .boss: - startBattle(nodeId: nodeId, roomType: node.roomType) + clearRoomState(clearEventBattleContext: true) + startBattle(nodeId: nodeId, roomType: node.roomType, source: .mapNode) default: + prepareRoomState(nodeId: nodeId, roomType: node.roomType, runState: runState) route = .room(nodeId: nodeId, roomType: node.roomType) } } @@ -75,6 +118,7 @@ final class RunSession { guard var runState else { return } runState.completeCurrentNode() self.runState = runState + clearRoomState(clearEventBattleContext: true) if runState.isOver, let lastNodeId { route = .runOver(lastNodeId: lastNodeId, won: runState.won, floor: runState.floor) @@ -83,12 +127,347 @@ final class RunSession { } } + func restHeal() { + guard case .room(_, .rest) = route else { return } + guard var runState else { return } + _ = runState.restAtNode() + self.runState = runState + restRoomMessage = nil + completeCurrentRoomAndReturnToMap() + } + + func restUpgradeCard(deckIndex: Int) { + guard case .room(_, .rest) = route else { return } + guard var runState else { return } + guard runState.upgradeCard(at: deckIndex) else { + restRoomMessage = "该卡牌无法升级" + return + } + + self.runState = runState + restRoomMessage = nil + completeCurrentRoomAndReturnToMap() + } + + func restTalkToAira() { + guard case .room(_, .rest) = route else { return } + guard let runState else { return } + + let dialogue = RestPointDialogues.getAiraDialogue(floor: runState.floor) + var lines = [ + dialogue.title.resolved(for: .zhHans), + dialogue.content.resolved(for: .zhHans) + ] + if let effect = dialogue.effect?.resolved(for: .zhHans), !effect.isEmpty { + lines.append("效果:\(effect)") + } + restRoomMessage = lines.joined(separator: "\n\n") + } + + func leaveShopRoom() { + guard case .room(_, .shop) = route else { return } + completeCurrentRoomAndReturnToMap() + } + + func clearShopTransientMessage() { + guard case .room(let nodeId, .shop) = route else { return } + guard var shopState = shopRoomState, shopState.nodeId == nodeId else { return } + guard shopState.message != nil else { return } + shopState.message = nil + shopRoomState = shopState + } + + func buyShopCard(at offerIndex: Int) { + guard case .room(let nodeId, .shop) = route else { return } + guard var runState else { return } + var shopState = ensureShopRoomState(nodeId: nodeId, runState: runState) + + guard shopState.inventory.cardOffers.indices.contains(offerIndex) else { + setShopMessage("无效的卡牌编号", in: &shopState) + shopRoomState = shopState + return + } + + let offer = shopState.inventory.cardOffers[offerIndex] + guard runState.gold >= offer.price else { + setShopMessage("金币不足,无法购买该卡牌", in: &shopState) + shopRoomState = shopState + return + } + + runState.gold -= offer.price + runState.addCardToDeck(cardId: offer.cardId) + var cardOffers = shopState.inventory.cardOffers + cardOffers.remove(at: offerIndex) + shopState.inventory = ShopInventory( + cardOffers: cardOffers, + relicOffers: shopState.inventory.relicOffers, + consumableOffers: shopState.inventory.consumableOffers, + removeCardPrice: shopState.inventory.removeCardPrice + ) + setShopMessage("购买成功:\(CardRegistry.require(offer.cardId).name.resolved(for: .zhHans))", in: &shopState) + self.runState = runState + shopRoomState = shopState + } + + func buyShopRelic(at offerIndex: Int) { + guard case .room(let nodeId, .shop) = route else { return } + guard var runState else { return } + var shopState = ensureShopRoomState(nodeId: nodeId, runState: runState) + + guard shopState.inventory.relicOffers.indices.contains(offerIndex) else { + setShopMessage("无效的遗物编号", in: &shopState) + shopRoomState = shopState + return + } + + let offer = shopState.inventory.relicOffers[offerIndex] + guard runState.gold >= offer.price else { + setShopMessage("金币不足,无法购买该遗物", in: &shopState) + shopRoomState = shopState + return + } + + runState.gold -= offer.price + runState.relicManager.add(offer.relicId) + var relicOffers = shopState.inventory.relicOffers + relicOffers.remove(at: offerIndex) + shopState.inventory = ShopInventory( + cardOffers: shopState.inventory.cardOffers, + relicOffers: relicOffers, + consumableOffers: shopState.inventory.consumableOffers, + removeCardPrice: shopState.inventory.removeCardPrice + ) + let relicDef = RelicRegistry.require(offer.relicId) + setShopMessage("购买成功:\(relicDef.icon) \(relicDef.name.resolved(for: .zhHans))", in: &shopState) + self.runState = runState + shopRoomState = shopState + } + + func buyShopConsumable(at offerIndex: Int) { + guard case .room(let nodeId, .shop) = route else { return } + guard var runState else { return } + var shopState = ensureShopRoomState(nodeId: nodeId, runState: runState) + + guard shopState.inventory.consumableOffers.indices.contains(offerIndex) else { + setShopMessage("无效的消耗性卡牌编号", in: &shopState) + shopRoomState = shopState + return + } + + let offer = shopState.inventory.consumableOffers[offerIndex] + guard runState.gold >= offer.price else { + setShopMessage("金币不足,无法购买该消耗性卡牌", in: &shopState) + shopRoomState = shopState + return + } + + guard runState.addConsumableCardToDeck(cardId: offer.cardId) else { + setShopMessage("消耗性卡牌槽位已满(最多 \(RunState.maxConsumableCardSlots))", in: &shopState) + shopRoomState = shopState + return + } + + runState.gold -= offer.price + setShopMessage("购买成功:\(CardRegistry.require(offer.cardId).name.resolved(for: .zhHans))", in: &shopState) + self.runState = runState + shopRoomState = shopState + } + + func removeCardInShop(deckIndex: Int) { + guard case .room(let nodeId, .shop) = route else { return } + guard var runState else { return } + var shopState = ensureShopRoomState(nodeId: nodeId, runState: runState) + + guard runState.deck.indices.contains(deckIndex) else { + setShopMessage("无效的卡牌编号", in: &shopState) + shopRoomState = shopState + return + } + + let price = shopState.inventory.removeCardPrice + guard runState.gold >= price else { + setShopMessage("金币不足,无法删牌", in: &shopState) + shopRoomState = shopState + return + } + + let removedCard = runState.deck[deckIndex] + runState.removeCardFromDeck(at: deckIndex) + runState.gold -= price + setShopMessage("删牌成功:\(CardRegistry.require(removedCard.cardId).name.resolved(for: .zhHans))", in: &shopState) + self.runState = runState + shopRoomState = shopState + } + + func chooseEventOption(_ optionIndex: Int) { + guard case .room(let nodeId, .event) = route else { return } + guard var runState else { return } + var eventState = ensureEventRoomState(nodeId: nodeId, runState: runState) + guard case .choosing = eventState.phase else { return } + guard eventState.offer.options.indices.contains(optionIndex) else { + eventState.message = "无效的事件选项" + eventRoomState = eventState + return + } + + let option = eventState.offer.options[optionIndex] + var failureLines: [String] = [] + for effect in option.effects { + guard runState.apply(effect) else { + failureLines.append(eventApplyFailureLine(for: effect)) + continue + } + } + self.runState = runState + + if runState.isOver { + clearRoomState(clearEventBattleContext: true) + route = .runOver(lastNodeId: nodeId, won: false, floor: runState.floor) + return + } + + let baseResultLines = buildEventResultLines(option: option, additional: failureLines) + + if let followUp = option.followUp { + switch followUp { + case .chooseUpgradeableCard(let indices): + let validIndices = indices.filter { index in + runState.deck.indices.contains(index) + && RunState.upgradedCard(from: runState.deck[index]) != nil + } + if validIndices.isEmpty { + var lines = baseResultLines + lines.append("没有可升级的卡牌") + eventState.phase = .resolved(optionIndex: optionIndex, resultLines: lines) + } else { + eventState.phase = .chooseUpgrade( + optionIndex: optionIndex, + indices: validIndices, + baseResultLines: baseResultLines + ) + } + eventState.message = nil + eventRoomState = eventState + + case .startEliteBattle(let enemyId): + eventState.phase = .awaitingBattleResolution( + optionIndex: optionIndex, + enemyId: enemyId, + baseResultLines: baseResultLines + ) + eventState.message = nil + eventRoomState = eventState + eventBattleContext = EventBattleContext( + nodeId: nodeId, + optionIndex: optionIndex, + enemyId: enemyId, + baseResultLines: baseResultLines + ) + startBattle( + nodeId: nodeId, + roomType: .elite, + forcedEnemyIds: [enemyId], + source: .eventFollowUp + ) + } + return + } + + eventState.phase = .resolved(optionIndex: optionIndex, resultLines: baseResultLines) + eventState.message = nil + eventRoomState = eventState + } + + func chooseEventUpgradeCard(deckIndex: Int) { + guard case .room(let nodeId, .event) = route else { return } + guard var runState else { return } + var eventState = ensureEventRoomState(nodeId: nodeId, runState: runState) + guard case .chooseUpgrade(let optionIndex, let indices, let baseResultLines) = eventState.phase else { return } + guard indices.contains(deckIndex), runState.deck.indices.contains(deckIndex) else { + eventState.message = "请选择有效的升级目标" + eventRoomState = eventState + return + } + + let card = runState.deck[deckIndex] + guard let cardDef = CardRegistry.get(card.cardId), let upgradedId = cardDef.upgradedId else { + eventState.message = "该卡牌无法升级" + eventRoomState = eventState + return + } + guard runState.upgradeCard(at: deckIndex) else { + eventState.message = "升级失败,请重试" + eventRoomState = eventState + return + } + + let upgradedDef = CardRegistry.require(upgradedId) + var lines = baseResultLines + lines.append("升级:\(cardDef.name.resolved(for: .zhHans)) -> \(upgradedDef.name.resolved(for: .zhHans))") + eventState.phase = .resolved(optionIndex: optionIndex, resultLines: lines) + eventState.message = nil + self.runState = runState + eventRoomState = eventState + } + + func skipEventUpgradeChoice() { + guard case .room(let nodeId, .event) = route else { return } + guard let runState else { return } + var eventState = ensureEventRoomState(nodeId: nodeId, runState: runState) + guard case .chooseUpgrade(let optionIndex, _, let baseResultLines) = eventState.phase else { return } + + var lines = baseResultLines + lines.append("你放弃了升级") + eventState.phase = .resolved(optionIndex: optionIndex, resultLines: lines) + eventState.message = nil + eventRoomState = eventState + } + + func completeEventRoom() { + guard case .room(_, .event) = route else { return } + guard let eventRoomState else { return } + guard case .resolved = eventRoomState.phase else { return } + completeCurrentRoomAndReturnToMap() + } + func playCard(handIndex: Int) { guard routeIsBattle else { return } guard let battleEngine else { return } guard battleEngine.pendingInput == nil else { return } - _ = battleEngine.handleAction(.playCard(handIndex: handIndex, targetEnemyIndex: nil)) - battleState = battleEngine.state + guard battleEngine.state.hand.indices.contains(handIndex) else { return } + let targetEnemyIndex = resolveTargetEnemyIndex( + for: battleEngine.state.hand[handIndex], + in: battleEngine.state + ) + guard case .resolved(let resolvedTargetEnemyIndex) = targetEnemyIndex else { + return + } + + let targetEnemyEntityId: String? = { + guard let resolvedTargetEnemyIndex else { return nil } + guard battleEngine.state.enemies.indices.contains(resolvedTargetEnemyIndex) else { return nil } + return battleEngine.state.enemies[resolvedTargetEnemyIndex].id + }() + + let eventStartIndex = battleEngine.events.count + let succeeded = battleEngine.handleAction( + .playCard( + handIndex: handIndex, + targetEnemyIndex: resolvedTargetEnemyIndex + ) + ) + syncBattleStateFromEngine() + capturePlayedCardContexts( + startIndex: eventStartIndex, + sourceHandIndex: handIndex, + targetEnemyEntityId: targetEnemyEntityId + ) + if succeeded { + battleTargetHint = nil + } else { + captureInvalidActionHint(from: battleEngine.events, startIndex: eventStartIndex) + } finishBattleIfNeeded() } @@ -97,7 +476,8 @@ final class RunSession { guard let battleEngine else { return } guard battleEngine.pendingInput == nil else { return } _ = battleEngine.handleAction(.endTurn) - battleState = battleEngine.state + syncBattleStateFromEngine() + battleTargetHint = nil finishBattleIfNeeded() } @@ -110,14 +490,88 @@ final class RunSession { guard routeIsBattle else { return } guard let battleEngine else { return } _ = battleEngine.submitForesightChoice(index: index) - battleState = battleEngine.state + syncBattleStateFromEngine() finishBattleIfNeeded() } + func selectEnemyTarget(index: Int) { + guard routeIsBattle else { return } + guard let battleState else { return } + guard battleState.enemies.indices.contains(index) else { return } + guard battleState.enemies[index].isAlive else { + battleTargetHint = "目标已失效,请重新选择" + return + } + + if selectedEnemyIndex == index { + selectedEnemyIndex = nil + } else { + selectedEnemyIndex = index + } + battleTargetHint = nil + } + + func selectEnemyTarget(entityId: String) { + guard let battleState else { return } + guard let index = battleState.enemies.firstIndex(where: { $0.id == entityId }) else { return } + selectEnemyTarget(index: index) + } + + func consumeNewBattleEvents() -> [BattleEvent] { + let startIndex = lastConsumedBattleEventIndex + let newEvents = consumeNewBattleEventSlice() + let consumedEnd = startIndex + newEvents.count + if consumedEnd > startIndex { + for sequence in startIndex.. [BattlePresentationEvent] { + let startIndex = lastConsumedBattleEventIndex + let newEvents = consumeNewBattleEventSlice() + return newEvents.enumerated().map { offset, event in + let sequence = startIndex + offset + let context = playedCardContextsBySequence.removeValue(forKey: sequence) + return BattlePresentationEvent( + sequence: sequence, + event: event, + playedCardContext: context + ) + } + } + + func chooseRelicReward(take: Bool) { + guard case .reward(var rewardState) = route else { return } + guard rewardState.phase == .relic else { return } + guard var runState else { return } + guard let relicReward = rewardState.relicReward else { + rewardState.phase = .card + route = .reward(rewardState) + return + } + + if take { + runState.relicManager.add(relicReward.relicId) + self.runState = runState + } + + rewardState.phase = .card + route = .reward(rewardState) + } + func chooseCardReward(_ cardId: CardID?) { - guard case .cardReward(let nodeId, let roomType, let offer, let goldEarned) = route else { return } + guard case .reward(let rewardState) = route else { return } + guard rewardState.phase == .card else { return } guard var runState else { return } + let nodeId = rewardState.nodeId + let offer = rewardState.cardOffer + let previousFloor = runState.floor + let shouldShowChapterEnd = (rewardState.roomType == .boss) + if let cardId { guard offer.choices.contains(cardId) else { return } runState.addCardToDeck(cardId: cardId) @@ -128,32 +582,43 @@ final class RunSession { runState.completeCurrentNode() self.runState = runState - battleState = nil - battleNodeId = nil - battleRoomType = nil + clearBattleState(preserveSnapshot: false) if runState.isOver { route = .runOver(lastNodeId: nodeId, won: runState.won, floor: runState.floor) } else { - _ = roomType - _ = goldEarned - route = .map + if shouldShowChapterEnd { + route = .chapterEnd(previousFloor: previousFloor, nextFloor: runState.floor) + } else { + route = .map + } } } + func continueAfterChapterEnd() { + guard case .chapterEnd = route else { return } + route = .map + } + private func finishBattleIfNeeded() { guard let battleEngine, battleEngine.state.isOver else { return } guard var runState else { return } let nodeId = battleNodeId ?? runState.currentNodeId ?? "unknown" let roomTypeForRewards = battleRoomType ?? .battle + let battleSource = self.battleSource runState.updateFromBattle(playerHP: battleEngine.state.player.currentHP) self.runState = runState - // Freeze the final battle state for UI (reward panel), but release the engine. - self.battleEngine = nil - if battleEngine.state.playerWon == true { + if battleSource == .eventFollowUp { + resolveEventEliteBattleVictory(nodeId: nodeId) + return + } + + // Freeze the final battle state for UI (reward panel), but release the engine. + self.battleEvents = battleEngine.events + clearBattleState(preserveSnapshot: true) let rewardContext = RewardContext( seed: runState.seed, floor: runState.floor, @@ -165,65 +630,107 @@ final class RunSession { runState.gold += goldEarned self.runState = runState - let offer = RewardGenerator.generateCardReward(context: rewardContext) - route = .cardReward(nodeId: nodeId, roomType: roomTypeForRewards, offer: offer, goldEarned: goldEarned) + let cardOffer = RewardGenerator.generateCardReward(context: rewardContext) + let relicReward: RewardRouteState.RelicReward? = { + let source: RelicDropSource? + switch roomTypeForRewards { + case .elite: + source = .elite + case .boss: + source = .boss + default: + source = nil + } + guard let source else { return nil } + guard let relicId = RelicDropStrategy.generateRelicDrop( + context: rewardContext, + source: source, + ownedRelics: runState.relicManager.all + ) else { return nil } + return RewardRouteState.RelicReward(relicId: relicId, source: source) + }() + + route = .reward( + RewardRouteState( + nodeId: nodeId, + roomType: roomTypeForRewards, + goldEarned: goldEarned, + cardOffer: cardOffer, + relicReward: relicReward + ) + ) } else { - self.battleState = nil - self.battleNodeId = nil - self.battleRoomType = nil + clearBattleState(preserveSnapshot: false) + clearRoomState(clearEventBattleContext: true) route = .runOver(lastNodeId: nodeId, won: false, floor: runState.floor) } } - private func startBattle(nodeId: String, roomType: RoomType) { + private func startBattle( + nodeId: String, + roomType: RoomType, + forcedEnemyIds: [EnemyID]? = nil, + source: BattleSource + ) { guard let runState else { return } let battleSeed = SeedDerivation.battleSeed(runSeed: runState.seed, floor: runState.floor, nodeId: nodeId) var rng = SeededRNG(seed: battleSeed) - let enemyId: EnemyID - switch roomType { - case .battle: - let encounter: EnemyEncounter - switch runState.floor { - case 1: - encounter = Act1EncounterPool.randomWeak(rng: &rng) - case 2: - encounter = Act2EncounterPool.randomWeak(rng: &rng) - default: - encounter = Act3EncounterPool.randomWeak(rng: &rng) - } - enemyId = encounter.enemyIds.first ?? "jaw_worm" - - case .elite: - switch runState.floor { - case 1: - enemyId = Act1EnemyPool.randomMedium(rng: &rng) - case 2: - enemyId = Act2EnemyPool.randomMedium(rng: &rng) - default: - enemyId = Act3EnemyPool.randomMedium(rng: &rng) - } + let enemyIds: [EnemyID] + if let forcedEnemyIds { + enemyIds = forcedEnemyIds + } else { + switch roomType { + case .battle: + let encounter: EnemyEncounter + switch runState.floor { + case 1: + encounter = Act1EncounterPool.randomWeak(rng: &rng) + case 2: + encounter = Act2EncounterPool.randomWeak(rng: &rng) + default: + encounter = Act3EncounterPool.randomWeak(rng: &rng) + } + enemyIds = encounter.enemyIds + + case .elite: + switch runState.floor { + case 1: + enemyIds = [Act1EnemyPool.randomMedium(rng: &rng)] + case 2: + enemyIds = [Act2EnemyPool.randomMedium(rng: &rng)] + default: + enemyIds = [Act3EnemyPool.randomMedium(rng: &rng)] + } + + case .boss: + switch runState.floor { + case 1: + enemyIds = ["toxic_colossus"] + case 2: + enemyIds = ["cipher"] + default: + enemyIds = ["sequence_progenitor"] + } - case .boss: - switch runState.floor { - case 1: - enemyId = "toxic_colossus" - case 2: - enemyId = "cipher" default: - enemyId = "sequence_progenitor" + lastError = "Not a battle room: \(roomType.rawValue)" + return } + } - default: - lastError = "Not a battle room: \(roomType.rawValue)" + guard !enemyIds.isEmpty else { + lastError = "No enemies available for battle start." return } - let enemy = createEnemy(enemyId: enemyId, instanceIndex: 0, rng: &rng) + let enemies = enemyIds.enumerated().map { instanceIndex, enemyId in + createEnemy(enemyId: enemyId, instanceIndex: instanceIndex, rng: &rng) + } let engine = BattleEngine( player: runState.player, - enemies: [enemy], + enemies: enemies, deck: runState.deck, relicManager: runState.relicManager, seed: battleSeed @@ -232,8 +739,17 @@ final class RunSession { battleEngine = engine battleState = engine.state + battleEvents = engine.events battleNodeId = nodeId battleRoomType = roomType + lastConsumedBattleEventIndex = 0 + playedCardContextsBySequence = [:] + selectedEnemyIndex = enemies.count == 1 ? 0 : nil + battleTargetHint = nil + battleSource = source + if source == .mapNode { + eventBattleContext = nil + } route = .battle(nodeId: nodeId, roomType: roomType) } @@ -247,7 +763,310 @@ final class RunSession { lastError = nil battleEngine = nil battleState = nil + battleEvents = [] + battleNodeId = nil + battleRoomType = nil + lastConsumedBattleEventIndex = 0 + playedCardContextsBySequence = [:] + selectedEnemyIndex = nil + battleTargetHint = nil + restRoomMessage = nil + shopRoomState = nil + eventRoomState = nil + battleSource = .mapNode + eventBattleContext = nil + } + + private func consumeNewBattleEventSlice() -> ArraySlice { + guard lastConsumedBattleEventIndex < battleEvents.count else { return [] } + let range = lastConsumedBattleEventIndex.. PlayedCardDestinationPile { + let definition = CardRegistry.require(cardId) + return definition.type == .consumable ? .exhaust : .discard + } + + private enum TargetResolution { + case resolved(Int?) + case missingTarget + } + + private func resolveTargetEnemyIndex(for card: Card, in state: BattleState) -> TargetResolution { + let definition = CardRegistry.require(card.cardId) + guard definition.targeting == .singleEnemy else { return .resolved(nil) } + + let aliveEnemyIndices = state.enemies.indices.filter { state.enemies[$0].isAlive } + guard !aliveEnemyIndices.isEmpty else { + battleTargetHint = "没有可选目标" + return .missingTarget + } + + if aliveEnemyIndices.count == 1, let only = aliveEnemyIndices.first { + selectedEnemyIndex = only + return .resolved(only) + } + + guard let selectedEnemyIndex, aliveEnemyIndices.contains(selectedEnemyIndex) else { + battleTargetHint = "该牌需要选择目标:请先点击敌人" + return .missingTarget + } + return .resolved(selectedEnemyIndex) + } + + private func sanitizeSelectedEnemyIndex() { + guard let battleState else { + selectedEnemyIndex = nil + return + } + + if let selectedEnemyIndex { + let isValid = battleState.enemies.indices.contains(selectedEnemyIndex) + && battleState.enemies[selectedEnemyIndex].isAlive + if !isValid { + self.selectedEnemyIndex = nil + battleTargetHint = "目标已失效,请重新选择" + } + } + + if selectedEnemyIndex == nil { + let aliveEnemyIndices = battleState.enemies.indices.filter { battleState.enemies[$0].isAlive } + if aliveEnemyIndices.count == 1, let only = aliveEnemyIndices.first { + selectedEnemyIndex = only + } + } + } + + private func captureInvalidActionHint(from events: [BattleEvent], startIndex: Int) { + guard startIndex < events.count else { return } + for event in events[startIndex.. ShopRoomState { + if let shopRoomState, shopRoomState.nodeId == nodeId { + return shopRoomState + } + let context = ShopContext( + seed: runState.seed, + floor: runState.floor, + currentRow: runState.currentRow, + nodeId: nodeId, + ownedRelicIds: runState.relicManager.all + ) + let generated = ShopRoomState( + nodeId: nodeId, + inventory: ShopInventory.generate(context: context) + ) + self.shopRoomState = generated + return generated + } + + private func ensureEventRoomState(nodeId: String, runState: RunState) -> EventRoomState { + if let eventRoomState, eventRoomState.nodeId == nodeId { + return eventRoomState + } + let context = EventContext( + seed: runState.seed, + floor: runState.floor, + currentRow: runState.currentRow, + nodeId: nodeId, + playerMaxHP: runState.player.maxHP, + playerCurrentHP: runState.player.currentHP, + gold: runState.gold, + deck: runState.deck, + relicIds: runState.relicManager.all + ) + let generated = EventRoomState(nodeId: nodeId, offer: EventGenerator.generate(context: context)) + self.eventRoomState = generated + return generated + } + + private func setShopMessage(_ message: String, in shopState: inout ShopRoomState) { + shopMessageSequence &+= 1 + shopState.message = message + shopState.messageSequence = shopMessageSequence + } + + private func clearBattleState(preserveSnapshot: Bool) { + battleEngine = nil + if !preserveSnapshot { + battleState = nil + battleEvents = [] + } battleNodeId = nil battleRoomType = nil + // When preserving snapshot for reward UI, do not re-consume the entire battle event stream. + // Reward should display the final state, not replay the full battle timeline. + lastConsumedBattleEventIndex = preserveSnapshot ? battleEvents.count : 0 + playedCardContextsBySequence = [:] + selectedEnemyIndex = nil + battleTargetHint = nil + battleSource = .mapNode + eventBattleContext = nil + } + + private func resolveEventEliteBattleVictory(nodeId: String) { + let context = eventBattleContext + clearBattleState(preserveSnapshot: false) + + guard var eventRoomState, + let context, + eventRoomState.nodeId == nodeId, + context.nodeId == nodeId else { + route = .room(nodeId: nodeId, roomType: .event) + return + } + + let enemyName = EnemyRegistry.require(context.enemyId).name.resolved(for: .zhHans) + var resultLines = context.baseResultLines + resultLines.append("你击败了:\(enemyName)") + eventRoomState.phase = .resolved(optionIndex: context.optionIndex, resultLines: resultLines) + eventRoomState.message = nil + self.eventRoomState = eventRoomState + route = .room(nodeId: nodeId, roomType: .event) + } + + private func buildEventResultLines(option: EventOption, additional: [String]) -> [String] { + if let preview = option.preview { + let text = preview.resolved(for: .zhHans) + if !text.isEmpty { + return [text] + additional + } + } + + if option.effects.isEmpty { + return additional.isEmpty ? ["没有发生任何事。"] : additional + } + + let base = option.effects.map(describeRunEffect) + return base + additional + } + + private func describeRunEffect(_ effect: RunEffect) -> String { + switch effect { + case .gainGold(let amount): + return "获得 \(amount) 金币" + case .loseGold(let amount): + return "失去 \(amount) 金币" + case .heal(let amount): + return "恢复 \(amount) HP" + case .takeDamage(let amount): + return "失去 \(amount) HP" + case .addCard(let cardId): + let name = CardRegistry.require(cardId).name.resolved(for: .zhHans) + return "获得卡牌:\(name)" + case .addRelic(let relicId): + let def = RelicRegistry.require(relicId) + return "获得遗物:\(def.icon) \(def.name.resolved(for: .zhHans))" + case .applyStatus(let statusId, let stacks): + let statusName = StatusRegistry.get(statusId)?.name.resolved(for: .zhHans) ?? statusId.rawValue + let sign = stacks >= 0 ? "+" : "" + return "\(statusName) \(sign)\(stacks)" + case .setStatus(let statusId, let stacks): + let statusName = StatusRegistry.get(statusId)?.name.resolved(for: .zhHans) ?? statusId.rawValue + return "\(statusName) 设为 \(stacks)" + case .upgradeCard: + return "升级了一张卡牌" + } + } + + private func eventApplyFailureLine(for effect: RunEffect) -> String { + switch effect { + case .addCard(let cardId): + if let def = CardRegistry.get(cardId), def.type == .consumable { + return "消耗性卡牌槽位已满,未能获得 \(def.name.resolved(for: .zhHans))" + } + return "未能获得卡牌" + default: + return "有一项效果未能生效" + } } } diff --git a/SaluNative/SaluAVP/ViewModels/ShopRoomState.swift b/SaluNative/SaluAVP/ViewModels/ShopRoomState.swift new file mode 100644 index 0000000..0b7f6c0 --- /dev/null +++ b/SaluNative/SaluAVP/ViewModels/ShopRoomState.swift @@ -0,0 +1,20 @@ +import GameCore + +struct ShopRoomState: Sendable, Equatable { + let nodeId: String + var inventory: ShopInventory + var message: String? + var messageSequence: UInt64 + + init( + nodeId: String, + inventory: ShopInventory, + message: String? = nil, + messageSequence: UInt64 = 0 + ) { + self.nodeId = nodeId + self.inventory = inventory + self.message = message + self.messageSequence = messageSequence + } +} diff --git a/Sources/GameCLI/Components/EventFormatter.swift b/Sources/GameCLI/Components/EventFormatter.swift index ed60ac9..8c41e25 100644 --- a/Sources/GameCLI/Components/EventFormatter.swift +++ b/Sources/GameCLI/Components/EventFormatter.swift @@ -16,7 +16,7 @@ enum EventFormatter { case .energyReset(let amount): return "\(Terminal.yellow)⚡ \(L10n.text("能量恢复至", "Energy restored to")) \(amount)\(Terminal.reset)" - case .blockCleared(let target, let amount): + case .blockCleared(_, let target, let amount): return "\(Terminal.dim)🛡️ \(L10n.resolve(target)) \(amount) \(L10n.text("格挡清除", "Block cleared"))\(Terminal.reset)" case .drew(let cardId): @@ -30,10 +30,10 @@ enum EventFormatter { let def = CardRegistry.require(cardId) return "\(Terminal.bold)▶️ \(L10n.text("打出", "Played")) \(L10n.resolve(def.name)) (◆\(cost))\(Terminal.reset)" - case .damageDealt(let source, let target, let amount, let blocked): + case .damageDealt(_, let source, _, let target, let amount, let blocked): return formatDamage(source: source, target: target, amount: amount, blocked: blocked) - case .blockGained(let target, let amount): + case .blockGained(_, let target, let amount): return "\(Terminal.cyan)🛡️ \(L10n.resolve(target)) +\(amount) \(L10n.text("格挡", "Block"))\(Terminal.reset)" case .handDiscarded(let count): @@ -63,10 +63,10 @@ enum EventFormatter { case .invalidAction(let reason): return "\(Terminal.red)❌ \(L10n.resolve(reason))\(Terminal.reset)" - case .statusApplied(let target, let effect, let stacks): + case .statusApplied(_, let target, let effect, let stacks): return "\(Terminal.magenta)✨ \(L10n.resolve(target)) \(L10n.text("获得", "gains")) \(L10n.resolve(effect)) \(stacks) \(L10n.text("层", "stacks"))\(Terminal.reset)" - case .statusExpired(let target, let effect): + case .statusExpired(_, let target, let effect): return "\(Terminal.dim)💨 \(L10n.resolve(target)) \(L10n.text("的", "'s")) \(L10n.resolve(effect)) \(L10n.text("已消退", "has faded"))\(Terminal.reset)" // MARK: - 疯狂系统事件(占卜家序列) diff --git a/Sources/GameCore/Battle/BattleEngine.swift b/Sources/GameCore/Battle/BattleEngine.swift index 1eb5f6d..2823179 100644 --- a/Sources/GameCore/Battle/BattleEngine.swift +++ b/Sources/GameCore/Battle/BattleEngine.swift @@ -289,7 +289,7 @@ public final class BattleEngine: @unchecked Sendable { if state.player.block > 0 { let clearedBlock = state.player.block state.player.clearBlock() - emit(.blockCleared(target: state.player.name, amount: clearedBlock)) + emit(.blockCleared(targetEntityId: state.player.id, target: state.player.name, amount: clearedBlock)) } // P2: 状态递减现在由 processStatusesAtTurnEnd 处理(在回合结束时) @@ -373,7 +373,11 @@ public final class BattleEngine: @unchecked Sendable { if state.enemies[index].block > 0 { let clearedBlock = state.enemies[index].block state.enemies[index].clearBlock() - emit(.blockCleared(target: state.enemies[index].name, amount: clearedBlock)) + emit(.blockCleared( + targetEntityId: state.enemies[index].id, + target: state.enemies[index].name, + amount: clearedBlock + )) } } startNewTurn() @@ -624,7 +628,9 @@ public final class BattleEngine: @unchecked Sendable { } emit(.damageDealt( + sourceEntityId: attacker.id, source: resolveDisplayName(for: source), + targetEntityId: defenderBefore.id, target: resolveDisplayName(for: target), amount: damageResult.dealt, blocked: damageResult.blocked @@ -649,7 +655,7 @@ public final class BattleEngine: @unchecked Sendable { case .player: state.player.gainBlock(block) battleStats.totalBlockGained += block - emit(.blockGained(target: state.player.name, amount: block)) + emit(.blockGained(targetEntityId: state.player.id, target: state.player.name, amount: block)) // P4: 触发获得格挡遗物效果(仅玩家) triggerRelics(.blockGained(amount: block)) case .enemy(index: let enemyIndex): @@ -658,7 +664,11 @@ public final class BattleEngine: @unchecked Sendable { return } state.enemies[enemyIndex].gainBlock(block) - emit(.blockGained(target: state.enemies[enemyIndex].name, amount: block)) + emit(.blockGained( + targetEntityId: state.enemies[enemyIndex].id, + target: state.enemies[enemyIndex].name, + amount: block + )) } } @@ -677,6 +687,7 @@ public final class BattleEngine: @unchecked Sendable { if statusId == Madness.id && target == .player && stacks > 0 && shouldSkipNextMadnessFromRewrite { shouldSkipNextMadnessFromRewrite = false emit(.statusApplied( + targetEntityId: state.player.id, target: state.player.name, effect: LocalizedText("(预言者手札抵消疯狂)", "(Prophet Notes negated Madness)"), stacks: 0 @@ -687,14 +698,24 @@ public final class BattleEngine: @unchecked Sendable { switch target { case .player: state.player.statuses.apply(statusId, stacks: stacks) - emit(.statusApplied(target: state.player.name, effect: def.name, stacks: stacks)) + emit(.statusApplied( + targetEntityId: state.player.id, + target: state.player.name, + effect: def.name, + stacks: stacks + )) case .enemy(index: let enemyIndex): guard enemyIndex >= 0, enemyIndex < state.enemies.count else { emit(.invalidAction(reason: LocalizedText("无效的敌人索引", "Invalid enemy index"))) return } state.enemies[enemyIndex].statuses.apply(statusId, stacks: stacks) - emit(.statusApplied(target: state.enemies[enemyIndex].name, effect: def.name, stacks: stacks)) + emit(.statusApplied( + targetEntityId: state.enemies[enemyIndex].id, + target: state.enemies[enemyIndex].name, + effect: def.name, + stacks: stacks + )) } } @@ -804,8 +825,19 @@ public final class BattleEngine: @unchecked Sendable { // 3) 发出状态过期事件 for statusId in expired { guard let def = StatusRegistry.get(statusId) else { continue } + let entityId: String + switch target { + case .player: + entityId = state.player.id + case .enemy(index: let enemyIndex): + guard enemyIndex >= 0, enemyIndex < state.enemies.count else { + emit(.invalidAction(reason: LocalizedText("无效的敌人索引", "Invalid enemy index"))) + return + } + entityId = state.enemies[enemyIndex].id + } let entityName = resolveDisplayName(for: target) - emit(.statusExpired(target: entityName, effect: def.name)) + emit(.statusExpired(targetEntityId: entityId, target: entityName, effect: def.name)) } } diff --git a/Sources/GameCore/Events.swift b/Sources/GameCore/Events.swift index f07b4c9..247c3eb 100644 --- a/Sources/GameCore/Events.swift +++ b/Sources/GameCore/Events.swift @@ -11,7 +11,7 @@ public enum BattleEvent: Sendable, Equatable { case energyReset(amount: Int) /// 格挡清除 - case blockCleared(target: LocalizedText, amount: Int) + case blockCleared(targetEntityId: String, target: LocalizedText, amount: Int) /// 抽牌 case drew(cardId: CardID) @@ -23,10 +23,17 @@ public enum BattleEvent: Sendable, Equatable { case played(cardInstanceId: String, cardId: CardID, cost: Int) /// 造成伤害 - case damageDealt(source: LocalizedText, target: LocalizedText, amount: Int, blocked: Int) + case damageDealt( + sourceEntityId: String, + source: LocalizedText, + targetEntityId: String, + target: LocalizedText, + amount: Int, + blocked: Int + ) /// 获得格挡 - case blockGained(target: LocalizedText, amount: Int) + case blockGained(targetEntityId: String, target: LocalizedText, amount: Int) /// 手牌弃置(回合结束时) case handDiscarded(count: Int) @@ -56,10 +63,10 @@ public enum BattleEvent: Sendable, Equatable { case invalidAction(reason: LocalizedText) /// 获得状态效果 - case statusApplied(target: LocalizedText, effect: LocalizedText, stacks: Int) + case statusApplied(targetEntityId: String, target: LocalizedText, effect: LocalizedText, stacks: Int) /// 状态效果过期 - case statusExpired(target: LocalizedText, effect: LocalizedText) + case statusExpired(targetEntityId: String, target: LocalizedText, effect: LocalizedText) // MARK: - 疯狂系统事件(占卜家序列) @@ -100,7 +107,7 @@ extension BattleEvent { case .energyReset(let amount): return "⚡ 能量恢复至 \(amount)" - case .blockCleared(let target, let amount): + case .blockCleared(_, let target, let amount): return "🛡️ \(target.resolved(for: .zhHans)) 的格挡 \(amount) 已清除" case .drew(let cardId): @@ -114,14 +121,14 @@ extension BattleEvent { let def = CardRegistry.require(cardId) return "▶️ 打出 \(def.name.resolved(for: .zhHans))(消耗 \(cost) 能量)" - case .damageDealt(let source, let target, let amount, let blocked): + case .damageDealt(_, let source, _, let target, let amount, let blocked): if blocked > 0 { return "💥 \(source.resolved(for: .zhHans)) 对 \(target.resolved(for: .zhHans)) 造成 \(amount) 伤害(\(blocked) 被格挡)" } else { return "💥 \(source.resolved(for: .zhHans)) 对 \(target.resolved(for: .zhHans)) 造成 \(amount) 伤害" } - case .blockGained(let target, let amount): + case .blockGained(_, let target, let amount): return "🛡️ \(target.resolved(for: .zhHans)) 获得 \(amount) 格挡" case .handDiscarded(let count): @@ -151,10 +158,10 @@ extension BattleEvent { case .invalidAction(let reason): return "❌ 无效操作:\(reason.resolved(for: .zhHans))" - case .statusApplied(let target, let effect, let stacks): + case .statusApplied(_, let target, let effect, let stacks): return "✨ \(target.resolved(for: .zhHans)) 获得 \(effect.resolved(for: .zhHans)) \(stacks) 层" - case .statusExpired(let target, let effect): + case .statusExpired(_, let target, let effect): return "💨 \(target.resolved(for: .zhHans)) 的 \(effect.resolved(for: .zhHans)) 已消退" // MARK: - 疯狂系统事件 diff --git a/Tests/GameCoreTests/BattleEngineFlowEventOrderTests.swift b/Tests/GameCoreTests/BattleEngineFlowEventOrderTests.swift new file mode 100644 index 0000000..c163e71 --- /dev/null +++ b/Tests/GameCoreTests/BattleEngineFlowEventOrderTests.swift @@ -0,0 +1,100 @@ +import XCTest +@testable import GameCore + +final class BattleEngineFlowEventOrderTests: XCTestCase { + private func makeStrikeDeck(count: Int) -> [Card] { + (1...count).map { i in Card(id: "strike_\(i)", cardId: "strike") } + } + + private func makeEngine( + enemyHP: Int = 40, + deck: [Card], + seed: UInt64 = 1 + ) -> BattleEngine { + let player = Entity(id: "player", name: LocalizedText("玩家", "Player"), maxHP: 80) + let enemy = Entity( + id: "enemy", + name: LocalizedText("木桩", "Dummy"), + maxHP: enemyHP, + enemyId: "jaw_worm" + ) + return BattleEngine(player: player, enemies: [enemy], deck: deck, seed: seed) + } + + func testStartBattle_emitsBootstrapEventsInStableOrder() { + let engine = makeEngine(deck: makeStrikeDeck(count: 5)) + engine.startBattle() + + XCTAssertGreaterThanOrEqual(engine.events.count, 9) + XCTAssertEqual(engine.events[0], .battleStarted) + XCTAssertEqual(engine.events[1], .turnStarted(turn: 1)) + XCTAssertEqual(engine.events[2], .energyReset(amount: 3)) + + let drawIndices = engine.events.enumerated().compactMap { index, event -> Int? in + if case .drew = event { return index } + return nil + } + XCTAssertEqual(drawIndices, [3, 4, 5, 6, 7]) + + if case .enemyIntent = engine.events[8] { + // pass + } else { + XCTFail("Expected enemyIntent at index 8, got \(engine.events[8])") + } + } + + func testLethalPlay_emitsPlayedDeathDamageBattleWonInOrder() { + let engine = makeEngine(enemyHP: 1, deck: [Card(id: "strike_1", cardId: "strike")]) + engine.startBattle() + engine.clearEvents() + + XCTAssertTrue(engine.handleAction(.playCard(handIndex: 0, targetEnemyIndex: 0))) + + guard let playedIndex = firstIndex(of: .played, in: engine.events) else { + return XCTFail("Missing played event") + } + guard let diedIndex = firstIndex(of: .entityDied, in: engine.events) else { + return XCTFail("Missing entityDied event") + } + guard let damageIndex = firstIndex(of: .damageDealt, in: engine.events) else { + return XCTFail("Missing damageDealt event") + } + guard let wonIndex = firstIndex(of: .battleWon, in: engine.events) else { + return XCTFail("Missing battleWon event") + } + + XCTAssertLessThan(playedIndex, diedIndex) + XCTAssertLessThan(diedIndex, damageIndex) + XCTAssertLessThan(damageIndex, wonIndex) + XCTAssertEqual(engine.events.filter { event in + if case .battleWon = event { return true } + return false + }.count, 1) + } + + private func firstIndex(of kind: EventKind, in events: [BattleEvent]) -> Int? { + events.firstIndex { kind.matches($0) } + } +} + +private enum EventKind { + case played + case entityDied + case damageDealt + case battleWon + + func matches(_ event: BattleEvent) -> Bool { + switch (self, event) { + case (.played, .played): + return true + case (.entityDied, .entityDied): + return true + case (.damageDealt, .damageDealt): + return true + case (.battleWon, .battleWon): + return true + default: + return false + } + } +} diff --git a/Tests/GameCoreTests/BattleEngineFlowTests.swift b/Tests/GameCoreTests/BattleEngineFlowTests.swift index 69cab1d..62836e3 100644 --- a/Tests/GameCoreTests/BattleEngineFlowTests.swift +++ b/Tests/GameCoreTests/BattleEngineFlowTests.swift @@ -85,6 +85,29 @@ final class BattleEngineFlowTests: XCTestCase { XCTAssertEqual(engine.state.enemies[0].currentHP, e1HPBefore) XCTAssertEqual(engine.state.enemies[1].currentHP, e2HPBefore) } + + func testPlayAttackCard_targetsCorrectEnemyIndex_whenMultipleEnemiesAlive() { + + print("🧪 测试:testPlayAttackCard_targetsCorrectEnemyIndex_whenMultipleEnemiesAlive") + let player = Entity(id: "player", name: LocalizedText("玩家", "玩家"), maxHP: 80) + let e1 = Entity(id: "e1", name: LocalizedText("敌人A", "敌人A"), maxHP: 999, enemyId: "jaw_worm") + let e2 = Entity(id: "e2", name: LocalizedText("敌人B", "敌人B"), maxHP: 999, enemyId: "jaw_worm") + let engine = BattleEngine( + player: player, + enemies: [e1, e2], + deck: [Card(id: "strike_1", cardId: "strike")], + seed: 1 + ) + engine.startBattle() + engine.clearEvents() + + let e1HPBefore = engine.state.enemies[0].currentHP + let e2HPBefore = engine.state.enemies[1].currentHP + + XCTAssertTrue(engine.handleAction(PlayerAction.playCard(handIndex: 0, targetEnemyIndex: 1))) + XCTAssertEqual(engine.state.enemies[0].currentHP, e1HPBefore) + XCTAssertEqual(engine.state.enemies[1].currentHP, e2HPBefore - 6) + } func testShuffleDiscardIntoDraw_emitsShuffledEvent_nextTurn() { @@ -145,6 +168,7 @@ final class BattleEngineFlowTests: XCTestCase { XCTAssertEqual(engine.state.player.statuses.stacks(of: "vulnerable"), 0) XCTAssertTrue( engine.events.contains(.statusExpired( + targetEntityId: "player", target: LocalizedText("玩家", "玩家"), effect: Vulnerable.name )), @@ -173,4 +197,3 @@ final class BattleEngineFlowTests: XCTestCase { XCTAssertEqual(engine.state.player.statuses.stacks(of: "poison"), 1) } } - diff --git a/Tests/GameCoreTests/BattleEventDescriptionTests.swift b/Tests/GameCoreTests/BattleEventDescriptionTests.swift index 9b5cd7a..e4a8c4d 100644 --- a/Tests/GameCoreTests/BattleEventDescriptionTests.swift +++ b/Tests/GameCoreTests/BattleEventDescriptionTests.swift @@ -9,13 +9,13 @@ final class BattleEventDescriptionTests: XCTestCase { (.battleStarted, "战斗开始"), (.turnStarted(turn: 1), "第 1 回合"), (.energyReset(amount: 3), "能量"), - (.blockCleared(target: LocalizedText("玩家", "玩家"), amount: 5), "玩家"), + (.blockCleared(targetEntityId: "player", target: LocalizedText("玩家", "玩家"), amount: 5), "玩家"), (.drew(cardId: "strike"), "抽到"), (.shuffled(count: 5), "洗牌"), (.played(cardInstanceId: "strike_1", cardId: "strike", cost: 1), "打出"), - (.damageDealt(source: LocalizedText("玩家", "玩家"), target: LocalizedText("敌人", "敌人"), amount: 6, blocked: 0), "造成"), - (.damageDealt(source: LocalizedText("玩家", "玩家"), target: LocalizedText("敌人", "敌人"), amount: 6, blocked: 3), "被格挡"), - (.blockGained(target: LocalizedText("玩家", "玩家"), amount: 5), "格挡"), + (.damageDealt(sourceEntityId: "player", source: LocalizedText("玩家", "玩家"), targetEntityId: "enemy", target: LocalizedText("敌人", "敌人"), amount: 6, blocked: 0), "造成"), + (.damageDealt(sourceEntityId: "player", source: LocalizedText("玩家", "玩家"), targetEntityId: "enemy", target: LocalizedText("敌人", "敌人"), amount: 6, blocked: 3), "被格挡"), + (.blockGained(targetEntityId: "player", target: LocalizedText("玩家", "玩家"), amount: 5), "格挡"), (.handDiscarded(count: 3), "弃置"), (.enemyIntent(enemyId: "e", action: LocalizedText("攻击", "Attack"), damage: 10), "敌人意图"), (.enemyAction(enemyId: "e", action: LocalizedText("攻击", "Attack")), "执行"), @@ -25,8 +25,20 @@ final class BattleEventDescriptionTests: XCTestCase { (.battleLost, "失败"), (.notEnoughEnergy(required: 2, available: 1), "能量不足"), (.invalidAction(reason: LocalizedText("测试", "Test")), "无效操作"), - (.statusApplied(target: LocalizedText("玩家", "玩家"), effect: LocalizedText("易伤", "Vulnerable"), stacks: 2), "获得"), - (.statusExpired(target: LocalizedText("玩家", "玩家"), effect: LocalizedText("易伤", "Vulnerable")), "消退"), + (.statusApplied(targetEntityId: "player", target: LocalizedText("玩家", "玩家"), effect: LocalizedText("易伤", "Vulnerable"), stacks: 2), "获得"), + (.statusExpired(targetEntityId: "player", target: LocalizedText("玩家", "玩家"), effect: LocalizedText("易伤", "Vulnerable")), "消退"), + (.madnessReduced(from: 6, to: 4), "疯狂消减"), + (.madnessThreshold(level: 2, effect: LocalizedText("获得虚弱 1", "Gain Weak 1")), "阈值"), + (.madnessDiscard(cardId: "strike"), "弃牌"), + (.madnessCleared(amount: 2), "消除 2 层"), + (.madnessCleared(amount: 0), "完全消除"), + (.foresightChosen(cardId: "strike", fromCount: 3), "预知"), + (.rewindCard(cardId: "strike"), "回溯"), + (.intentRewritten( + enemyName: LocalizedText("敌人", "Enemy"), + oldIntent: LocalizedText("攻击", "Attack"), + newIntent: LocalizedText("防御", "Defend") + ), "改写"), ] for (event, expected) in events { @@ -35,4 +47,21 @@ final class BattleEventDescriptionTests: XCTestCase { XCTAssertTrue(desc.contains(expected), "期望描述包含关键字:\(expected)(实际:\(desc))") } } + + func testBattleEvent_description_sequenceOrder_staysStable() { + let events: [BattleEvent] = [ + .battleStarted, + .turnStarted(turn: 1), + .energyReset(amount: 3), + .turnEnded(turn: 1), + .battleWon, + ] + let descriptions = events.map(\.description) + XCTAssertEqual(descriptions.count, 5) + XCTAssertTrue(descriptions[0].contains("战斗开始")) + XCTAssertTrue(descriptions[1].contains("第 1 回合")) + XCTAssertTrue(descriptions[2].contains("能量")) + XCTAssertTrue(descriptions[3].contains("回合结束")) + XCTAssertTrue(descriptions[4].contains("胜利")) + } } diff --git a/Tests/GameCoreTests/SeerRelicsAndMadnessTests.swift b/Tests/GameCoreTests/SeerRelicsAndMadnessTests.swift index 0859471..32bf538 100644 --- a/Tests/GameCoreTests/SeerRelicsAndMadnessTests.swift +++ b/Tests/GameCoreTests/SeerRelicsAndMadnessTests.swift @@ -199,7 +199,7 @@ final class SeerRelicsAndMadnessTests: XCTestCase { XCTAssertEqual(engine.state.player.statuses.stacks(of: "madness"), 0) XCTAssertTrue(engine.events.contains(where: { - if case .statusApplied(_, let effect, let stacks) = $0 { + if case .statusApplied(_, _, let effect, let stacks) = $0 { return effect.zhHans.contains("预言者手札") && stacks == 0 } return false