From f6d3b3985991d8c2cbe1eecac1a829bf7aa30dca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Sun, 8 Feb 2026 17:08:08 +0800 Subject: [PATCH 01/29] docs --- ...00\346\234\257\346\226\207\346\241\243.md" | 422 ++++++++++++++++++ ...50\345\261\200\345\234\260\345\233\276.md" | 169 +++++++ .../OpenvisionOS \344\270\232\345\212\241.md" | 156 +++++++ ...00\346\234\257\346\226\207\346\241\243.md" | 211 +++++++++ 4 files changed, 958 insertions(+) create mode 100644 ".github/docs/DrumGo \346\212\200\346\234\257\346\226\207\346\241\243.md" create mode 100644 ".github/docs/DrumGo\344\270\232\345\212\241\345\205\250\345\261\200\345\234\260\345\233\276.md" create mode 100644 ".github/docs/OpenvisionOS \344\270\232\345\212\241.md" create mode 100644 ".github/docs/OpenvisionOS \346\212\200\346\234\257\346\226\207\346\241\243.md" 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` + From a92817fb2629154960b410d7fee451d215ae9e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:40:10 +0800 Subject: [PATCH 02/29] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20SaluAVP=20XR?= =?UTF-8?q?/3D=20=E5=8E=9F=E7=94=9F=E7=A0=B4=E5=9D=8F=E6=80=A7=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=EF=BC=8C=E6=98=8E?= =?UTF-8?q?=E7=A1=AE=E7=9B=AE=E6=A0=87=E3=80=81=E6=96=B9=E6=A1=88=E5=8F=8A?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E6=A0=87=E5=87=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...p-full-ui-animation-implementation-plan.md | 522 ++++++++++++++++++ 1 file changed, 522 insertions(+) create mode 100644 .github/plans/2026-02-09-saluavp-full-ui-animation-implementation-plan.md 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..d739f1d --- /dev/null +++ b/.github/plans/2026-02-09-saluavp-full-ui-animation-implementation-plan.md @@ -0,0 +1,522 @@ +# SaluAVP XR/3D 原生破坏性重构实施计划 + +> 执行方式:建议使用 `executing-plans` 按批次实现与验收。 + +**Goal(目标):** 以 XR 原生、3D 原生为唯一方向,对 `SaluAVP` 做破坏性重构:完整补齐战斗动画、目标选择、多敌人、房间 UI(休息/商店/事件)、奖励链路(含精英/Boss 遗物)、存档 Continue 与回放观测,并在每个阶段及时删除老旧实现与兼容层。 + +**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) 2D 控制面板支持 Continue(从快照恢复)与自动保存。 +6) 可导出一局关键选择路径并用于重放验证。 +7) 旧 AVP 占位逻辑被删除(无旧 room panel 占位完成路径、无旧 battle 静态分支)。 +8) 修改 `SaluNative/**` 后 `xcodebuild` 可通过;修改 `Sources/**` 后 `swift test` 可通过。 + +--- + +## 重构硬约束(本计划强制) + +1) 不向后兼容旧 AVP 表现层 API:允许重命名 `RunSession.Route`、面板组件、渲染入口。 +2) 不保留“兼容桥接层”超过一个任务周期:新实现落地的同批次必须删除旧实现。 +3) 禁止新旧双轨渲染:同一能力只保留一条主路径。 +4) 2D Window 仅承担控制面板与调试入口;核心玩法必须在 Immersive 3D 中完成。 +5) 存档兼容策略仅保证 `GameCore.RunSnapshot` 语义,不保证旧 AVP 私有结构可读。 + +--- + +## 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:存档与 Continue(控制面板能力补齐) + +### Task 18: AVP 快照存储层(RunSnapshot) +**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: 控制面板 Continue / Save / Reset UI +**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: 自动保存策略 +**Files:** +- Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` + +**Step 1: 最小验收** +- 关键节点自动保存:进入房间、战斗结算、奖励确认后。 + +**Step 2: 最小实现** +- 封装 `autosaveIfNeeded()`,失败仅记录错误不阻塞流程。 + +**Step 3: 验证** +- Manual: 强制关闭 App 后恢复到最近关键节点。 + +--- + +### P7:可观测性与回放(Determinism + Replay) + +### Task 21: 选择路径记录模型 +**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: Trace 导出与重放模式(开发向) +**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` +- Create: `Tests/GameCoreTests/BattleSeedAndEncounterParityTests.swift` +- Create: `Tests/GameCoreTests/RunSnapshotCodableRoundTripTests.swift` + +**Step 1: 最小验收** +- 新增逻辑(多敌人目标、奖励链路、快照读写)有单测覆盖。 + +**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) 完成 P6-P8 后:交付回归 +- `swift test` +- `xcodebuild ... build` +- 手动:新开局、Continue、重放导入全链路。 + +--- + +## 不确定项(执行前建议确认) + +1) 已确认:本轮不纳入“甩牌/投掷命中(B1)”,留在后续迭代。 +2) 事件房间中的“文本演出深度”目标(仅功能可用 vs 有完整剧情排版和动效)。 +3) 存档策略是否只保留“单槽自动保存”,还是支持多槽(多槽会显著增加 UI 与管理复杂度)。 From 9eeb17c21568edfaf77ad8a51c6e369ca1d54197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:59:43 +0800 Subject: [PATCH 03/29] =?UTF-8?q?feat(battle):=20Task=201:=20=E5=BB=BA?= =?UTF-8?q?=E7=AB=8B=20AVP=20=E6=88=98=E6=96=97=E4=BA=8B=E4=BB=B6=E6=B6=88?= =?UTF-8?q?=E8=B4=B9=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...p-full-ui-animation-implementation-plan.md | 2 +- .../ViewModels/BattlePresentationEvent.swift | 8 ++ .../SaluAVP/ViewModels/RunSession.swift | 48 ++++++++- .../BattleEngineFlowEventOrderTests.swift | 100 ++++++++++++++++++ .../BattleEventDescriptionTests.swift | 29 +++++ 5 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 SaluNative/SaluAVP/ViewModels/BattlePresentationEvent.swift create mode 100644 Tests/GameCoreTests/BattleEngineFlowEventOrderTests.swift 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 index d739f1d..988fd38 100644 --- 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 @@ -59,7 +59,7 @@ ### P1(最高优先级):战斗表现基础层(事件桥接 + 动画骨架) -### Task 1: 建立 AVP 战斗事件消费接口(允许破坏旧 AVP 接口) +### ✅Task 1: 建立 AVP 战斗事件消费接口(允许破坏旧 AVP 接口) **Files:** - Create: `SaluNative/SaluAVP/ViewModels/BattlePresentationEvent.swift` - Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` diff --git a/SaluNative/SaluAVP/ViewModels/BattlePresentationEvent.swift b/SaluNative/SaluAVP/ViewModels/BattlePresentationEvent.swift new file mode 100644 index 0000000..d7fe243 --- /dev/null +++ b/SaluNative/SaluAVP/ViewModels/BattlePresentationEvent.swift @@ -0,0 +1,8 @@ +import Foundation +import GameCore + +/// AVP 表现层使用的战斗事件包装,包含稳定序号以支持动画队列消费。 +struct BattlePresentationEvent: Sendable, Equatable { + let sequence: Int + let event: BattleEvent +} diff --git a/SaluNative/SaluAVP/ViewModels/RunSession.swift b/SaluNative/SaluAVP/ViewModels/RunSession.swift index a530b64..facd820 100644 --- a/SaluNative/SaluAVP/ViewModels/RunSession.swift +++ b/SaluNative/SaluAVP/ViewModels/RunSession.swift @@ -20,8 +20,10 @@ 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 func startNewRun() { let seed: UInt64 @@ -40,8 +42,10 @@ final class RunSession { lastError = nil battleEngine = nil battleState = nil + battleEvents = [] battleNodeId = nil battleRoomType = nil + lastConsumedBattleEventIndex = 0 route = .map } @@ -88,7 +92,7 @@ final class RunSession { guard let battleEngine else { return } guard battleEngine.pendingInput == nil else { return } _ = battleEngine.handleAction(.playCard(handIndex: handIndex, targetEnemyIndex: nil)) - battleState = battleEngine.state + syncBattleStateFromEngine() finishBattleIfNeeded() } @@ -97,7 +101,7 @@ final class RunSession { guard let battleEngine else { return } guard battleEngine.pendingInput == nil else { return } _ = battleEngine.handleAction(.endTurn) - battleState = battleEngine.state + syncBattleStateFromEngine() finishBattleIfNeeded() } @@ -110,10 +114,23 @@ final class RunSession { guard routeIsBattle else { return } guard let battleEngine else { return } _ = battleEngine.submitForesightChoice(index: index) - battleState = battleEngine.state + syncBattleStateFromEngine() finishBattleIfNeeded() } + func consumeNewBattleEvents() -> [BattleEvent] { + let newEvents = consumeNewBattleEventSlice() + return Array(newEvents) + } + + func consumeNewBattlePresentationEvents() -> [BattlePresentationEvent] { + let startIndex = lastConsumedBattleEventIndex + let newEvents = consumeNewBattleEventSlice() + return newEvents.enumerated().map { offset, event in + BattlePresentationEvent(sequence: startIndex + offset, event: event) + } + } + func chooseCardReward(_ cardId: CardID?) { guard case .cardReward(let nodeId, let roomType, let offer, let goldEarned) = route else { return } guard var runState else { return } @@ -129,8 +146,10 @@ final class RunSession { self.runState = runState battleState = nil + battleEvents = [] battleNodeId = nil battleRoomType = nil + lastConsumedBattleEventIndex = 0 if runState.isOver { route = .runOver(lastNodeId: nodeId, won: runState.won, floor: runState.floor) @@ -151,6 +170,7 @@ final class RunSession { self.runState = runState // Freeze the final battle state for UI (reward panel), but release the engine. + self.battleEvents = battleEngine.events self.battleEngine = nil if battleEngine.state.playerWon == true { @@ -169,8 +189,10 @@ final class RunSession { route = .cardReward(nodeId: nodeId, roomType: roomTypeForRewards, offer: offer, goldEarned: goldEarned) } else { self.battleState = nil + self.battleEvents = [] self.battleNodeId = nil self.battleRoomType = nil + self.lastConsumedBattleEventIndex = 0 route = .runOver(lastNodeId: nodeId, won: false, floor: runState.floor) } } @@ -232,8 +254,10 @@ final class RunSession { battleEngine = engine battleState = engine.state + battleEvents = engine.events battleNodeId = nodeId battleRoomType = roomType + lastConsumedBattleEventIndex = 0 route = .battle(nodeId: nodeId, roomType: roomType) } @@ -247,7 +271,25 @@ final class RunSession { lastError = nil battleEngine = nil battleState = nil + battleEvents = [] battleNodeId = nil battleRoomType = nil + lastConsumedBattleEventIndex = 0 + } + + private func consumeNewBattleEventSlice() -> ArraySlice { + guard lastConsumedBattleEventIndex < battleEvents.count else { return [] } + let range = lastConsumedBattleEventIndex.. [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/BattleEventDescriptionTests.swift b/Tests/GameCoreTests/BattleEventDescriptionTests.swift index 9b5cd7a..bfa6a32 100644 --- a/Tests/GameCoreTests/BattleEventDescriptionTests.swift +++ b/Tests/GameCoreTests/BattleEventDescriptionTests.swift @@ -27,6 +27,18 @@ final class BattleEventDescriptionTests: XCTestCase { (.invalidAction(reason: LocalizedText("测试", "Test")), "无效操作"), (.statusApplied(target: LocalizedText("玩家", "玩家"), effect: LocalizedText("易伤", "Vulnerable"), stacks: 2), "获得"), (.statusExpired(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("胜利")) + } } From d6dd5713172321e3ff40e07bcf332b689933df41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:08:47 +0800 Subject: [PATCH 04/29] =?UTF-8?q?feat(battle):=20=E2=9C=85Task=202:=20?= =?UTF-8?q?=E6=8A=BD=E7=A6=BB=E6=88=98=E6=96=97=E6=B8=B2=E6=9F=93=E5=99=A8?= =?UTF-8?q?=EF=BC=8C=E9=99=8D=E4=BD=8E=20`ImmersiveRootView`=20=E5=A4=8D?= =?UTF-8?q?=E6=9D=82=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...p-full-ui-animation-implementation-plan.md | 2 +- .../Immersive/BattleAnimationSystem.swift | 16 + .../Immersive/BattleSceneRenderer.swift | 328 ++++++++++++++++++ .../SaluAVP/Immersive/ImmersiveRootView.swift | 324 ++--------------- 4 files changed, 367 insertions(+), 303 deletions(-) create mode 100644 SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift create mode 100644 SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift 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 index 988fd38..735d591 100644 --- 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 @@ -84,7 +84,7 @@ - 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` 复杂度 +### ✅Task 2: 抽离战斗渲染器,降低 `ImmersiveRootView` 复杂度 **Files:** - Create: `SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift` - Create: `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` diff --git a/SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift b/SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift new file mode 100644 index 0000000..68c0761 --- /dev/null +++ b/SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift @@ -0,0 +1,16 @@ +import RealityKit + +@MainActor +final class BattleAnimationSystem { + func beginRenderPass(in battleLayer: RealityKit.Entity) { + _ = battleLayer + } + + func endRenderPass(in battleLayer: RealityKit.Entity) { + _ = battleLayer + } + + func clear(in battleLayer: RealityKit.Entity) { + _ = battleLayer + } +} diff --git a/SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift b/SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift new file mode 100644 index 0000000..322a9b1 --- /dev/null +++ b/SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift @@ -0,0 +1,328 @@ +import GameCore +import RealityKit +import UIKit + +@MainActor +final class BattleSceneRenderer { + enum Names { + static let battleLayer = "battleLayer" + static let battleHeadAnchor = "battleHeadAnchor" + static let battleHandRoot = "battleHandRoot" + static let battleEnemyRoot = "battleEnemyRoot" + static let battleInspectRoot = "battleInspectRoot" + static let battlePilesRoot = "battlePilesRoot" + static let cardNamePrefix = "card:" + static let pileNamePrefix = "pile:" + } + + private var cardFaceTextureCache = CardFaceTextureCache() + private let animationSystem: BattleAnimationSystem + + init(animationSystem: BattleAnimationSystem) { + self.animationSystem = animationSystem + } + + convenience init() { + self.init(animationSystem: BattleAnimationSystem()) + } + + func makeBattleLayer() -> 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? + ) { + animationSystem.beginRenderPass(in: battleLayer) + + let enemyRoot = ensureEnemyRoot(in: battleLayer) + enemyRoot.children.forEach { $0.removeFromParent() } + + if let enemy = engine.state.enemies.first { + let enemyEntity = makeEnemyEntity(enemy: enemy) + enemyEntity.position = .zero + enemyRoot.addChild(enemyEntity) + } + + let headAnchor = ensureHeadAnchor(in: battleLayer) + let handRoot = ensureHandRoot(in: headAnchor) + + 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: battleLayer) + animationSystem.endRenderPass(in: battleLayer) + 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: battleLayer) + animationSystem.endRenderPass(in: battleLayer) + } + + func renderReward(state: BattleState, in battleLayer: RealityKit.Entity) { + let enemyRoot = ensureEnemyRoot(in: battleLayer) + enemyRoot.children.forEach { $0.removeFromParent() } + + if let enemy = state.enemies.first { + let enemyEntity = makeEnemyEntity(enemy: enemy) + enemyEntity.position = .zero + enemyRoot.addChild(enemyEntity) + } + + if let headAnchor = battleLayer.findEntity(named: Names.battleHeadAnchor) { + headAnchor.findEntity(named: Names.battleHandRoot)? + .children + .forEach { $0.removeFromParent() } + clearPeek(in: headAnchor) + } + + renderPiles(state: state, in: battleLayer) + } + + 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 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 battleLayer: RealityKit.Entity) { + let pilesRoot = battleLayer.findEntity(named: Names.battlePilesRoot) ?? { + let root = RealityKit.Entity() + root.name = Names.battlePilesRoot + root.position = [0, 0.045, -0.72] + root.orientation = simd_quatf(angle: -0.55, axis: [1, 0, 0]) + battleLayer.addChild(root) + return root + }() + + 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 makeEnemyEntity(enemy: GameCore.Entity) -> ModelEntity { + _ = enemy + 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) + } + + return entity + } +} diff --git a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift index a84fafd..7ae36d3 100644 --- a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift +++ b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift @@ -9,15 +9,13 @@ 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 peekedHandIndex: Int? = nil @State private var peekedPile: PileKind? = nil @State private var didPeekInCurrentPress: Bool = false @State private var suppressNextTap: Bool = false 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 +23,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,8 +48,8 @@ struct ImmersiveRootView: View { runSession.selectAccessibleNode(nodeId) case .battle: - guard value.entity.name.hasPrefix(cardNamePrefix) else { return } - let suffix = value.entity.name.dropFirst(cardNamePrefix.count) + 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) @@ -77,8 +69,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 +78,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 +111,7 @@ 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 battleLayer = battleSceneRenderer.makeBattleLayer() mapRoot.addChild(battleLayer) content.add(mapRoot) } update: { content, attachments in @@ -162,7 +135,7 @@ 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: BattleSceneRenderer.Names.battleLayer)?.isEnabled = false return } @@ -184,26 +157,8 @@ 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 }() @@ -223,20 +178,26 @@ struct ImmersiveRootView: View { switch runSession.route { case .battle: if let engine = runSession.battleEngine { - renderBattle(engine: engine, in: battleLayer) + battleSceneRenderer.render( + engine: engine, + in: battleLayer, + cardDisplayMode: appModel.cardDisplayMode, + language: .zhHans, + peekedHandIndex: peekedHandIndex + ) } else { - clearBattle(in: battleLayer) + battleSceneRenderer.clear(in: battleLayer) } case .cardReward: if let state = runSession.battleState { - renderBattleReward(state: state, in: battleLayer) + battleSceneRenderer.renderReward(state: state, in: battleLayer) } else { - clearBattle(in: battleLayer) + battleSceneRenderer.clear(in: battleLayer) } case .map, .room, .runOver: - clearBattle(in: battleLayer) + battleSceneRenderer.clear(in: battleLayer) } if let panel = attachments.entity(for: roomPanelAttachmentId) { @@ -387,247 +348,6 @@ struct ImmersiveRootView: View { 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() } - } - 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) - } - - 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) - 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 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: "\(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 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 - }() - - 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 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 - .forEach { $0.removeFromParent() } - } - - 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) - } - - return entity - } - private func updateMapState(run: RunState, in mapLayer: RealityKit.Entity) { // Map topology within the same floor doesn't change; only node state changes. for node in run.map { From 29fcf82f6d7c3ded6c521a68afd44ae7d20a7fa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:17:07 +0800 Subject: [PATCH 05/29] =?UTF-8?q?feat(battle):=20Task=203-=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=88=98=E6=96=97=E5=8A=A8=E7=94=BB=E9=98=9F=E5=88=97?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E4=BA=8B=E4=BB=B6=E5=A4=84=E7=90=86?= =?UTF-8?q?=E4=B8=8E=E6=B8=B2=E6=9F=93=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...p-full-ui-animation-implementation-plan.md | 2 +- .../Immersive/BattleAnimationQueue.swift | 82 +++++++++++++++++++ .../Immersive/BattleAnimationSystem.swift | 19 +++++ .../Immersive/BattleSceneRenderer.swift | 14 +++- .../SaluAVP/Immersive/ImmersiveRootView.swift | 7 +- 5 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 SaluNative/SaluAVP/Immersive/BattleAnimationQueue.swift 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 index 735d591..8d42150 100644 --- 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 @@ -105,7 +105,7 @@ - Run: `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build` - Manual: 新开一局进入战斗,确认地图/战斗切换、出牌、回合结束可用。 -### Task 3: 引入动画队列(先占位,不改交互) +### ❎Task 3: 引入动画队列(先占位,不改交互) **Files:** - Create: `SaluNative/SaluAVP/Immersive/BattleAnimationQueue.swift` - Modify: `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` diff --git a/SaluNative/SaluAVP/Immersive/BattleAnimationQueue.swift b/SaluNative/SaluAVP/Immersive/BattleAnimationQueue.swift new file mode 100644 index 0000000..412505b --- /dev/null +++ b/SaluNative/SaluAVP/Immersive/BattleAnimationQueue.swift @@ -0,0 +1,82 @@ +import GameCore + +@MainActor +struct BattleAnimationQueue { + enum AnimationKind: String, Sendable, Equatable { + case draw + case play + case hit + case die + case pileUpdate + } + + struct AnimationJob: Sendable, Equatable { + let sequence: Int + let kind: AnimationKind + let summary: String + } + + 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)"), + AnimationJob(sequence: event.sequence, kind: .pileUpdate, summary: "pileUpdate:draw") + ] + + case .played(_, let cardId, _): + return [ + AnimationJob(sequence: event.sequence, kind: .play, summary: "played:\(cardId.rawValue)"), + AnimationJob(sequence: event.sequence, kind: .pileUpdate, summary: "pileUpdate:play") + ] + + case .damageDealt(_, _, _, _): + return [AnimationJob(sequence: event.sequence, kind: .hit, summary: "damageDealt")] + + case .entityDied(let entityId, _): + return [AnimationJob(sequence: event.sequence, kind: .die, summary: "entityDied:\(entityId)")] + + 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 index 68c0761..0aeca74 100644 --- a/SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift +++ b/SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift @@ -1,9 +1,18 @@ import RealityKit +import OSLog @MainActor final class BattleAnimationSystem { + private var queue = BattleAnimationQueue() + private let logger = Logger(subsystem: "com.chiimagnus.SaluAVP", category: "BattleAnimation") + + func enqueue(events: [BattlePresentationEvent]) { + queue.enqueue(events: events) + } + func beginRenderPass(in battleLayer: RealityKit.Entity) { _ = battleLayer + processQueuedJobs() } func endRenderPass(in battleLayer: RealityKit.Entity) { @@ -12,5 +21,15 @@ final class BattleAnimationSystem { func clear(in battleLayer: RealityKit.Entity) { _ = battleLayer + queue.clear() + } + + private func processQueuedJobs() { + let jobs = queue.drainAll() + guard !jobs.isEmpty else { return } + + for job in jobs { + logger.info("[AnimationQueue] seq=\(job.sequence, privacy: .public) kind=\(job.kind.rawValue, privacy: .public) summary=\(job.summary, privacy: .public)") + } } } diff --git a/SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift b/SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift index 322a9b1..1738195 100644 --- a/SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift +++ b/SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift @@ -65,8 +65,10 @@ final class BattleSceneRenderer { in battleLayer: RealityKit.Entity, cardDisplayMode: CardDisplayMode, language: GameLanguage, - peekedHandIndex: Int? + peekedHandIndex: Int?, + newEvents: [BattlePresentationEvent] ) { + animationSystem.enqueue(events: newEvents) animationSystem.beginRenderPass(in: battleLayer) let enemyRoot = ensureEnemyRoot(in: battleLayer) @@ -136,7 +138,14 @@ final class BattleSceneRenderer { animationSystem.endRenderPass(in: battleLayer) } - func renderReward(state: BattleState, in battleLayer: RealityKit.Entity) { + func renderReward( + state: BattleState, + in battleLayer: RealityKit.Entity, + newEvents: [BattlePresentationEvent] + ) { + animationSystem.enqueue(events: newEvents) + animationSystem.beginRenderPass(in: battleLayer) + let enemyRoot = ensureEnemyRoot(in: battleLayer) enemyRoot.children.forEach { $0.removeFromParent() } @@ -154,6 +163,7 @@ final class BattleSceneRenderer { } renderPiles(state: state, in: battleLayer) + animationSystem.endRenderPass(in: battleLayer) } private func addBattleFloor(to root: RealityKit.Entity) { diff --git a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift index 7ae36d3..b26c931 100644 --- a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift +++ b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift @@ -178,12 +178,14 @@ struct ImmersiveRootView: View { switch runSession.route { case .battle: if let engine = runSession.battleEngine { + let newEvents = runSession.consumeNewBattlePresentationEvents() battleSceneRenderer.render( engine: engine, in: battleLayer, cardDisplayMode: appModel.cardDisplayMode, language: .zhHans, - peekedHandIndex: peekedHandIndex + peekedHandIndex: peekedHandIndex, + newEvents: newEvents ) } else { battleSceneRenderer.clear(in: battleLayer) @@ -191,7 +193,8 @@ struct ImmersiveRootView: View { case .cardReward: if let state = runSession.battleState { - battleSceneRenderer.renderReward(state: state, in: battleLayer) + let newEvents = runSession.consumeNewBattlePresentationEvents() + battleSceneRenderer.renderReward(state: state, in: battleLayer, newEvents: newEvents) } else { battleSceneRenderer.clear(in: battleLayer) } From debc2c741020657e7b9caef29b6537ac25a3af3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:32:40 +0800 Subject: [PATCH 06/29] =?UTF-8?q?feat:=20P2=E2=9C=85-Enhance=20battle=20an?= =?UTF-8?q?imation=20system=20with=20new=20events=20and=20UI=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added new animation kinds: block, turnStart, turnEnd, and energyPulse to BattleAnimationQueue. - Updated AnimationJob structure to include additional properties for cardId, playedCardContext, amount, blocked, entityId, turn, and energy. - Enhanced BattleAnimationSystem to handle new animation kinds and improved rendering logic for hand and enemy states. - Introduced FloatingTextFactory for displaying floating text effects for damage, block, and neutral events. - Updated BattleHUDPanel to display turn banners and energy information with animations. - Modified RunSession to track played card contexts and manage event sequences effectively. - Refactored BattlePresentationEvent to include playedCardContext for better event handling. --- .../Immersive/BattleAnimationQueue.swift | 87 +++- .../Immersive/BattleAnimationSystem.swift | 440 +++++++++++++++++- .../SaluAVP/Immersive/BattleHUDPanel.swift | 129 ++++- .../Immersive/BattleSceneRenderer.swift | 82 +++- .../Immersive/FloatingTextFactory.swift | 92 ++++ .../SaluAVP/Immersive/ImmersiveRootView.swift | 2 +- .../ViewModels/BattlePresentationEvent.swift | 21 + .../SaluAVP/ViewModels/RunSession.swift | 44 +- 8 files changed, 855 insertions(+), 42 deletions(-) create mode 100644 SaluNative/SaluAVP/Immersive/FloatingTextFactory.swift diff --git a/SaluNative/SaluAVP/Immersive/BattleAnimationQueue.swift b/SaluNative/SaluAVP/Immersive/BattleAnimationQueue.swift index 412505b..c4ccace 100644 --- a/SaluNative/SaluAVP/Immersive/BattleAnimationQueue.swift +++ b/SaluNative/SaluAVP/Immersive/BattleAnimationQueue.swift @@ -6,14 +6,25 @@ struct BattleAnimationQueue { 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] = [] @@ -39,21 +50,87 @@ struct BattleAnimationQueue { switch event.event { case .drew(let cardId): return [ - AnimationJob(sequence: event.sequence, kind: .draw, summary: "drew:\(cardId.rawValue)"), + 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)"), + 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(_, _, _, _): - return [AnimationJob(sequence: event.sequence, kind: .hit, summary: "damageDealt")] + case .damageDealt(_, _, let amount, let blocked): + return [ + AnimationJob( + sequence: event.sequence, + kind: .hit, + summary: "damageDealt", + amount: amount, + blocked: blocked + ) + ] + + case .blockGained(_, let amount): + return [ + AnimationJob( + sequence: event.sequence, + kind: .block, + summary: "blockGained", + amount: amount + ) + ] case .entityDied(let entityId, _): - return [AnimationJob(sequence: event.sequence, kind: .die, summary: "entityDied:\(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")] diff --git a/SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift b/SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift index 0aeca74..1ff7315 100644 --- a/SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift +++ b/SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift @@ -1,35 +1,461 @@ 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) { - _ = battleLayer - processQueuedJobs() + 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) { - _ = 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) { - _ = battleLayer queue.clear() + previousHandTransforms = [:] + previousEnemyTransforms = [:] + battleLayer.findEntity(named: Names.animationRoot)?.removeFromParent() } - private func processQueuedJobs() { + 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, + pilesRoot: pilesRoot + ) + case .hit: + playDamageFeedback(job: job, in: battleLayer, enemyRoot: enemyRoot) + case .block: + playBlockFeedback(job: job, in: battleLayer, 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?, + 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]) + + 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, + enemyRoot: RealityKit.Entity? + ) { + guard let target = enemyRoot?.children.first 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, + enemyRoot: RealityKit.Entity? + ) { + guard let target = enemyRoot?.children.first 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 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..7dd7934 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,6 +37,20 @@ 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)") @@ -35,9 +58,30 @@ struct BattleHUDPanel: View { 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) + 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 +154,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 +176,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 { + 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, @@ -252,16 +294,7 @@ final class BattleSceneRenderer { inspectRoot.children.forEach { $0.removeFromParent() } } - private func renderPiles(state: BattleState, in battleLayer: RealityKit.Entity) { - let pilesRoot = battleLayer.findEntity(named: Names.battlePilesRoot) ?? { - let root = RealityKit.Entity() - root.name = Names.battlePilesRoot - root.position = [0, 0.045, -0.72] - root.orientation = simd_quatf(angle: -0.55, axis: [1, 0, 0]) - battleLayer.addChild(root) - return root - }() - + 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 @@ -283,11 +316,10 @@ final class BattleSceneRenderer { } private func makeEnemyEntity(enemy: GameCore.Entity) -> ModelEntity { - _ = enemy 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.name = "enemy:\(enemy.id)" entity.components.set(CollisionComponent(shapes: [.generateSphere(radius: 0.14)])) entity.components.set(InputTargetComponent()) return entity 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 b26c931..a29d23a 100644 --- a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift +++ b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift @@ -305,7 +305,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 diff --git a/SaluNative/SaluAVP/ViewModels/BattlePresentationEvent.swift b/SaluNative/SaluAVP/ViewModels/BattlePresentationEvent.swift index d7fe243..f1f9f86 100644 --- a/SaluNative/SaluAVP/ViewModels/BattlePresentationEvent.swift +++ b/SaluNative/SaluAVP/ViewModels/BattlePresentationEvent.swift @@ -1,8 +1,29 @@ import Foundation import GameCore +enum PlayedCardDestinationPile: String, Sendable, Equatable { + case discard + case exhaust +} + +struct PlayedCardPresentationContext: Sendable, Equatable { + let sourceHandIndex: Int + let destinationPile: PlayedCardDestinationPile +} + /// 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/RunSession.swift b/SaluNative/SaluAVP/ViewModels/RunSession.swift index facd820..af56a2b 100644 --- a/SaluNative/SaluAVP/ViewModels/RunSession.swift +++ b/SaluNative/SaluAVP/ViewModels/RunSession.swift @@ -24,6 +24,7 @@ final class RunSession { private var battleNodeId: String? private var battleRoomType: RoomType? private var lastConsumedBattleEventIndex: Int = 0 + private var playedCardContextsBySequence: [Int: PlayedCardPresentationContext] = [:] func startNewRun() { let seed: UInt64 @@ -46,6 +47,7 @@ final class RunSession { battleNodeId = nil battleRoomType = nil lastConsumedBattleEventIndex = 0 + playedCardContextsBySequence = [:] route = .map } @@ -91,8 +93,11 @@ final class RunSession { guard routeIsBattle else { return } guard let battleEngine else { return } guard battleEngine.pendingInput == nil else { return } + guard battleEngine.state.hand.indices.contains(handIndex) else { return } + let eventStartIndex = battleEngine.events.count _ = battleEngine.handleAction(.playCard(handIndex: handIndex, targetEnemyIndex: nil)) syncBattleStateFromEngine() + capturePlayedCardContexts(startIndex: eventStartIndex, sourceHandIndex: handIndex) finishBattleIfNeeded() } @@ -119,7 +124,14 @@ final class RunSession { } func consumeNewBattleEvents() -> [BattleEvent] { + let startIndex = lastConsumedBattleEventIndex let newEvents = consumeNewBattleEventSlice() + let consumedEnd = startIndex + newEvents.count + if consumedEnd > startIndex { + for sequence in startIndex.. ArraySlice { @@ -290,6 +312,26 @@ final class RunSession { battleEvents = battleEngine.events if battleEvents.count < lastConsumedBattleEventIndex { lastConsumedBattleEventIndex = 0 + playedCardContextsBySequence = [:] + } + playedCardContextsBySequence = playedCardContextsBySequence.filter { $0.key < battleEvents.count } + } + + private func capturePlayedCardContexts(startIndex: Int, sourceHandIndex: Int) { + guard startIndex < battleEvents.count else { return } + let newEvents = battleEvents[startIndex.. PlayedCardDestinationPile { + let definition = CardRegistry.require(cardId) + return definition.type == .consumable ? .exhaust : .discard + } } From bd0e2b5950c21c90d76fcc5172193c187abbb3f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:48:54 +0800 Subject: [PATCH 07/29] - --- ...02-09-saluavp-full-ui-animation-implementation-plan.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index 8d42150..f57bc82 100644 --- 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 @@ -126,7 +126,7 @@ ### P2:战斗动画闭环(抽牌/打牌/受击/死亡) -### Task 4: 抽牌动画(DrawPile -> Hand) +### ✅Task 4: 抽牌动画(DrawPile -> Hand) **Files:** - Modify: `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` - Modify: `SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift` @@ -141,7 +141,7 @@ **Step 3: 验证** - Manual: 连续抽牌与洗牌后抽牌,动画不丢帧、不重影。 -### Task 5: 出牌动画(Hand -> Enemy / Pile) +### ✅Task 5: 出牌动画(Hand -> Enemy / Pile) **Files:** - Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` - Modify: `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` @@ -156,7 +156,7 @@ **Step 3: 验证** - Manual: 普通卡与消耗性卡各打 3 次,路径正确。 -### Task 6: 受击、格挡、死亡反馈 +### ✅Task 6: 受击、格挡、死亡反馈 **Files:** - Create: `SaluNative/SaluAVP/Immersive/FloatingTextFactory.swift` - Modify: `SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift` @@ -174,7 +174,7 @@ **Step 3: 验证** - Manual: 触发高伤、被格挡、击杀三种情形。 -### Task 7: 回合切换与 HUD 动效 +### ✅Task 7: 回合切换与 HUD 动效 **Files:** - Modify: `SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift` - Modify: `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` From e078dac65c4e4940478da7ac6f475dae354605bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:53:22 +0800 Subject: [PATCH 08/29] =?UTF-8?q?P3=EF=BC=9A=E5=A4=9A=E6=95=8C=E4=BA=BA?= =?UTF-8?q?=E4=B8=8E=E7=9B=AE=E6=A0=87=E9=80=89=E6=8B=A9=EF=BC=88=E6=88=98?= =?UTF-8?q?=E6=96=97=E5=8F=AF=E7=8E=A9=E6=80=A7=E8=A1=A5=E9=BD=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...p-full-ui-animation-implementation-plan.md | 4 +- .../SaluAVP/Immersive/BattleHUDPanel.swift | 30 +++- .../Immersive/BattleSceneRenderer.swift | 80 +++++++--- .../SaluAVP/Immersive/ImmersiveRootView.swift | 8 + .../SaluAVP/ViewModels/RunSession.swift | 143 ++++++++++++++++-- 5 files changed, 232 insertions(+), 33 deletions(-) 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 index f57bc82..e530980 100644 --- 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 @@ -195,7 +195,7 @@ ### P3:多敌人与目标选择(战斗可玩性补齐) -### Task 8: RunSession 启用多敌人遭遇初始化 +### ✅Task 8: RunSession 启用多敌人遭遇初始化 **Files:** - Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` @@ -226,7 +226,7 @@ **Step 3: 验证** - Manual: 多敌人时攻击牌可定向命中,非目标牌不受影响。 -### Task 10: 目标选择边界处理与提示 +### ✅Task 10: 目标选择边界处理与提示 **Files:** - Modify: `SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift` - Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` diff --git a/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift b/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift index 7dd7934..dd85d71 100644 --- a/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift +++ b/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift @@ -55,9 +55,33 @@ struct BattleHUDPanel: View { 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) + + 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) diff --git a/SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift b/SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift index 6faf767..4aa07a1 100644 --- a/SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift +++ b/SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift @@ -9,6 +9,7 @@ final class BattleSceneRenderer { static let battleHeadAnchor = "battleHeadAnchor" static let battleHandRoot = "battleHandRoot" static let battleEnemyRoot = "battleEnemyRoot" + static let enemyNamePrefix = "enemy:" static let battleInspectRoot = "battleInspectRoot" static let battlePilesRoot = "battlePilesRoot" static let cardNamePrefix = "card:" @@ -66,6 +67,7 @@ final class BattleSceneRenderer { cardDisplayMode: CardDisplayMode, language: GameLanguage, peekedHandIndex: Int?, + selectedEnemyIndex: Int?, newEvents: [BattlePresentationEvent] ) { let enemyRoot = ensureEnemyRoot(in: battleLayer) @@ -81,12 +83,7 @@ final class BattleSceneRenderer { ) enemyRoot.children.forEach { $0.removeFromParent() } - - if let enemy = engine.state.enemies.first { - let enemyEntity = makeEnemyEntity(enemy: enemy) - enemyEntity.position = .zero - enemyRoot.addChild(enemyEntity) - } + renderEnemies(enemies: engine.state.enemies, selectedEnemyIndex: selectedEnemyIndex, in: enemyRoot) let hand = engine.state.hand let playable = Set(engine.playableCardIndices) @@ -172,12 +169,7 @@ final class BattleSceneRenderer { ) enemyRoot.children.forEach { $0.removeFromParent() } - - if let enemy = state.enemies.first { - let enemyEntity = makeEnemyEntity(enemy: enemy) - enemyEntity.position = .zero - enemyRoot.addChild(enemyEntity) - } + renderEnemies(enemies: state.enemies, selectedEnemyIndex: nil, in: enemyRoot) if let headAnchor = battleLayer.findEntity(named: Names.battleHeadAnchor) { headAnchor.findEntity(named: Names.battleHandRoot)? @@ -315,13 +307,67 @@ final class BattleSceneRenderer { pilesRoot.addChild(discard) } - private func makeEnemyEntity(enemy: GameCore.Entity) -> ModelEntity { - let material = SimpleMaterial(color: UIColor.systemRed.withAlphaComponent(0.85), isMetallic: true) + 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 + + for (visibleIndex, pair) in aliveEnemies.enumerated() { + let (stateIndex, enemy) = pair + let isSelected = (selectedEnemyIndex == stateIndex) + let enemyEntity = makeEnemyEntity(enemy: enemy, isSelected: isSelected) + enemyEntity.position = enemyPosition(at: visibleIndex, total: count) + enemyRoot.addChild(enemyEntity) + } + } + + 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 = "enemy:\(enemy.id)" - entity.components.set(CollisionComponent(shapes: [.generateSphere(radius: 0.14)])) - entity.components.set(InputTargetComponent()) + 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 } diff --git a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift index a29d23a..32c3ee7 100644 --- a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift +++ b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift @@ -48,6 +48,13 @@ struct ImmersiveRootView: View { runSession.selectAccessibleNode(nodeId) case .battle: + 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 } @@ -185,6 +192,7 @@ struct ImmersiveRootView: View { cardDisplayMode: appModel.cardDisplayMode, language: .zhHans, peekedHandIndex: peekedHandIndex, + selectedEnemyIndex: runSession.selectedEnemyIndex, newEvents: newEvents ) } else { diff --git a/SaluNative/SaluAVP/ViewModels/RunSession.swift b/SaluNative/SaluAVP/ViewModels/RunSession.swift index af56a2b..7201362 100644 --- a/SaluNative/SaluAVP/ViewModels/RunSession.swift +++ b/SaluNative/SaluAVP/ViewModels/RunSession.swift @@ -25,6 +25,14 @@ final class RunSession { private var battleRoomType: RoomType? private var lastConsumedBattleEventIndex: Int = 0 private var playedCardContextsBySequence: [Int: PlayedCardPresentationContext] = [:] + private(set) var selectedEnemyIndex: Int? + private(set) var battleTargetHint: String? + + 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 @@ -48,6 +56,8 @@ final class RunSession { battleRoomType = nil lastConsumedBattleEventIndex = 0 playedCardContextsBySequence = [:] + selectedEnemyIndex = nil + battleTargetHint = nil route = .map } @@ -94,10 +104,27 @@ final class RunSession { guard let battleEngine else { return } guard battleEngine.pendingInput == nil else { return } 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 eventStartIndex = battleEngine.events.count - _ = battleEngine.handleAction(.playCard(handIndex: handIndex, targetEnemyIndex: nil)) + let succeeded = battleEngine.handleAction( + .playCard( + handIndex: handIndex, + targetEnemyIndex: resolvedTargetEnemyIndex + ) + ) syncBattleStateFromEngine() capturePlayedCardContexts(startIndex: eventStartIndex, sourceHandIndex: handIndex) + if succeeded { + battleTargetHint = nil + } else { + captureInvalidActionHint(from: battleEngine.events, startIndex: eventStartIndex) + } finishBattleIfNeeded() } @@ -107,6 +134,7 @@ final class RunSession { guard battleEngine.pendingInput == nil else { return } _ = battleEngine.handleAction(.endTurn) syncBattleStateFromEngine() + battleTargetHint = nil finishBattleIfNeeded() } @@ -123,6 +151,29 @@ final class RunSession { 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() @@ -169,6 +220,8 @@ final class RunSession { battleRoomType = nil lastConsumedBattleEventIndex = 0 playedCardContextsBySequence = [:] + selectedEnemyIndex = nil + battleTargetHint = nil if runState.isOver { route = .runOver(lastNodeId: nodeId, won: runState.won, floor: runState.floor) @@ -213,6 +266,8 @@ final class RunSession { self.battleRoomType = nil self.lastConsumedBattleEventIndex = 0 self.playedCardContextsBySequence = [:] + self.selectedEnemyIndex = nil + self.battleTargetHint = nil route = .runOver(lastNodeId: nodeId, won: false, floor: runState.floor) } } @@ -223,7 +278,7 @@ final class RunSession { let battleSeed = SeedDerivation.battleSeed(runSeed: runState.seed, floor: runState.floor, nodeId: nodeId) var rng = SeededRNG(seed: battleSeed) - let enemyId: EnemyID + let enemyIds: [EnemyID] switch roomType { case .battle: let encounter: EnemyEncounter @@ -235,26 +290,26 @@ final class RunSession { default: encounter = Act3EncounterPool.randomWeak(rng: &rng) } - enemyId = encounter.enemyIds.first ?? "jaw_worm" + enemyIds = encounter.enemyIds case .elite: switch runState.floor { case 1: - enemyId = Act1EnemyPool.randomMedium(rng: &rng) + enemyIds = [Act1EnemyPool.randomMedium(rng: &rng)] case 2: - enemyId = Act2EnemyPool.randomMedium(rng: &rng) + enemyIds = [Act2EnemyPool.randomMedium(rng: &rng)] default: - enemyId = Act3EnemyPool.randomMedium(rng: &rng) + enemyIds = [Act3EnemyPool.randomMedium(rng: &rng)] } case .boss: switch runState.floor { case 1: - enemyId = "toxic_colossus" + enemyIds = ["toxic_colossus"] case 2: - enemyId = "cipher" + enemyIds = ["cipher"] default: - enemyId = "sequence_progenitor" + enemyIds = ["sequence_progenitor"] } default: @@ -262,10 +317,12 @@ final class RunSession { 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 @@ -279,6 +336,8 @@ final class RunSession { battleRoomType = roomType lastConsumedBattleEventIndex = 0 playedCardContextsBySequence = [:] + selectedEnemyIndex = enemies.count == 1 ? 0 : nil + battleTargetHint = nil route = .battle(nodeId: nodeId, roomType: roomType) } @@ -297,6 +356,8 @@ final class RunSession { battleRoomType = nil lastConsumedBattleEventIndex = 0 playedCardContextsBySequence = [:] + selectedEnemyIndex = nil + battleTargetHint = nil } private func consumeNewBattleEventSlice() -> ArraySlice { @@ -315,6 +376,7 @@ final class RunSession { playedCardContextsBySequence = [:] } playedCardContextsBySequence = playedCardContextsBySequence.filter { $0.key < battleEvents.count } + sanitizeSelectedEnemyIndex() } private func capturePlayedCardContexts(startIndex: Int, sourceHandIndex: Int) { @@ -334,4 +396,63 @@ final class RunSession { 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.. Date: Mon, 9 Feb 2026 21:17:02 +0800 Subject: [PATCH 09/29] =?UTF-8?q?feat:=20P4=E2=9C=85-Add=20Event,=20Rest,?= =?UTF-8?q?=20and=20Shop=20Room=20Panels=20with=20corresponding=20state=20?= =?UTF-8?q?management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implemented EventRoomPanel to handle event interactions and display options. - Created RestRoomPanel for resting and upgrading cards, including UI for player health and actions. - Developed ShopRoomPanel for purchasing cards, relics, and consumables, with inventory management. - Introduced EventRoomState and ShopRoomState to manage the state of event and shop rooms. - Enhanced RunSession to support new room types and their functionalities, including event handling and shop interactions. --- .../SaluAVP/Immersive/EventRoomPanel.swift | 157 +++++ .../SaluAVP/Immersive/ImmersiveRootView.swift | 131 ++-- .../SaluAVP/Immersive/RestRoomPanel.swift | 88 +++ .../SaluAVP/Immersive/ShopRoomPanel.swift | 139 ++++ .../SaluAVP/ViewModels/EventRoomState.swift | 27 + .../SaluAVP/ViewModels/RunSession.swift | 630 ++++++++++++++++-- .../SaluAVP/ViewModels/ShopRoomState.swift | 17 + 7 files changed, 1075 insertions(+), 114 deletions(-) create mode 100644 SaluNative/SaluAVP/Immersive/EventRoomPanel.swift create mode 100644 SaluNative/SaluAVP/Immersive/RestRoomPanel.swift create mode 100644 SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift create mode 100644 SaluNative/SaluAVP/ViewModels/EventRoomState.swift create mode 100644 SaluNative/SaluAVP/ViewModels/ShopRoomState.swift 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/ImmersiveRootView.swift b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift index 32c3ee7..30c0c74 100644 --- a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift +++ b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift @@ -277,18 +277,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) + 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, .cardReward: + EmptyView() + } } Attachment(id: battleHudAttachmentId) { @@ -497,60 +517,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) - - Text("Node: \(nodeId)") - .font(.caption) - .foregroundStyle(.secondary) - - Button("Complete") { - onCompleteRoom() - } - .buttonStyle(.borderedProminent) - } + VStack(alignment: .leading, spacing: 10) { + Text("\(roomType.icon) \(roomType.displayName(language: .zhHans))") + .font(.headline) - case .battle: - EmptyView() + Text("Node: \(nodeId)") + .font(.caption) + .foregroundStyle(.secondary) - case .cardReward: - EmptyView() + Button("继续") { + onCompleteRoom() + } + .buttonStyle(.borderedProminent) + } + .padding(12) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} - case .runOver(_, let won, let floor): - VStack(alignment: .leading, spacing: 10) { - Text(won ? "🎉 Victory" : "💀 Defeat") - .font(.headline) +private struct RunOverPanel: View { + let won: Bool + let floor: Int + let onNewRun: () -> Void + let onClose: () -> Void - Text("Run ended at Act \(floor)") - .font(.caption) - .foregroundStyle(.secondary) + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(won ? "Victory" : "Defeat") + .font(.headline) - HStack(spacing: 10) { - Button("New Run") { - onNewRun() - } - .buttonStyle(.borderedProminent) + Text("Run ended at Act \(floor)") + .font(.caption) + .foregroundStyle(.secondary) - Button("Close") { - onClose() - } - .buttonStyle(.bordered) - } + HStack(spacing: 10) { + Button("New Run") { + onNewRun() + } + .buttonStyle(.borderedProminent) + + Button("Close") { + onClose() } + .buttonStyle(.bordered) } } .padding(12) @@ -572,10 +588,3 @@ private struct CardRewardAttachment: View { } } } - -private extension RunSession.Route { - var isRoom: Bool { - if case .room = self { return true } - return false - } -} 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/ShopRoomPanel.swift b/SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift new file mode 100644 index 0000000..f101b9f --- /dev/null +++ b/SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift @@ -0,0 +1,139 @@ +import SwiftUI +import GameCore + +struct ShopRoomPanel: View { + @Environment(RunSession.self) private var runSession + + let nodeId: String + + var body: some View { + if let runState = runSession.runState, + let shopState = runSession.shopRoomState, + shopState.nodeId == nodeId { + ScrollView { + VStack(alignment: .leading, spacing: 10) { + Text("\(RoomType.shop.icon) 商店") + .font(.headline) + + Text("金币: \(runState.gold)") + .font(.caption) + .foregroundStyle(.secondary) + + shopSectionTitle("卡牌") + ForEach(Array(shopState.inventory.cardOffers.enumerated()), id: \.offset) { index, offer in + let cardName = CardRegistry.require(offer.cardId).name.resolved(for: .zhHans) + Button { + runSession.buyShopCard(at: index) + } label: { + HStack { + Text(cardName) + Spacer(minLength: 12) + priceLabel(price: offer.price, currentGold: runState.gold) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.bordered) + } + + shopSectionTitle("遗物") + if shopState.inventory.relicOffers.isEmpty { + Text("遗物已售空") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(Array(shopState.inventory.relicOffers.enumerated()), id: \.offset) { index, offer in + let def = RelicRegistry.require(offer.relicId) + Button { + runSession.buyShopRelic(at: index) + } label: { + HStack { + Text("\(def.icon) \(def.name.resolved(for: .zhHans))") + Spacer(minLength: 12) + priceLabel(price: offer.price, currentGold: runState.gold) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.bordered) + } + } + + shopSectionTitle("消耗性卡牌") + ForEach(Array(shopState.inventory.consumableOffers.enumerated()), id: \.offset) { index, offer in + let cardName = CardRegistry.require(offer.cardId).name.resolved(for: .zhHans) + Button { + runSession.buyShopConsumable(at: index) + } label: { + HStack { + Text(cardName) + Spacer(minLength: 12) + priceLabel(price: offer.price, currentGold: runState.gold) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.bordered) + } + + shopSectionTitle("删牌服务 \(shopState.inventory.removeCardPrice) 金币") + if runState.deck.isEmpty { + Text("牌组为空") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(Array(runState.deck.enumerated()), id: \.element.id) { deckIndex, card in + let cardName = CardRegistry.require(card.cardId).name.resolved(for: .zhHans) + Button { + runSession.removeCardInShop(deckIndex: deckIndex) + } label: { + HStack { + Text("删除:\(cardName)") + Spacer(minLength: 12) + priceLabel( + price: shopState.inventory.removeCardPrice, + currentGold: runState.gold + ) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.bordered) + } + } + + if let message = shopState.message, !message.isEmpty { + Text(message) + .font(.caption) + .foregroundStyle(message.contains("成功") ? .green : .red) + } + + Button("离开商店") { + runSession.leaveShopRoom() + } + .buttonStyle(.borderedProminent) + + Text("Node: \(nodeId)") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(12) + } + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .frame(width: 380, height: 460) + } else { + EmptyView() + } + } + + private func shopSectionTitle(_ text: String) -> some View { + Text(text) + .font(.subheadline) + .padding(.top, 4) + } + + @ViewBuilder + private func priceLabel(price: Int, currentGold: Int) -> some View { + let color: Color = currentGold >= price ? .secondary : .red + Text("\(price)") + .font(.caption) + .foregroundStyle(color) + } +} 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/RunSession.swift b/SaluNative/SaluAVP/ViewModels/RunSession.swift index 7201362..d7a7ba9 100644 --- a/SaluNative/SaluAVP/ViewModels/RunSession.swift +++ b/SaluNative/SaluAVP/ViewModels/RunSession.swift @@ -13,6 +13,18 @@ final class RunSession { 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? @@ -27,6 +39,11 @@ final class RunSession { 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 battleSource: BattleSource = .mapNode + private var eventBattleContext: EventBattleContext? var selectedEnemyDisplayName: String? { guard let battleState, let selectedEnemyIndex else { return nil } @@ -58,6 +75,11 @@ final class RunSession { playedCardContextsBySequence = [:] selectedEnemyIndex = nil battleTargetHint = nil + restRoomMessage = nil + shopRoomState = nil + eventRoomState = nil + battleSource = .mapNode + eventBattleContext = nil route = .map } @@ -74,8 +96,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) } } @@ -91,6 +115,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) @@ -99,6 +124,302 @@ 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 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 { + shopState.message = "无效的卡牌编号" + shopRoomState = shopState + return + } + + let offer = shopState.inventory.cardOffers[offerIndex] + guard runState.gold >= offer.price else { + shopState.message = "金币不足,无法购买该卡牌" + 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 + ) + shopState.message = "购买成功:\(CardRegistry.require(offer.cardId).name.resolved(for: .zhHans))" + 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 { + shopState.message = "无效的遗物编号" + shopRoomState = shopState + return + } + + let offer = shopState.inventory.relicOffers[offerIndex] + guard runState.gold >= offer.price else { + shopState.message = "金币不足,无法购买该遗物" + 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) + shopState.message = "购买成功:\(relicDef.icon) \(relicDef.name.resolved(for: .zhHans))" + 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 { + shopState.message = "无效的消耗性卡牌编号" + shopRoomState = shopState + return + } + + let offer = shopState.inventory.consumableOffers[offerIndex] + guard runState.gold >= offer.price else { + shopState.message = "金币不足,无法购买该消耗性卡牌" + shopRoomState = shopState + return + } + + guard runState.addConsumableCardToDeck(cardId: offer.cardId) else { + shopState.message = "消耗性卡牌槽位已满(最多 \(RunState.maxConsumableCardSlots))" + shopRoomState = shopState + return + } + + runState.gold -= offer.price + shopState.message = "购买成功:\(CardRegistry.require(offer.cardId).name.resolved(for: .zhHans))" + 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 { + shopState.message = "无效的卡牌编号" + shopRoomState = shopState + return + } + + let price = shopState.inventory.removeCardPrice + guard runState.gold >= price else { + shopState.message = "金币不足,无法删牌" + shopRoomState = shopState + return + } + + let removedCard = runState.deck[deckIndex] + runState.removeCardFromDeck(at: deckIndex) + runState.gold -= price + shopState.message = "删牌成功:\(CardRegistry.require(removedCard.cardId).name.resolved(for: .zhHans))" + 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 } @@ -214,14 +535,7 @@ final class RunSession { runState.completeCurrentNode() self.runState = runState - battleState = nil - battleEvents = [] - battleNodeId = nil - battleRoomType = nil - lastConsumedBattleEventIndex = 0 - playedCardContextsBySequence = [:] - selectedEnemyIndex = nil - battleTargetHint = nil + clearBattleState(preserveSnapshot: false) if runState.isOver { route = .runOver(lastNodeId: nodeId, won: runState.won, floor: runState.floor) @@ -238,14 +552,19 @@ final class RunSession { 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.battleEvents = battleEngine.events - 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, @@ -260,60 +579,68 @@ final class RunSession { let offer = RewardGenerator.generateCardReward(context: rewardContext) route = .cardReward(nodeId: nodeId, roomType: roomTypeForRewards, offer: offer, goldEarned: goldEarned) } else { - self.battleState = nil - self.battleEvents = [] - self.battleNodeId = nil - self.battleRoomType = nil - self.lastConsumedBattleEventIndex = 0 - self.playedCardContextsBySequence = [:] - self.selectedEnemyIndex = nil - self.battleTargetHint = 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 enemyIds: [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) - } - 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)] - } + 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: - enemyIds = ["toxic_colossus"] - case 2: - enemyIds = ["cipher"] default: - enemyIds = ["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 } @@ -338,6 +665,10 @@ final class RunSession { playedCardContextsBySequence = [:] selectedEnemyIndex = enemies.count == 1 ? 0 : nil battleTargetHint = nil + battleSource = source + if source == .mapNode { + eventBattleContext = nil + } route = .battle(nodeId: nodeId, roomType: roomType) } @@ -358,6 +689,11 @@ final class RunSession { playedCardContextsBySequence = [:] selectedEnemyIndex = nil battleTargetHint = nil + restRoomMessage = nil + shopRoomState = nil + eventRoomState = nil + battleSource = .mapNode + eventBattleContext = nil } private func consumeNewBattleEventSlice() -> ArraySlice { @@ -455,4 +791,192 @@ final class RunSession { return } } + + private func clearRoomState(clearEventBattleContext: Bool) { + restRoomMessage = nil + shopRoomState = nil + eventRoomState = nil + if clearEventBattleContext { + eventBattleContext = nil + } + } + + private func prepareRoomState(nodeId: String, roomType: RoomType, runState: RunState) { + restRoomMessage = nil + eventBattleContext = nil + + switch roomType { + case .rest: + shopRoomState = nil + eventRoomState = nil + + case .shop: + let context = ShopContext( + seed: runState.seed, + floor: runState.floor, + currentRow: runState.currentRow, + nodeId: nodeId, + ownedRelicIds: runState.relicManager.all + ) + shopRoomState = ShopRoomState( + nodeId: nodeId, + inventory: ShopInventory.generate(context: context) + ) + eventRoomState = nil + + case .event: + 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 + ) + eventRoomState = EventRoomState( + nodeId: nodeId, + offer: EventGenerator.generate(context: context) + ) + shopRoomState = nil + + default: + shopRoomState = nil + eventRoomState = nil + } + } + + private func ensureShopRoomState(nodeId: String, runState: RunState) -> 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 clearBattleState(preserveSnapshot: Bool) { + battleEngine = nil + if !preserveSnapshot { + battleState = nil + battleEvents = [] + } + battleNodeId = nil + battleRoomType = nil + lastConsumedBattleEventIndex = 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..511726d --- /dev/null +++ b/SaluNative/SaluAVP/ViewModels/ShopRoomState.swift @@ -0,0 +1,17 @@ +import GameCore + +struct ShopRoomState: Sendable, Equatable { + let nodeId: String + var inventory: ShopInventory + var message: String? + + init( + nodeId: String, + inventory: ShopInventory, + message: String? = nil + ) { + self.nodeId = nodeId + self.inventory = inventory + self.message = message + } +} From 5d9b2c2c68afcf7bf7efd4850cf43f7052fc04b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:17:23 +0800 Subject: [PATCH 10/29] =?UTF-8?q?=E5=AE=8C=E6=88=90P4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-02-09-saluavp-full-ui-animation-implementation-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index e530980..3aca637 100644 --- 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 @@ -244,7 +244,7 @@ --- -### P4:房间 UI 闭环(Rest / Shop / Event) +### ✅P4:房间 UI 闭环(Rest / Shop / Event) ### Task 11: Rest 房间交互(休息/升级/对话) **Files:** From b39203be7703e8c67d3bb2c14d97118ab917722f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:27:19 +0800 Subject: [PATCH 11/29] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=88=BF?= =?UTF-8?q?=E9=97=B4=E5=9C=BA=E6=99=AF=E6=B8=B2=E6=9F=93=E5=99=A8=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=88=BF=E9=97=B4=E5=B1=82=E7=9A=84=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E5=92=8C=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SaluAVP/Immersive/ImmersiveRootView.swift | 81 +++--- .../SaluAVP/Immersive/RoomSceneRenderer.swift | 247 ++++++++++++++++++ 2 files changed, 293 insertions(+), 35 deletions(-) create mode 100644 SaluNative/SaluAVP/Immersive/RoomSceneRenderer.swift diff --git a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift index 30c0c74..dc21ad1 100644 --- a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift +++ b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift @@ -10,6 +10,7 @@ struct ImmersiveRootView: View { @Environment(\.openWindow) private var openWindow @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 @@ -118,6 +119,9 @@ struct ImmersiveRootView: View { hudAnchor.name = hudAnchorName mapRoot.addChild(hudAnchor) + let roomLayer = roomSceneRenderer.makeRoomLayer() + mapRoot.addChild(roomLayer) + let battleLayer = battleSceneRenderer.makeBattleLayer() mapRoot.addChild(battleLayer) content.add(mapRoot) @@ -142,6 +146,7 @@ struct ImmersiveRootView: View { guard let run = runSession.runState else { mapRoot.children.first(where: { $0.name.hasPrefix(mapLayerPrefix) })?.removeFromParent() + mapRoot.findEntity(named: RoomSceneRenderer.Names.roomLayer)?.isEnabled = false mapRoot.findEntity(named: BattleSceneRenderer.Names.battleLayer)?.isEnabled = false return } @@ -170,6 +175,12 @@ struct ImmersiveRootView: View { 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: @@ -179,11 +190,24 @@ struct ImmersiveRootView: View { } }() - 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, in: roomLayer) + battleSceneRenderer.clear(in: battleLayer) + case .battle: + roomSceneRenderer.clear(in: roomLayer) if let engine = runSession.battleEngine { let newEvents = runSession.consumeNewBattlePresentationEvents() battleSceneRenderer.render( @@ -200,6 +224,7 @@ struct ImmersiveRootView: View { } case .cardReward: + roomSceneRenderer.clear(in: roomLayer) if let state = runSession.battleState { let newEvents = runSession.consumeNewBattlePresentationEvents() battleSceneRenderer.renderReward(state: state, in: battleLayer, newEvents: newEvents) @@ -207,7 +232,8 @@ struct ImmersiveRootView: View { battleSceneRenderer.clear(in: battleLayer) } - case .map, .room, .runOver: + case .map, .runOver: + roomSceneRenderer.clear(in: roomLayer) battleSceneRenderer.clear(in: battleLayer) } @@ -215,10 +241,23 @@ struct ImmersiveRootView: View { 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) + uiLayer.children.first(where: { $0.name == roomPanelAttachmentId })?.removeFromParent() + roomLayer.children.first(where: { $0.name == roomPanelAttachmentId })?.removeFromParent() + + switch runSession.route { + case .room(_, let roomType): + 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, .cardReward: + panel.isEnabled = false + } } if let hud = attachments.entity(for: battleHudAttachmentId) { @@ -241,7 +280,7 @@ struct ImmersiveRootView: View { hud.name = mapHudAttachmentId hud.components.set(BillboardComponent()) hud.components.set(InputTargetComponent()) - hud.isEnabled = !isInBattle + hud.isEnabled = !isInBattle && !isInRoom hudAnchor.children.first(where: { $0.name == mapHudAttachmentId })?.removeFromParent() hud.position = [0.18, 0.17, -0.50] @@ -343,34 +382,6 @@ struct ImmersiveRootView: View { } } - private func roomPanelPlacement(mapRoot: RealityKit.Entity, route: RunSession.Route) -> (isVisible: Bool, position: SIMD3) { - switch route { - case .map: - return (false, .zero) - - 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]) - } - - // 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]) - } - } - private func addFloor(to root: RealityKit.Entity) { let floor = RealityKit.Entity() floor.name = "floor" diff --git a/SaluNative/SaluAVP/Immersive/RoomSceneRenderer.swift b/SaluNative/SaluAVP/Immersive/RoomSceneRenderer.swift new file mode 100644 index 0000000..74f4ca4 --- /dev/null +++ b/SaluNative/SaluAVP/Immersive/RoomSceneRenderer.swift @@ -0,0 +1,247 @@ +import GameCore +import RealityKit +import UIKit + +struct RoomSceneRenderer { + enum Names { + static let roomLayer = "roomLayer" + static let roomRoot = "roomRoot" + static let npcPrefix = "roomNpc:" + } + + private struct RenderKey: Equatable { + let nodeId: String + let roomType: RoomType + } + + private var renderKey: RenderKey? + + mutating func makeRoomLayer() -> RealityKit.Entity { + let layer = RealityKit.Entity() + layer.name = Names.roomLayer + return layer + } + + mutating func render(nodeId: String, roomType: RoomType, in layer: RealityKit.Entity) { + let key = RenderKey(nodeId: nodeId, roomType: roomType) + guard renderKey != key else { return } + rebuildScene(nodeId: nodeId, roomType: roomType, in: layer) + renderKey = key + } + + mutating func clear(in layer: RealityKit.Entity) { + guard renderKey != nil || !layer.children.isEmpty else { return } + layer.children.forEach { $0.removeFromParent() } + renderKey = nil + } + + func panelPosition(for roomType: RoomType) -> SIMD3 { + switch roomType { + case .rest: + return [0.34, 0.14, -0.62] + case .shop: + return [0.38, 0.14, -0.62] + case .event: + return [0.34, 0.14, -0.62] + default: + return [0.32, 0.14, -0.62] + } + } + + private mutating func rebuildScene(nodeId: String, roomType: RoomType, 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, in: root) + case .event: + buildEventScene(nodeId: nodeId, in: root) + default: + buildGenericScene(nodeId: nodeId, roomType: roomType, in: root) + } + } + + 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, in root: RealityKit.Entity) { + _ = nodeId + + let counter = ModelEntity( + mesh: .generateBox(size: [0.9, 0.16, 0.26]), + materials: [SimpleMaterial(color: UIColor.systemBrown, isMetallic: false)] + ) + counter.position = [0, 0.08, -0.82] + root.addChild(counter) + + let stallTop = ModelEntity( + mesh: .generateBox(size: [1.0, 0.02, 0.28]), + materials: [SimpleMaterial(color: UIColor.systemYellow.withAlphaComponent(0.65), isMetallic: false)] + ) + stallTop.position = [0, 0.44, -0.82] + root.addChild(stallTop) + + let merchant = makeNPC( + name: "\(Names.npcPrefix)merchant", + color: UIColor.systemTeal, + accessoryColor: UIColor.systemOrange.withAlphaComponent(0.7) + ) + merchant.position = [0, 0, -0.97] + root.addChild(merchant) + + let relicPedestalLeft = ModelEntity( + mesh: .generateCylinder(height: 0.18, radius: 0.05), + materials: [SimpleMaterial(color: UIColor.systemGray2, isMetallic: true)] + ) + relicPedestalLeft.position = [-0.26, 0.09, -0.68] + root.addChild(relicPedestalLeft) + + let relicPedestalRight = ModelEntity( + mesh: .generateCylinder(height: 0.18, radius: 0.05), + materials: [SimpleMaterial(color: UIColor.systemGray2, isMetallic: true)] + ) + relicPedestalRight.position = [0.26, 0.09, -0.68] + root.addChild(relicPedestalRight) + } + + 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 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 + } +} From 6a6ed51566e9560d11f5572e452022b49298b3a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:32:43 +0800 Subject: [PATCH 12/29] =?UTF-8?q?feat:=20=E5=95=86=E5=BA=97=E8=B4=AD?= =?UTF-8?q?=E4=B9=B0=E8=A1=8C=E4=B8=BA=E7=8E=B0=E5=9C=A8=E8=B5=B0=203D=20?= =?UTF-8?q?=E5=B0=8F=E6=91=8A=E4=BA=A4=E4=BA=92=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=202D=20=E5=95=86=E5=BA=97=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SaluAVP/Immersive/ImmersiveRootView.swift | 58 ++++- .../SaluAVP/Immersive/RoomSceneRenderer.swift | 225 ++++++++++++++++-- .../SaluAVP/Immersive/ShopRoomPanel.swift | 139 ----------- 3 files changed, 252 insertions(+), 170 deletions(-) delete mode 100644 SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift diff --git a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift index dc21ad1..19b0d7c 100644 --- a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift +++ b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift @@ -61,7 +61,12 @@ struct ImmersiveRootView: View { 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 .cardReward, .runOver: break } } @@ -203,7 +208,13 @@ struct ImmersiveRootView: View { switch runSession.route { case .room(let nodeId, let roomType): - roomSceneRenderer.render(nodeId: nodeId, roomType: roomType, in: roomLayer) + roomSceneRenderer.render( + nodeId: nodeId, + roomType: roomType, + shopState: runSession.shopRoomState, + runState: runSession.runState, + in: roomLayer + ) battleSceneRenderer.clear(in: battleLayer) case .battle: @@ -237,18 +248,23 @@ struct ImmersiveRootView: View { battleSceneRenderer.clear(in: battleLayer) } + uiLayer.children.first(where: { $0.name == roomPanelAttachmentId })?.removeFromParent() + roomLayer.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()) - uiLayer.children.first(where: { $0.name == roomPanelAttachmentId })?.removeFromParent() - roomLayer.children.first(where: { $0.name == roomPanelAttachmentId })?.removeFromParent() switch runSession.route { case .room(_, let roomType): - panel.isEnabled = true - panel.position = roomSceneRenderer.panelPosition(for: roomType) - roomLayer.addChild(panel) + if roomType == .shop { + panel.isEnabled = false + } else { + panel.isEnabled = true + panel.position = roomSceneRenderer.panelPosition(for: roomType) + roomLayer.addChild(panel) + } case .runOver: panel.isEnabled = true @@ -322,7 +338,7 @@ struct ImmersiveRootView: View { case .rest: RestRoomPanel(nodeId: nodeId) case .shop: - ShopRoomPanel(nodeId: nodeId) + EmptyView() case .event: EventRoomPanel(nodeId: nodeId) default: @@ -382,6 +398,32 @@ struct ImmersiveRootView: View { } } + private func handleShopSceneTap(named entityName: String) { + guard entityName.hasPrefix(RoomSceneRenderer.Names.shopActionPrefix) else { return } + + let suffix = String(entityName.dropFirst(RoomSceneRenderer.Names.shopActionPrefix.count)) + if suffix == "leave" { + runSession.leaveShopRoom() + return + } + + let parts = suffix.split(separator: ":", omittingEmptySubsequences: true) + guard parts.count == 2, let index = Int(parts[1]) else { return } + + switch parts[0] { + case "card": + runSession.buyShopCard(at: index) + case "relic": + runSession.buyShopRelic(at: index) + case "consumable": + runSession.buyShopConsumable(at: index) + case "remove": + runSession.removeCardInShop(deckIndex: index) + default: + break + } + } + private func addFloor(to root: RealityKit.Entity) { let floor = RealityKit.Entity() floor.name = "floor" diff --git a/SaluNative/SaluAVP/Immersive/RoomSceneRenderer.swift b/SaluNative/SaluAVP/Immersive/RoomSceneRenderer.swift index 74f4ca4..c162116 100644 --- a/SaluNative/SaluAVP/Immersive/RoomSceneRenderer.swift +++ b/SaluNative/SaluAVP/Immersive/RoomSceneRenderer.swift @@ -7,11 +7,29 @@ struct RoomSceneRenderer { 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 var renderKey: RenderKey? @@ -22,10 +40,23 @@ struct RoomSceneRenderer { return layer } - mutating func render(nodeId: String, roomType: RoomType, in layer: RealityKit.Entity) { - let key = RenderKey(nodeId: nodeId, roomType: roomType) + mutating 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) guard renderKey != key else { return } - rebuildScene(nodeId: nodeId, roomType: roomType, in: layer) + rebuildScene( + nodeId: nodeId, + roomType: roomType, + shopState: shopState, + runState: runState, + in: layer + ) renderKey = key } @@ -39,8 +70,6 @@ struct RoomSceneRenderer { switch roomType { case .rest: return [0.34, 0.14, -0.62] - case .shop: - return [0.38, 0.14, -0.62] case .event: return [0.34, 0.14, -0.62] default: @@ -48,7 +77,29 @@ struct RoomSceneRenderer { } } - private mutating func rebuildScene(nodeId: String, roomType: RoomType, in layer: RealityKit.Entity) { + 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 mutating func rebuildScene( + nodeId: String, + roomType: RoomType, + shopState: ShopRoomState?, + runState: RunState?, + in layer: RealityKit.Entity + ) { layer.children.forEach { $0.removeFromParent() } let root = RealityKit.Entity() @@ -61,7 +112,7 @@ struct RoomSceneRenderer { case .rest: buildRestScene(nodeId: nodeId, in: root) case .shop: - buildShopScene(nodeId: nodeId, in: root) + buildShopScene(nodeId: nodeId, shopState: shopState, runState: runState, in: root) case .event: buildEventScene(nodeId: nodeId, in: root) default: @@ -132,18 +183,26 @@ struct RoomSceneRenderer { root.addChild(seat) } - private func buildShopScene(nodeId: String, in root: RealityKit.Entity) { - _ = nodeId + 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 counter = ModelEntity( - mesh: .generateBox(size: [0.9, 0.16, 0.26]), + mesh: .generateBox(size: [1.0, 0.16, 0.28]), materials: [SimpleMaterial(color: UIColor.systemBrown, isMetallic: false)] ) counter.position = [0, 0.08, -0.82] root.addChild(counter) let stallTop = ModelEntity( - mesh: .generateBox(size: [1.0, 0.02, 0.28]), + mesh: .generateBox(size: [1.05, 0.02, 0.30]), materials: [SimpleMaterial(color: UIColor.systemYellow.withAlphaComponent(0.65), isMetallic: false)] ) stallTop.position = [0, 0.44, -0.82] @@ -154,22 +213,116 @@ struct RoomSceneRenderer { color: UIColor.systemTeal, accessoryColor: UIColor.systemOrange.withAlphaComponent(0.7) ) - merchant.position = [0, 0, -0.97] + merchant.position = [0, 0, -0.98] root.addChild(merchant) - let relicPedestalLeft = ModelEntity( - mesh: .generateCylinder(height: 0.18, radius: 0.05), - materials: [SimpleMaterial(color: UIColor.systemGray2, isMetallic: true)] - ) - relicPedestalLeft.position = [-0.26, 0.09, -0.68] - root.addChild(relicPedestalLeft) + 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) + } - let relicPedestalRight = ModelEntity( - mesh: .generateCylinder(height: 0.18, radius: 0.05), - materials: [SimpleMaterial(color: UIColor.systemGray2, isMetallic: true)] + 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 entity = makeShopActionEntity( + mesh: .generateBox(size: [0.09, 0.11, 0.05]), + color: actionColor(base: UIColor.systemBlue, affordable: affordable), + position: [x, 0.18, -0.70], + name: Names.shopActionName("card", index: index), + collisionSize: [0.12, 0.14, 0.10] + ) + root.addChild(entity) + } + } + + 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 entity = makeShopActionEntity( + mesh: .generateSphere(radius: 0.055), + color: actionColor(base: UIColor.systemPurple, affordable: affordable), + position: [x, 0.22, -0.58], + name: Names.shopActionName("relic", index: index), + collisionSize: [0.14, 0.14, 0.14] + ) + root.addChild(entity) + } + } + + 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 entity = makeShopActionEntity( + mesh: .generateCylinder(height: 0.12, radius: 0.045), + color: actionColor(base: UIColor.systemGreen, affordable: affordable), + position: [x, 0.20, -0.72], + name: Names.shopActionName("consumable", index: index), + collisionSize: [0.12, 0.15, 0.12] + ) + root.addChild(entity) + } + } + + private func renderShopRemoveCardOffers( + shopState: ShopRoomState, + runState: RunState, + in root: RealityKit.Entity + ) { + let isAffordable = runState.gold >= shopState.inventory.removeCardPrice + let maxCount = min(runState.deck.count, Self.maxRemoveCardDisplayCount) + for deckIndex in 0.. 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 leave = makeShopActionEntity( + mesh: .generateCone(height: 0.14, radius: 0.06), + color: UIColor.systemGray, + position: [0.44, 0.09, -0.58], + name: Names.shopActionName("leave"), + collisionSize: [0.14, 0.18, 0.14], + metallic: false ) - relicPedestalRight.position = [0.26, 0.09, -0.68] - root.addChild(relicPedestalRight) + root.addChild(leave) } private func buildEventScene(nodeId: String, in root: RealityKit.Entity) { @@ -215,6 +368,30 @@ struct RoomSceneRenderer { root.addChild(plate) } + private func actionColor(base: UIColor, affordable: Bool) -> UIColor { + if affordable { return base } + return UIColor.systemRed.withAlphaComponent(0.85) + } + + 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 @@ -244,4 +421,6 @@ struct RoomSceneRenderer { return npc } + + private static let maxRemoveCardDisplayCount = 12 } diff --git a/SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift b/SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift deleted file mode 100644 index f101b9f..0000000 --- a/SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift +++ /dev/null @@ -1,139 +0,0 @@ -import SwiftUI -import GameCore - -struct ShopRoomPanel: View { - @Environment(RunSession.self) private var runSession - - let nodeId: String - - var body: some View { - if let runState = runSession.runState, - let shopState = runSession.shopRoomState, - shopState.nodeId == nodeId { - ScrollView { - VStack(alignment: .leading, spacing: 10) { - Text("\(RoomType.shop.icon) 商店") - .font(.headline) - - Text("金币: \(runState.gold)") - .font(.caption) - .foregroundStyle(.secondary) - - shopSectionTitle("卡牌") - ForEach(Array(shopState.inventory.cardOffers.enumerated()), id: \.offset) { index, offer in - let cardName = CardRegistry.require(offer.cardId).name.resolved(for: .zhHans) - Button { - runSession.buyShopCard(at: index) - } label: { - HStack { - Text(cardName) - Spacer(minLength: 12) - priceLabel(price: offer.price, currentGold: runState.gold) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.bordered) - } - - shopSectionTitle("遗物") - if shopState.inventory.relicOffers.isEmpty { - Text("遗物已售空") - .font(.caption) - .foregroundStyle(.secondary) - } else { - ForEach(Array(shopState.inventory.relicOffers.enumerated()), id: \.offset) { index, offer in - let def = RelicRegistry.require(offer.relicId) - Button { - runSession.buyShopRelic(at: index) - } label: { - HStack { - Text("\(def.icon) \(def.name.resolved(for: .zhHans))") - Spacer(minLength: 12) - priceLabel(price: offer.price, currentGold: runState.gold) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.bordered) - } - } - - shopSectionTitle("消耗性卡牌") - ForEach(Array(shopState.inventory.consumableOffers.enumerated()), id: \.offset) { index, offer in - let cardName = CardRegistry.require(offer.cardId).name.resolved(for: .zhHans) - Button { - runSession.buyShopConsumable(at: index) - } label: { - HStack { - Text(cardName) - Spacer(minLength: 12) - priceLabel(price: offer.price, currentGold: runState.gold) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.bordered) - } - - shopSectionTitle("删牌服务 \(shopState.inventory.removeCardPrice) 金币") - if runState.deck.isEmpty { - Text("牌组为空") - .font(.caption) - .foregroundStyle(.secondary) - } else { - ForEach(Array(runState.deck.enumerated()), id: \.element.id) { deckIndex, card in - let cardName = CardRegistry.require(card.cardId).name.resolved(for: .zhHans) - Button { - runSession.removeCardInShop(deckIndex: deckIndex) - } label: { - HStack { - Text("删除:\(cardName)") - Spacer(minLength: 12) - priceLabel( - price: shopState.inventory.removeCardPrice, - currentGold: runState.gold - ) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.bordered) - } - } - - if let message = shopState.message, !message.isEmpty { - Text(message) - .font(.caption) - .foregroundStyle(message.contains("成功") ? .green : .red) - } - - Button("离开商店") { - runSession.leaveShopRoom() - } - .buttonStyle(.borderedProminent) - - Text("Node: \(nodeId)") - .font(.caption2) - .foregroundStyle(.secondary) - } - .padding(12) - } - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .frame(width: 380, height: 460) - } else { - EmptyView() - } - } - - private func shopSectionTitle(_ text: String) -> some View { - Text(text) - .font(.subheadline) - .padding(.top, 4) - } - - @ViewBuilder - private func priceLabel(price: Int, currentGold: Int) -> some View { - let color: Color = currentGold >= price ? .secondary : .red - Text("\(price)") - .font(.caption) - .foregroundStyle(color) - } -} From 13603b0e7d2d242f247d76ced0ccbfe9c64b8bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:21:32 +0800 Subject: [PATCH 13/29] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=95=86?= =?UTF-8?q?=E5=BA=97=E5=8F=8D=E9=A6=88=E6=9C=BA=E5=88=B6=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8A=A8=E6=80=81=E6=B6=88=E6=81=AF=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=92=8C=E9=87=91=E5=B8=81=E4=BD=99=E9=A2=9D=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SaluAVP/Immersive/ImmersiveRootView.swift | 58 +++++++++++++ .../SaluAVP/Immersive/RoomSceneRenderer.swift | 83 +++++++++++++++++-- .../SaluAVP/ViewModels/RunSession.swift | 34 +++++--- .../SaluAVP/ViewModels/ShopRoomState.swift | 5 +- 4 files changed, 161 insertions(+), 19 deletions(-) diff --git a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift index 19b0d7c..6d3e63f 100644 --- a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift +++ b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift @@ -15,6 +15,8 @@ struct ImmersiveRootView: View { @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 private let nodeNamePrefix = "node:" private let roomPanelAttachmentId = "roomPanel" @@ -215,10 +217,16 @@ struct ImmersiveRootView: View { runState: runSession.runState, in: roomLayer ) + if roomType == .shop { + updateShopFeedback(in: roomLayer) + } else { + clearShopFeedback(in: roomLayer) + } battleSceneRenderer.clear(in: battleLayer) case .battle: roomSceneRenderer.clear(in: roomLayer) + clearShopFeedback(in: roomLayer) if let engine = runSession.battleEngine { let newEvents = runSession.consumeNewBattlePresentationEvents() battleSceneRenderer.render( @@ -236,6 +244,7 @@ struct ImmersiveRootView: View { case .cardReward: roomSceneRenderer.clear(in: roomLayer) + clearShopFeedback(in: roomLayer) if let state = runSession.battleState { let newEvents = runSession.consumeNewBattlePresentationEvents() battleSceneRenderer.renderReward(state: state, in: battleLayer, newEvents: newEvents) @@ -245,6 +254,7 @@ struct ImmersiveRootView: View { case .map, .runOver: roomSceneRenderer.clear(in: roomLayer) + clearShopFeedback(in: roomLayer) battleSceneRenderer.clear(in: battleLayer) } @@ -424,6 +434,54 @@ struct ImmersiveRootView: View { } } + 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 } + + shownShopMessageSequence = shopState.messageSequence + roomLayer.children + .filter { $0.name.hasPrefix("shopFeedback:") } + .forEach { $0.removeFromParent() } + + 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() + } + } + + private func clearShopFeedback(in roomLayer: RealityKit.Entity) { + roomLayer.children + .filter { $0.name.hasPrefix("shopFeedback:") } + .forEach { $0.removeFromParent() } + shopFeedbackTask?.cancel() + shopFeedbackTask = nil + } + + private func shopFeedbackStyle(for message: String) -> FloatingTextFactory.Style { + if message.contains("不足") + || message.contains("无效") + || message.contains("失败") + || message.contains("已满") { + return .damage + } + return .block + } + private func addFloor(to root: RealityKit.Entity) { let floor = RealityKit.Entity() floor.name = "floor" diff --git a/SaluNative/SaluAVP/Immersive/RoomSceneRenderer.swift b/SaluNative/SaluAVP/Immersive/RoomSceneRenderer.swift index c162116..38124d4 100644 --- a/SaluNative/SaluAVP/Immersive/RoomSceneRenderer.swift +++ b/SaluNative/SaluAVP/Immersive/RoomSceneRenderer.swift @@ -2,6 +2,7 @@ import GameCore import RealityKit import UIKit +@MainActor struct RoomSceneRenderer { enum Names { static let roomLayer = "roomLayer" @@ -216,6 +217,7 @@ struct RoomSceneRenderer { 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) @@ -223,6 +225,22 @@ struct RoomSceneRenderer { 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, @@ -231,14 +249,21 @@ struct RoomSceneRenderer { 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: [x, 0.18, -0.70], + 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) + } } } @@ -250,14 +275,21 @@ struct RoomSceneRenderer { 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: [x, 0.22, -0.58], + 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) + } } } @@ -269,14 +301,21 @@ struct RoomSceneRenderer { 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: [x, 0.20, -0.72], + 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) + } } } @@ -286,21 +325,29 @@ struct RoomSceneRenderer { 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: [x, 0.10, z], + 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 { @@ -314,15 +361,21 @@ struct RoomSceneRenderer { } 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: [0.44, 0.09, -0.58], + 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) { @@ -373,6 +426,26 @@ struct RoomSceneRenderer { 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, diff --git a/SaluNative/SaluAVP/ViewModels/RunSession.swift b/SaluNative/SaluAVP/ViewModels/RunSession.swift index d7a7ba9..71269b8 100644 --- a/SaluNative/SaluAVP/ViewModels/RunSession.swift +++ b/SaluNative/SaluAVP/ViewModels/RunSession.swift @@ -42,6 +42,7 @@ final class RunSession { 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? @@ -78,6 +79,7 @@ final class RunSession { restRoomMessage = nil shopRoomState = nil eventRoomState = nil + shopMessageSequence = 0 battleSource = .mapNode eventBattleContext = nil route = .map @@ -172,14 +174,14 @@ final class RunSession { var shopState = ensureShopRoomState(nodeId: nodeId, runState: runState) guard shopState.inventory.cardOffers.indices.contains(offerIndex) else { - shopState.message = "无效的卡牌编号" + setShopMessage("无效的卡牌编号", in: &shopState) shopRoomState = shopState return } let offer = shopState.inventory.cardOffers[offerIndex] guard runState.gold >= offer.price else { - shopState.message = "金币不足,无法购买该卡牌" + setShopMessage("金币不足,无法购买该卡牌", in: &shopState) shopRoomState = shopState return } @@ -194,7 +196,7 @@ final class RunSession { consumableOffers: shopState.inventory.consumableOffers, removeCardPrice: shopState.inventory.removeCardPrice ) - shopState.message = "购买成功:\(CardRegistry.require(offer.cardId).name.resolved(for: .zhHans))" + setShopMessage("购买成功:\(CardRegistry.require(offer.cardId).name.resolved(for: .zhHans))", in: &shopState) self.runState = runState shopRoomState = shopState } @@ -205,14 +207,14 @@ final class RunSession { var shopState = ensureShopRoomState(nodeId: nodeId, runState: runState) guard shopState.inventory.relicOffers.indices.contains(offerIndex) else { - shopState.message = "无效的遗物编号" + setShopMessage("无效的遗物编号", in: &shopState) shopRoomState = shopState return } let offer = shopState.inventory.relicOffers[offerIndex] guard runState.gold >= offer.price else { - shopState.message = "金币不足,无法购买该遗物" + setShopMessage("金币不足,无法购买该遗物", in: &shopState) shopRoomState = shopState return } @@ -228,7 +230,7 @@ final class RunSession { removeCardPrice: shopState.inventory.removeCardPrice ) let relicDef = RelicRegistry.require(offer.relicId) - shopState.message = "购买成功:\(relicDef.icon) \(relicDef.name.resolved(for: .zhHans))" + setShopMessage("购买成功:\(relicDef.icon) \(relicDef.name.resolved(for: .zhHans))", in: &shopState) self.runState = runState shopRoomState = shopState } @@ -239,26 +241,26 @@ final class RunSession { var shopState = ensureShopRoomState(nodeId: nodeId, runState: runState) guard shopState.inventory.consumableOffers.indices.contains(offerIndex) else { - shopState.message = "无效的消耗性卡牌编号" + setShopMessage("无效的消耗性卡牌编号", in: &shopState) shopRoomState = shopState return } let offer = shopState.inventory.consumableOffers[offerIndex] guard runState.gold >= offer.price else { - shopState.message = "金币不足,无法购买该消耗性卡牌" + setShopMessage("金币不足,无法购买该消耗性卡牌", in: &shopState) shopRoomState = shopState return } guard runState.addConsumableCardToDeck(cardId: offer.cardId) else { - shopState.message = "消耗性卡牌槽位已满(最多 \(RunState.maxConsumableCardSlots))" + setShopMessage("消耗性卡牌槽位已满(最多 \(RunState.maxConsumableCardSlots))", in: &shopState) shopRoomState = shopState return } runState.gold -= offer.price - shopState.message = "购买成功:\(CardRegistry.require(offer.cardId).name.resolved(for: .zhHans))" + setShopMessage("购买成功:\(CardRegistry.require(offer.cardId).name.resolved(for: .zhHans))", in: &shopState) self.runState = runState shopRoomState = shopState } @@ -269,14 +271,14 @@ final class RunSession { var shopState = ensureShopRoomState(nodeId: nodeId, runState: runState) guard runState.deck.indices.contains(deckIndex) else { - shopState.message = "无效的卡牌编号" + setShopMessage("无效的卡牌编号", in: &shopState) shopRoomState = shopState return } let price = shopState.inventory.removeCardPrice guard runState.gold >= price else { - shopState.message = "金币不足,无法删牌" + setShopMessage("金币不足,无法删牌", in: &shopState) shopRoomState = shopState return } @@ -284,7 +286,7 @@ final class RunSession { let removedCard = runState.deck[deckIndex] runState.removeCardFromDeck(at: deckIndex) runState.gold -= price - shopState.message = "删牌成功:\(CardRegistry.require(removedCard.cardId).name.resolved(for: .zhHans))" + setShopMessage("删牌成功:\(CardRegistry.require(removedCard.cardId).name.resolved(for: .zhHans))", in: &shopState) self.runState = runState shopRoomState = shopState } @@ -887,6 +889,12 @@ final class RunSession { 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 { diff --git a/SaluNative/SaluAVP/ViewModels/ShopRoomState.swift b/SaluNative/SaluAVP/ViewModels/ShopRoomState.swift index 511726d..0b7f6c0 100644 --- a/SaluNative/SaluAVP/ViewModels/ShopRoomState.swift +++ b/SaluNative/SaluAVP/ViewModels/ShopRoomState.swift @@ -4,14 +4,17 @@ struct ShopRoomState: Sendable, Equatable { let nodeId: String var inventory: ShopInventory var message: String? + var messageSequence: UInt64 init( nodeId: String, inventory: ShopInventory, - message: String? = nil + message: String? = nil, + messageSequence: UInt64 = 0 ) { self.nodeId = nodeId self.inventory = inventory self.message = message + self.messageSequence = messageSequence } } From fe2e3d09d6d1a9d13e2933aef7e0a7e4f2942b0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:03:12 +0800 Subject: [PATCH 14/29] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=95=86?= =?UTF-8?q?=E5=BA=97=E9=9D=A2=E6=9D=BF=EF=BC=8C=E6=94=AF=E6=8C=81=E5=95=86?= =?UTF-8?q?=E5=93=81=E9=80=89=E6=8B=A9=E5=92=8C=E8=B4=AD=E4=B9=B0=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SaluAVP/Immersive/ImmersiveRootView.swift | 62 ++++++- .../SaluAVP/Immersive/ShopRoomPanel.swift | 171 ++++++++++++++++++ 2 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift diff --git a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift index 6d3e63f..03e1444 100644 --- a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift +++ b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift @@ -17,6 +17,7 @@ struct ImmersiveRootView: View { @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 roomPanelAttachmentId = "roomPanel" @@ -218,14 +219,17 @@ struct ImmersiveRootView: View { 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 { let newEvents = runSession.consumeNewBattlePresentationEvents() @@ -244,6 +248,7 @@ struct ImmersiveRootView: View { case .cardReward: roomSceneRenderer.clear(in: roomLayer) + selectedShopItem = nil clearShopFeedback(in: roomLayer) if let state = runSession.battleState { let newEvents = runSession.consumeNewBattlePresentationEvents() @@ -254,6 +259,7 @@ struct ImmersiveRootView: View { case .map, .runOver: roomSceneRenderer.clear(in: roomLayer) + selectedShopItem = nil clearShopFeedback(in: roomLayer) battleSceneRenderer.clear(in: battleLayer) } @@ -269,7 +275,13 @@ struct ImmersiveRootView: View { switch runSession.route { case .room(_, let roomType): if roomType == .shop { - panel.isEnabled = false + if selectedShopItem == nil { + panel.isEnabled = false + } else { + panel.isEnabled = true + panel.position = [0.52, 0.18, -0.58] + roomLayer.addChild(panel) + } } else { panel.isEnabled = true panel.position = roomSceneRenderer.panelPosition(for: roomType) @@ -348,7 +360,7 @@ struct ImmersiveRootView: View { case .rest: RestRoomPanel(nodeId: nodeId) case .shop: - EmptyView() + ShopRoomPanel(nodeId: nodeId, selection: $selectedShopItem) case .event: EventRoomPanel(nodeId: nodeId) default: @@ -413,24 +425,58 @@ struct ImmersiveRootView: View { let suffix = String(entityName.dropFirst(RoomSceneRenderer.Names.shopActionPrefix.count)) if suffix == "leave" { + selectedShopItem = nil runSession.leaveShopRoom() return } + guard let nextSelection = parseShopSelection(from: suffix) else { return } + if selectedShopItem == nextSelection { + selectedShopItem = nil + } else { + selectedShopItem = nextSelection + } + } + + 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 } + guard parts.count == 2, let index = Int(parts[1]) else { return nil } switch parts[0] { case "card": - runSession.buyShopCard(at: index) + return .card(index) case "relic": - runSession.buyShopRelic(at: index) + return .relic(index) case "consumable": - runSession.buyShopConsumable(at: index) + return .consumable(index) case "remove": - runSession.removeCardInShop(deckIndex: index) + return .removeCard(index) default: - break + return nil + } + } + + private func sanitizeShopSelection() { + guard let selectedShopItem else { return } + guard let runState = runSession.runState, let shopState = runSession.shopRoomState else { + self.selectedShopItem = nil + 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) + } + + if !isValid { + self.selectedShopItem = nil } } diff --git a/SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift b/SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift new file mode 100644 index 0000000..cb7a0e0 --- /dev/null +++ b/SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift @@ -0,0 +1,171 @@ +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("关闭") { + 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 +} From 09d9a0ececd546d2a34a76375c95ead40d0d52d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:06:48 +0800 Subject: [PATCH 15/29] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E6=88=BF?= =?UTF-8?q?=E9=97=B4=E9=9D=A2=E6=9D=BF=E4=BD=8D=E7=BD=AE=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=95=86=E5=BA=97=E9=9D=A2=E6=9D=BF=E7=9A=84=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift index 03e1444..4d6e620 100644 --- a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift +++ b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift @@ -266,6 +266,7 @@ struct ImmersiveRootView: View { 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 @@ -279,8 +280,8 @@ struct ImmersiveRootView: View { panel.isEnabled = false } else { panel.isEnabled = true - panel.position = [0.52, 0.18, -0.58] - roomLayer.addChild(panel) + panel.position = [0.30, 0.15, -0.50] + hudAnchor.addChild(panel) } } else { panel.isEnabled = true @@ -425,6 +426,10 @@ struct ImmersiveRootView: View { let suffix = String(entityName.dropFirst(RoomSceneRenderer.Names.shopActionPrefix.count)) if suffix == "leave" { + if selectedShopItem != nil { + selectedShopItem = nil + return + } selectedShopItem = nil runSession.leaveShopRoom() return From beefada122335f719c90f6ff906fbc11bb6ab301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:24:00 +0800 Subject: [PATCH 16/29] =?UTF-8?q?fix:=20=E5=95=86=E5=BA=97=E9=87=8C?= =?UTF-8?q?=E8=BF=9E=E7=BB=AD=E7=82=B9=E5=87=BB=E4=B8=8D=E5=90=8C=E5=95=86?= =?UTF-8?q?=E5=93=81=EF=BC=8C=E7=A1=AE=E8=AE=A4=203D=20=E5=9C=BA=E6=99=AF?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=95=B4=E5=B1=82=E5=88=B7=E6=96=B0=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SaluAVP/Immersive/RoomSceneRenderer.swift | 67 ++++++++++++------- 1 file changed, 43 insertions(+), 24 deletions(-) diff --git a/SaluNative/SaluAVP/Immersive/RoomSceneRenderer.swift b/SaluNative/SaluAVP/Immersive/RoomSceneRenderer.swift index 38124d4..1970f93 100644 --- a/SaluNative/SaluAVP/Immersive/RoomSceneRenderer.swift +++ b/SaluNative/SaluAVP/Immersive/RoomSceneRenderer.swift @@ -3,7 +3,7 @@ import RealityKit import UIKit @MainActor -struct RoomSceneRenderer { +final class RoomSceneRenderer { enum Names { static let roomLayer = "roomLayer" static let roomRoot = "roomRoot" @@ -33,15 +33,17 @@ struct RoomSceneRenderer { let shopVisual: ShopVisualKey? } - private var renderKey: RenderKey? + private struct RoomRenderStateComponent: Component { + let signature: UInt64 + } - mutating func makeRoomLayer() -> RealityKit.Entity { + func makeRoomLayer() -> RealityKit.Entity { let layer = RealityKit.Entity() layer.name = Names.roomLayer return layer } - mutating func render( + func render( nodeId: String, roomType: RoomType, shopState: ShopRoomState?, @@ -50,7 +52,12 @@ struct RoomSceneRenderer { ) { let shopVisual = makeShopVisualKey(roomType: roomType, shopState: shopState, runState: runState) let key = RenderKey(nodeId: nodeId, roomType: roomType, shopVisual: shopVisual) - guard renderKey != key else { return } + 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, @@ -58,13 +65,13 @@ struct RoomSceneRenderer { runState: runState, in: layer ) - renderKey = key + layer.components.set(RoomRenderStateComponent(signature: signature)) } - mutating func clear(in layer: RealityKit.Entity) { - guard renderKey != nil || !layer.children.isEmpty else { return } + func clear(in layer: RealityKit.Entity) { + guard !layer.children.isEmpty || layer.components[RoomRenderStateComponent.self] != nil else { return } layer.children.forEach { $0.removeFromParent() } - renderKey = nil + layer.components.remove(RoomRenderStateComponent.self) } func panelPosition(for roomType: RoomType) -> SIMD3 { @@ -94,7 +101,7 @@ struct RoomSceneRenderer { ) } - private mutating func rebuildScene( + private func rebuildScene( nodeId: String, roomType: RoomType, shopState: ShopRoomState?, @@ -121,6 +128,32 @@ struct RoomSceneRenderer { } } + 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 { @@ -195,20 +228,6 @@ struct RoomSceneRenderer { return } - let counter = ModelEntity( - mesh: .generateBox(size: [1.0, 0.16, 0.28]), - materials: [SimpleMaterial(color: UIColor.systemBrown, isMetallic: false)] - ) - counter.position = [0, 0.08, -0.82] - root.addChild(counter) - - let stallTop = ModelEntity( - mesh: .generateBox(size: [1.05, 0.02, 0.30]), - materials: [SimpleMaterial(color: UIColor.systemYellow.withAlphaComponent(0.65), isMetallic: false)] - ) - stallTop.position = [0, 0.44, -0.82] - root.addChild(stallTop) - let merchant = makeNPC( name: "\(Names.npcPrefix)merchant", color: UIColor.systemTeal, From d277cf8f410d2b316bf44f8d5273aeff73ff114d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:26:08 +0800 Subject: [PATCH 17/29] =?UTF-8?q?fix=EF=BC=9A=20=E4=BF=AE=E5=A4=8D-shopRoo?= =?UTF-8?q?mState.message=20=E4=BC=9A=E8=B7=A8=E5=95=86=E5=93=81=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E4=BF=9D=E7=95=99=EF=BC=8C=E5=AF=BC=E8=87=B4=E4=BD=A0?= =?UTF-8?q?=E7=82=B9=E4=B8=8B=E4=B8=80=E4=B8=AA=E5=95=86=E5=93=81=E6=97=B6?= =?UTF-8?q?=E4=BB=8D=E6=98=BE=E7=A4=BA=E4=B8=8A=E4=B8=80=E6=9D=A1=E2=80=9C?= =?UTF-8?q?=E8=B4=AD=E4=B9=B0=E6=88=90=E5=8A=9F=E2=80=9D=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift | 2 ++ SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift | 1 + SaluNative/SaluAVP/ViewModels/RunSession.swift | 8 ++++++++ 3 files changed, 11 insertions(+) diff --git a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift index 4d6e620..d036cd8 100644 --- a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift +++ b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift @@ -428,6 +428,7 @@ struct ImmersiveRootView: View { if suffix == "leave" { if selectedShopItem != nil { selectedShopItem = nil + runSession.clearShopTransientMessage() return } selectedShopItem = nil @@ -436,6 +437,7 @@ struct ImmersiveRootView: View { } guard let nextSelection = parseShopSelection(from: suffix) else { return } + runSession.clearShopTransientMessage() if selectedShopItem == nextSelection { selectedShopItem = nil } else { diff --git a/SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift b/SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift index cb7a0e0..a74605b 100644 --- a/SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift +++ b/SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift @@ -57,6 +57,7 @@ struct ShopRoomPanel: View { .disabled(!descriptor.canAfford) Button("关闭") { + runSession.clearShopTransientMessage() self.selection = nil } .buttonStyle(.bordered) diff --git a/SaluNative/SaluAVP/ViewModels/RunSession.swift b/SaluNative/SaluAVP/ViewModels/RunSession.swift index 71269b8..3aeee62 100644 --- a/SaluNative/SaluAVP/ViewModels/RunSession.swift +++ b/SaluNative/SaluAVP/ViewModels/RunSession.swift @@ -168,6 +168,14 @@ final class RunSession { 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 } From 819f572752af512f06cafbbed42315a5886ebec8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:37:59 +0800 Subject: [PATCH 18/29] - --- ...02-09-saluavp-full-ui-animation-implementation-plan.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index 3aca637..10c2bb5 100644 --- 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 @@ -246,7 +246,7 @@ ### ✅P4:房间 UI 闭环(Rest / Shop / Event) -### Task 11: Rest 房间交互(休息/升级/对话) +### ✅Task 11: Rest 房间交互(休息/升级/对话) **Files:** - Create: `SaluNative/SaluAVP/Immersive/RestRoomPanel.swift` - Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` @@ -263,7 +263,7 @@ **Step 3: 验证** - Manual: 进入休息点完成“休息”与“升级”各一次。 -### Task 12: Shop 房间交互(买卡/买遗物/买消耗/删牌) +### ✅Task 12: Shop 房间交互(买卡/买遗物/买消耗/删牌) **Files:** - Create: `SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift` - Create: `SaluNative/SaluAVP/ViewModels/ShopRoomState.swift` @@ -281,7 +281,7 @@ **Step 3: 验证** - Manual: 每类交易至少执行一次;金币不足路径触发一次。 -### Task 13: Event 房间交互(选项 + Follow-up) +### ✅Task 13: Event 房间交互(选项 + Follow-up) **Files:** - Create: `SaluNative/SaluAVP/Immersive/EventRoomPanel.swift` - Create: `SaluNative/SaluAVP/ViewModels/EventRoomState.swift` @@ -299,7 +299,7 @@ **Step 3: 验证** - Manual: 触发 3 个不同事件,验证数值和牌组变化。 -### Task 14: Event 触发精英战(followUp.startEliteBattle) +### ✅Task 14: Event 触发精英战(followUp.startEliteBattle) **Files:** - Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` - Modify: `SaluNative/SaluAVP/Immersive/EventRoomPanel.swift` From 294f9a2fd5dd4387d4dec7337cf48c5d9ac47a65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:41:36 +0800 Subject: [PATCH 19/29] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E5=A5=96?= =?UTF-8?q?=E5=8A=B1=E7=B3=BB=E7=BB=9F=EF=BC=8C=E6=B7=BB=E5=8A=A0=E5=A5=96?= =?UTF-8?q?=E5=8A=B1=E7=8A=B6=E6=80=81=E5=92=8C=E7=AB=A0=E8=8A=82=E7=BB=93?= =?UTF-8?q?=E6=9D=9F=E9=9D=A2=E6=9D=BF=EF=BC=8C=E4=BC=98=E5=8C=96=E5=A5=96?= =?UTF-8?q?=E5=8A=B1=E9=80=89=E6=8B=A9=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ControlPanel/ControlPanelView.swift | 6 +- .../SaluAVP/Immersive/ChapterEndPanel.swift | 34 +++++++++ .../SaluAVP/Immersive/ImmersiveRootView.swift | 55 +++++++++++--- .../SaluAVP/Immersive/RelicRewardPanel.swift | 65 ++++++++++++++++ .../SaluAVP/ViewModels/RewardRouteState.swift | 36 +++++++++ .../SaluAVP/ViewModels/RunSession.swift | 74 +++++++++++++++++-- 6 files changed, 250 insertions(+), 20 deletions(-) create mode 100644 SaluNative/SaluAVP/Immersive/ChapterEndPanel.swift create mode 100644 SaluNative/SaluAVP/Immersive/RelicRewardPanel.swift create mode 100644 SaluNative/SaluAVP/ViewModels/RewardRouteState.swift 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/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/ImmersiveRootView.swift b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift index d036cd8..bafae7b 100644 --- a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift +++ b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift @@ -69,7 +69,7 @@ struct ImmersiveRootView: View { handleShopSceneTap(named: value.entity.name) } - case .cardReward, .runOver: + case .reward, .chapterEnd, .runOver: break } } @@ -191,9 +191,9 @@ struct ImmersiveRootView: View { 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 } }() @@ -246,7 +246,7 @@ struct ImmersiveRootView: View { battleSceneRenderer.clear(in: battleLayer) } - case .cardReward: + case .reward: roomSceneRenderer.clear(in: roomLayer) selectedShopItem = nil clearShopFeedback(in: roomLayer) @@ -257,7 +257,7 @@ struct ImmersiveRootView: View { battleSceneRenderer.clear(in: battleLayer) } - case .map, .runOver: + case .chapterEnd, .map, .runOver: roomSceneRenderer.clear(in: roomLayer) selectedShopItem = nil clearShopFeedback(in: roomLayer) @@ -294,7 +294,7 @@ struct ImmersiveRootView: View { panel.position = [0, 0.25, -0.55] uiLayer.addChild(panel) - case .map, .battle, .cardReward: + case .map, .battle, .reward, .chapterEnd: panel.isEnabled = false } } @@ -319,7 +319,8 @@ struct ImmersiveRootView: View { hud.name = mapHudAttachmentId hud.components.set(BillboardComponent()) hud.components.set(InputTargetComponent()) - hud.isEnabled = !isInBattle && !isInRoom + 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] @@ -330,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 @@ -384,7 +393,7 @@ struct ImmersiveRootView: View { } ) - case .map, .battle, .cardReward: + case .map, .battle, .reward, .chapterEnd: EmptyView() } } @@ -744,8 +753,32 @@ 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() 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/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 3aeee62..d78109b 100644 --- a/SaluNative/SaluAVP/ViewModels/RunSession.swift +++ b/SaluNative/SaluAVP/ViewModels/RunSession.swift @@ -9,7 +9,8 @@ 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) } @@ -531,10 +532,35 @@ final class RunSession { } } + 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) @@ -550,12 +576,19 @@ final class RunSession { 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 } @@ -586,8 +619,35 @@ 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 { clearBattleState(preserveSnapshot: false) clearRoomState(clearEventBattleContext: true) From 9b1daf73eb0f8fb2f63abaf6b39f1132f0097b6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:53:16 +0800 Subject: [PATCH 20/29] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0plan=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...02-09-saluavp-full-ui-animation-implementation-plan.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index 10c2bb5..9e173e8 100644 --- 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 @@ -315,9 +315,9 @@ --- -### P5:奖励链路一致性(尤其精英/Boss 遗物) +### ✅P5:奖励链路一致性(尤其精英/Boss 遗物) -### Task 15: 统一 AVP 奖励路由模型 +### ✅Task 15: 统一 AVP 奖励路由模型 **Files:** - Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` - Create: `SaluNative/SaluAVP/ViewModels/RewardRouteState.swift` @@ -332,7 +332,7 @@ **Step 3: 验证** - Manual: 普通战、精英战、Boss 战分别走一遍奖励。 -### Task 16: 遗物奖励面板(精英/Boss) +### ✅Task 16: 遗物奖励面板(精英/Boss) **Files:** - Create: `SaluNative/SaluAVP/Immersive/RelicRewardPanel.swift` - Modify: `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` @@ -346,7 +346,7 @@ **Step 3: 验证** - Manual: 精英和 Boss 奖励各测一次选择与跳过。 -### Task 17: Boss 章节收束和下一幕衔接 +### ✅Task 17: Boss 章节收束和下一幕衔接 **Files:** - Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` - Create: `SaluNative/SaluAVP/Immersive/ChapterEndPanel.swift` From 97f7fba4c20b5ecabdb7e1480945fc84d28ed2c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Tue, 10 Feb 2026 00:14:37 +0800 Subject: [PATCH 21/29] =?UTF-8?q?feat:=20P6-=E6=B7=BB=E5=8A=A0=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E5=BF=AB=E7=85=A7=E5=AD=98=E5=82=A8=E5=92=8C=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E3=80=81=E5=8A=A0=E8=BD=BD=E5=92=8C=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...p-full-ui-animation-implementation-plan.md | 8 +- .../ControlPanel/ControlPanelView.swift | 30 ++++++ .../Persistence/AVPDataDirectory.swift | 23 +++++ .../Persistence/AVPRunSnapshotStore.swift | 49 ++++++++++ .../SaluAVP/ViewModels/RunSession.swift | 76 +++++++++++++++ Sources/GameCore/Run/RunSnapshotMapper.swift | 92 +++++++++++++++++++ 6 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 SaluNative/SaluAVP/Persistence/AVPDataDirectory.swift create mode 100644 SaluNative/SaluAVP/Persistence/AVPRunSnapshotStore.swift create mode 100644 Sources/GameCore/Run/RunSnapshotMapper.swift 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 index 9e173e8..2f0a70c 100644 --- 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 @@ -362,9 +362,9 @@ --- -### P6:存档与 Continue(控制面板能力补齐) +### ✅P6:存档与 Continue(控制面板能力补齐) -### Task 18: AVP 快照存储层(RunSnapshot) +### ✅Task 18: AVP 快照存储层(RunSnapshot) **Files:** - Create: `SaluNative/SaluAVP/Persistence/AVPRunSnapshotStore.swift` - Create: `SaluNative/SaluAVP/Persistence/AVPDataDirectory.swift` @@ -379,7 +379,7 @@ **Step 3: 验证** - Manual: 新开 run -> 保存 -> 关闭重开 -> Continue 恢复。 -### Task 19: 控制面板 Continue / Save / Reset UI +### ✅Task 19: 控制面板 Continue / Save / Reset UI **Files:** - Modify: `SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift` - Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` @@ -393,7 +393,7 @@ **Step 3: 验证** - Manual: 有存档和无存档两条路径。 -### Task 20: 自动保存策略 +### ✅Task 20: 自动保存策略 **Files:** - Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` diff --git a/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift b/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift index a702da8..8bd6338 100644 --- a/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift +++ b/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift @@ -26,6 +26,31 @@ struct ControlPanelView: View { .fontWeight(.semibold) } + HStack(spacing: 8) { + Button("Continue") { + runSession.continueFromSavedSnapshot() + } + .buttonStyle(.borderedProminent) + .disabled(!runSession.hasSavedRunSnapshot) + + Button("Save") { + runSession.saveRunSnapshot() + } + .buttonStyle(.bordered) + .disabled(runSession.runState == nil) + + Button("Delete Save") { + runSession.deleteSavedSnapshot() + } + .buttonStyle(.bordered) + .disabled(!runSession.hasSavedRunSnapshot) + + Button("Reset") { + runSession.resetToControlPanel() + } + .buttonStyle(.bordered) + } + if let error = runSession.lastError { Text(error) .font(.caption) @@ -36,6 +61,11 @@ struct ControlPanelView: View { Text("Route: \(routeLabel(runSession.route)) Current: \(run.currentNodeId ?? "-")") .font(.caption2) .foregroundStyle(.secondary) + if let autosaveError = runSession.lastAutosaveError { + Text("Autosave: \(autosaveError)") + .font(.caption2) + .foregroundStyle(.secondary) + } } else { Text("No run started.") .font(.caption) diff --git a/SaluNative/SaluAVP/Persistence/AVPDataDirectory.swift b/SaluNative/SaluAVP/Persistence/AVPDataDirectory.swift new file mode 100644 index 0000000..10bfb2d --- /dev/null +++ b/SaluNative/SaluAVP/Persistence/AVPDataDirectory.swift @@ -0,0 +1,23 @@ +import Foundation + +enum AVPDataDirectory { + static func rootURL() throws -> URL { + if let override = ProcessInfo.processInfo.environment["SALU_DATA_DIR"], + !override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let url = URL(fileURLWithPath: override, isDirectory: true).appendingPathComponent("SaluAVP", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + let base = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let url = base.appendingPathComponent("SaluAVP", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } +} + diff --git a/SaluNative/SaluAVP/Persistence/AVPRunSnapshotStore.swift b/SaluNative/SaluAVP/Persistence/AVPRunSnapshotStore.swift new file mode 100644 index 0000000..06213e5 --- /dev/null +++ b/SaluNative/SaluAVP/Persistence/AVPRunSnapshotStore.swift @@ -0,0 +1,49 @@ +import Foundation +import GameCore + +struct AVPRunSnapshotStore: Sendable { + enum StoreError: Error, Sendable, Equatable { + case missing + case invalidData + } + + private let fileName = "run_snapshot.json" + + func snapshotExists() -> Bool { + (try? snapshotURL().checkResourceIsReachable()) == true + } + + func save(_ snapshot: RunSnapshot) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(snapshot) + + let url = try snapshotURL() + try data.write(to: url, options: [.atomic]) + } + + func load() throws -> RunSnapshot { + let url = try snapshotURL() + guard (try? url.checkResourceIsReachable()) == true else { + throw StoreError.missing + } + + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + guard let snapshot = try? decoder.decode(RunSnapshot.self, from: data) else { + throw StoreError.invalidData + } + return snapshot + } + + func delete() throws { + let url = try snapshotURL() + guard (try? url.checkResourceIsReachable()) == true else { return } + try FileManager.default.removeItem(at: url) + } + + private func snapshotURL() throws -> URL { + try AVPDataDirectory.rootURL().appendingPathComponent(fileName, isDirectory: false) + } +} + diff --git a/SaluNative/SaluAVP/ViewModels/RunSession.swift b/SaluNative/SaluAVP/ViewModels/RunSession.swift index d78109b..f87d510 100644 --- a/SaluNative/SaluAVP/ViewModels/RunSession.swift +++ b/SaluNative/SaluAVP/ViewModels/RunSession.swift @@ -47,6 +47,14 @@ final class RunSession { private var battleSource: BattleSource = .mapNode private var eventBattleContext: EventBattleContext? + private let snapshotStore = AVPRunSnapshotStore() + private(set) var hasSavedRunSnapshot: Bool = false + private(set) var lastAutosaveError: String? + + init() { + refreshSnapshotPresence() + } + var selectedEnemyDisplayName: String? { guard let battleState, let selectedEnemyIndex else { return nil } guard battleState.enemies.indices.contains(selectedEnemyIndex) else { return nil } @@ -84,6 +92,8 @@ final class RunSession { battleSource = .mapNode eventBattleContext = nil route = .map + refreshSnapshotPresence() + autosaveIfNeeded() } func selectAccessibleNode(_ nodeId: String) { @@ -91,6 +101,7 @@ final class RunSession { guard runState.enterNode(nodeId) else { return } self.runState = runState + autosaveIfNeeded() guard let node = runState.map.node(withId: nodeId) else { lastError = "Node not found: \(nodeId)" @@ -125,6 +136,8 @@ final class RunSession { } else { route = .map } + + autosaveIfNeeded() } func restHeal() { @@ -549,6 +562,7 @@ final class RunSession { rewardState.phase = .card route = .reward(rewardState) + autosaveIfNeeded() } func chooseCardReward(_ cardId: CardID?) { @@ -582,11 +596,70 @@ final class RunSession { route = .map } } + + autosaveIfNeeded() } func continueAfterChapterEnd() { guard case .chapterEnd = route else { return } route = .map + autosaveIfNeeded() + } + + func saveRunSnapshot() { + guard let runState else { return } + do { + let snapshot = RunSnapshotMapper.makeSnapshot(from: runState) + try snapshotStore.save(snapshot) + hasSavedRunSnapshot = true + lastError = nil + } catch { + lastError = "Save failed: \(error)" + } + } + + func continueFromSavedSnapshot() { + do { + let snapshot = try snapshotStore.load() + let restored = try RunSnapshotMapper.loadRunState(from: snapshot) + seed = restored.seed + seedText = String(restored.seed) + runState = restored + lastError = nil + clearBattleState(preserveSnapshot: false) + clearRoomState(clearEventBattleContext: true) + route = restored.isOver ? .runOver(lastNodeId: restored.currentNodeId ?? "unknown", won: restored.won, floor: restored.floor) : .map + hasSavedRunSnapshot = true + } catch { + lastError = "Continue failed: \(error)" + refreshSnapshotPresence() + } + } + + func deleteSavedSnapshot() { + do { + try snapshotStore.delete() + refreshSnapshotPresence() + } catch { + lastError = "Delete save failed: \(error)" + } + } + + private func autosaveIfNeeded() { + // Best-effort. Never block gameplay flow on persistence, and never clobber lastError. + guard let runState else { return } + do { + let snapshot = RunSnapshotMapper.makeSnapshot(from: runState) + try snapshotStore.save(snapshot) + hasSavedRunSnapshot = true + lastAutosaveError = nil + } catch { + lastAutosaveError = String(describing: error) + } + } + + private func refreshSnapshotPresence() { + hasSavedRunSnapshot = snapshotStore.snapshotExists() } private func finishBattleIfNeeded() { @@ -648,10 +721,12 @@ final class RunSession { relicReward: relicReward ) ) + autosaveIfNeeded() } else { clearBattleState(preserveSnapshot: false) clearRoomState(clearEventBattleContext: true) route = .runOver(lastNodeId: nodeId, won: false, floor: runState.floor) + autosaveIfNeeded() } } @@ -764,6 +839,7 @@ final class RunSession { eventRoomState = nil battleSource = .mapNode eventBattleContext = nil + refreshSnapshotPresence() } private func consumeNewBattleEventSlice() -> ArraySlice { diff --git a/Sources/GameCore/Run/RunSnapshotMapper.swift b/Sources/GameCore/Run/RunSnapshotMapper.swift new file mode 100644 index 0000000..3e55e7a --- /dev/null +++ b/Sources/GameCore/Run/RunSnapshotMapper.swift @@ -0,0 +1,92 @@ +// RunSnapshot <-> RunState mapping lives in GameCore (pure logic, no I/O). + +public enum RunSnapshotMapper { + public enum LoadError: Error, Sendable, Equatable { + case incompatibleVersion(Int) + case invalidRoomType(String) + } + + public static func makeSnapshot(from run: RunState) -> RunSnapshot { + RunSnapshot( + version: RunSaveVersion.current, + seed: run.seed, + floor: run.floor, + maxFloor: run.maxFloor, + gold: run.gold, + mapNodes: run.map.map { node in + RunSnapshot.NodeData( + id: node.id, + row: node.row, + column: node.column, + roomType: node.roomType.rawValue, + connections: node.connections, + isCompleted: node.isCompleted, + isAccessible: node.isAccessible + ) + }, + currentNodeId: run.currentNodeId, + player: RunSnapshot.PlayerData( + maxHP: run.player.maxHP, + currentHP: run.player.currentHP, + statuses: Dictionary( + uniqueKeysWithValues: run.player.statuses.all.map { ($0.id.rawValue, $0.stacks) } + ) + ), + deck: run.deck.map { card in + RunSnapshot.CardData(id: card.id, cardId: card.cardId.rawValue) + }, + relicIds: run.relicManager.all.map(\.rawValue), + isOver: run.isOver, + won: run.won + ) + } + + public static func loadRunState(from snapshot: RunSnapshot) throws -> RunState { + guard RunSaveVersion.isCompatible(snapshot.version) else { + throw LoadError.incompatibleVersion(snapshot.version) + } + + let map: [MapNode] = try snapshot.mapNodes.map { node in + guard let roomType = RoomType(rawValue: node.roomType) else { + throw LoadError.invalidRoomType(node.roomType) + } + return MapNode( + id: node.id, + row: node.row, + column: node.column, + roomType: roomType, + connections: node.connections, + isCompleted: node.isCompleted, + isAccessible: node.isAccessible + ) + } + + var player = Entity(id: "player", name: LocalizedText("安德", "Ander"), maxHP: snapshot.player.maxHP) + player.currentHP = snapshot.player.currentHP + for (raw, stacks) in snapshot.player.statuses { + player.statuses.set(StatusID(raw), stacks: stacks) + } + + let deck = snapshot.deck.map { Card(id: $0.id, cardId: CardID($0.cardId)) } + var relicManager = RelicManager() + for raw in snapshot.relicIds { + relicManager.add(RelicID(raw)) + } + + var run = RunState( + player: player, + deck: deck, + gold: snapshot.gold, + relicManager: relicManager, + map: map, + seed: snapshot.seed, + floor: snapshot.floor, + maxFloor: snapshot.maxFloor + ) + run.currentNodeId = snapshot.currentNodeId + run.isOver = snapshot.isOver + run.won = snapshot.won + return run + } +} + From 8f873ba59623f15f56f8469e3891dc75f3966bc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Tue, 10 Feb 2026 00:28:21 +0800 Subject: [PATCH 22/29] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=9B=9E?= =?UTF-8?q?=E6=94=BE=E9=9D=A2=E6=9D=BF=E5=92=8C=E8=BF=90=E8=A1=8C=E8=BD=A8?= =?UTF-8?q?=E8=BF=B9=E5=AD=98=E5=82=A8=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=AF=BC=E5=87=BA=E3=80=81=E5=8A=A0=E8=BD=BD=E5=92=8C?= =?UTF-8?q?=E6=B8=85=E9=99=A4=E8=BD=A8=E8=BF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ControlPanel/ControlPanelView.swift | 4 + .../SaluAVP/ControlPanel/ReplayPanel.swift | 62 +++++ .../Persistence/AVPRunTraceStore.swift | 50 ++++ .../SaluAVP/ViewModels/RunSession.swift | 250 ++++++++++++++++++ SaluNative/SaluAVP/ViewModels/RunTrace.swift | 57 ++++ 5 files changed, 423 insertions(+) create mode 100644 SaluNative/SaluAVP/ControlPanel/ReplayPanel.swift create mode 100644 SaluNative/SaluAVP/Persistence/AVPRunTraceStore.swift create mode 100644 SaluNative/SaluAVP/ViewModels/RunTrace.swift diff --git a/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift b/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift index 8bd6338..8d3634f 100644 --- a/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift +++ b/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift @@ -84,6 +84,10 @@ struct ControlPanelView: View { Divider() + ReplayPanel() + + Divider() + VStack(alignment: .leading, spacing: 8) { Text("Card Display Mode") .font(.caption) diff --git a/SaluNative/SaluAVP/ControlPanel/ReplayPanel.swift b/SaluNative/SaluAVP/ControlPanel/ReplayPanel.swift new file mode 100644 index 0000000..7453fd1 --- /dev/null +++ b/SaluNative/SaluAVP/ControlPanel/ReplayPanel.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct ReplayPanel: View { + @Environment(RunSession.self) private var runSession + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Trace / Replay") + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + Button("Export Trace") { + runSession.exportRunTrace() + } + .buttonStyle(.bordered) + .disabled(runSession.runTrace == nil) + + Button("Replay") { + runSession.replayFromSavedTrace() + } + .buttonStyle(.borderedProminent) + .disabled(!runSession.hasSavedRunTrace) + + Button("Clear Trace") { + runSession.clearRunTrace() + } + .buttonStyle(.bordered) + .disabled(runSession.runTrace == nil) + + Button("Delete Trace") { + runSession.deleteSavedTrace() + } + .buttonStyle(.bordered) + .disabled(!runSession.hasSavedRunTrace) + } + + if let trace = runSession.runTrace { + Text("Trace entries: \(trace.entries.count)") + .font(.caption2) + .foregroundStyle(.secondary) + } else { + Text("Trace: off (replay mode or no run).") + .font(.caption2) + .foregroundStyle(.secondary) + } + + if let path = runSession.lastTracePath { + Text("Trace file: \(path)") + .font(.caption2) + .foregroundStyle(.secondary) + } + + if let replayError = runSession.lastReplayError { + Text(replayError) + .font(.caption2) + .foregroundStyle(.red) + } + } + } +} + diff --git a/SaluNative/SaluAVP/Persistence/AVPRunTraceStore.swift b/SaluNative/SaluAVP/Persistence/AVPRunTraceStore.swift new file mode 100644 index 0000000..714b23d --- /dev/null +++ b/SaluNative/SaluAVP/Persistence/AVPRunTraceStore.swift @@ -0,0 +1,50 @@ +import Foundation + +struct AVPRunTraceStore: Sendable { + enum StoreError: Error, Sendable, Equatable { + case missing + case invalidData + } + + private let fileName = "run_trace.json" + + func traceExists() -> Bool { + (try? traceURL().checkResourceIsReachable()) == true + } + + func save(_ trace: RunTrace) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(trace) + let url = try traceURL() + try data.write(to: url, options: [.atomic]) + } + + func load() throws -> RunTrace { + let url = try traceURL() + guard (try? url.checkResourceIsReachable()) == true else { + throw StoreError.missing + } + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + guard let trace = try? decoder.decode(RunTrace.self, from: data) else { + throw StoreError.invalidData + } + return trace + } + + func delete() throws { + let url = try traceURL() + guard (try? url.checkResourceIsReachable()) == true else { return } + try FileManager.default.removeItem(at: url) + } + + func tracePathString() -> String? { + (try? traceURL().path) + } + + private func traceURL() throws -> URL { + try AVPDataDirectory.rootURL().appendingPathComponent(fileName, isDirectory: false) + } +} + diff --git a/SaluNative/SaluAVP/ViewModels/RunSession.swift b/SaluNative/SaluAVP/ViewModels/RunSession.swift index f87d510..0f7c863 100644 --- a/SaluNative/SaluAVP/ViewModels/RunSession.swift +++ b/SaluNative/SaluAVP/ViewModels/RunSession.swift @@ -51,8 +51,16 @@ final class RunSession { private(set) var hasSavedRunSnapshot: Bool = false private(set) var lastAutosaveError: String? + private let traceStore = AVPRunTraceStore() + private(set) var hasSavedRunTrace: Bool = false + private(set) var lastTracePath: String? + private(set) var lastReplayError: String? + private var isReplaying: Bool = false + private(set) var runTrace: RunTrace? + init() { refreshSnapshotPresence() + refreshTracePresence() } var selectedEnemyDisplayName: String? { @@ -92,7 +100,9 @@ final class RunSession { battleSource = .mapNode eventBattleContext = nil route = .map + runTrace = RunTrace(seed: seed) refreshSnapshotPresence() + refreshTracePresence() autosaveIfNeeded() } @@ -100,6 +110,7 @@ final class RunSession { guard var runState else { return } guard runState.enterNode(nodeId) else { return } + appendTrace(.selectNode(nodeId: nodeId)) self.runState = runState autosaveIfNeeded() @@ -143,6 +154,7 @@ final class RunSession { func restHeal() { guard case .room(_, .rest) = route else { return } guard var runState else { return } + appendTrace(.restHeal) _ = runState.restAtNode() self.runState = runState restRoomMessage = nil @@ -157,6 +169,7 @@ final class RunSession { return } + appendTrace(.restUpgrade(deckIndex: deckIndex)) self.runState = runState restRoomMessage = nil completeCurrentRoomAndReturnToMap() @@ -179,6 +192,7 @@ final class RunSession { func leaveShopRoom() { guard case .room(_, .shop) = route else { return } + appendTrace(.leaveShop) completeCurrentRoomAndReturnToMap() } @@ -208,6 +222,7 @@ final class RunSession { return } + appendTrace(.shopBuyCard(offerIndex: offerIndex)) runState.gold -= offer.price runState.addCardToDeck(cardId: offer.cardId) var cardOffers = shopState.inventory.cardOffers @@ -241,6 +256,7 @@ final class RunSession { return } + appendTrace(.shopBuyRelic(offerIndex: offerIndex)) runState.gold -= offer.price runState.relicManager.add(offer.relicId) var relicOffers = shopState.inventory.relicOffers @@ -281,6 +297,7 @@ final class RunSession { return } + appendTrace(.shopBuyConsumable(offerIndex: offerIndex)) runState.gold -= offer.price setShopMessage("购买成功:\(CardRegistry.require(offer.cardId).name.resolved(for: .zhHans))", in: &shopState) self.runState = runState @@ -305,6 +322,7 @@ final class RunSession { return } + appendTrace(.shopRemoveCard(deckIndex: deckIndex)) let removedCard = runState.deck[deckIndex] runState.removeCardFromDeck(at: deckIndex) runState.gold -= price @@ -325,6 +343,7 @@ final class RunSession { } let option = eventState.offer.options[optionIndex] + appendTrace(.eventChooseOption(optionIndex: optionIndex)) var failureLines: [String] = [] for effect in option.effects { guard runState.apply(effect) else { @@ -415,6 +434,7 @@ final class RunSession { return } + appendTrace(.eventChooseUpgrade(deckIndex: deckIndex)) let upgradedDef = CardRegistry.require(upgradedId) var lines = baseResultLines lines.append("升级:\(cardDef.name.resolved(for: .zhHans)) -> \(upgradedDef.name.resolved(for: .zhHans))") @@ -432,6 +452,7 @@ final class RunSession { var lines = baseResultLines lines.append("你放弃了升级") + appendTrace(.eventSkipUpgrade) eventState.phase = .resolved(optionIndex: optionIndex, resultLines: lines) eventState.message = nil eventRoomState = eventState @@ -441,6 +462,7 @@ final class RunSession { guard case .room(_, .event) = route else { return } guard let eventRoomState else { return } guard case .resolved = eventRoomState.phase else { return } + appendTrace(.eventComplete) completeCurrentRoomAndReturnToMap() } @@ -449,6 +471,14 @@ final class RunSession { guard let battleEngine else { return } guard battleEngine.pendingInput == nil else { return } guard battleEngine.state.hand.indices.contains(handIndex) else { return } + + let targetEntityId: String? = { + guard let selectedEnemyIndex, let battleState else { return nil } + guard battleState.enemies.indices.contains(selectedEnemyIndex) else { return nil } + return battleState.enemies[selectedEnemyIndex].id + }() + appendTrace(.playCard(handIndex: handIndex, targetEnemyEntityId: targetEntityId)) + let targetEnemyIndex = resolveTargetEnemyIndex( for: battleEngine.state.hand[handIndex], in: battleEngine.state @@ -477,6 +507,7 @@ final class RunSession { guard routeIsBattle else { return } guard let battleEngine else { return } guard battleEngine.pendingInput == nil else { return } + appendTrace(.endTurn) _ = battleEngine.handleAction(.endTurn) syncBattleStateFromEngine() battleTargetHint = nil @@ -516,6 +547,7 @@ final class RunSession { func selectEnemyTarget(entityId: String) { guard let battleState else { return } guard let index = battleState.enemies.firstIndex(where: { $0.id == entityId }) else { return } + appendTrace(.selectEnemy(entityId: entityId)) selectEnemyTarget(index: index) } @@ -560,6 +592,7 @@ final class RunSession { self.runState = runState } + appendTrace(.chooseRelicReward(take: take)) rewardState.phase = .card route = .reward(rewardState) autosaveIfNeeded() @@ -582,6 +615,7 @@ final class RunSession { guard offer.canSkip else { return } } + appendTrace(.chooseCardReward(cardId: cardId?.rawValue)) runState.completeCurrentNode() self.runState = runState @@ -603,6 +637,7 @@ final class RunSession { func continueAfterChapterEnd() { guard case .chapterEnd = route else { return } route = .map + appendTrace(.continueAfterChapterEnd) autosaveIfNeeded() } @@ -630,12 +665,48 @@ final class RunSession { clearRoomState(clearEventBattleContext: true) route = restored.isOver ? .runOver(lastNodeId: restored.currentNodeId ?? "unknown", won: restored.won, floor: restored.floor) : .map hasSavedRunSnapshot = true + runTrace = RunTrace(seed: restored.seed) } catch { lastError = "Continue failed: \(error)" refreshSnapshotPresence() } } + func exportRunTrace() { + guard let runTrace else { return } + do { + try traceStore.save(runTrace) + refreshTracePresence() + lastTracePath = traceStore.tracePathString() + lastError = nil + } catch { + lastError = "Export trace failed: \(error)" + } + } + + func clearRunTrace() { + guard let seed else { + runTrace = nil + return + } + runTrace = RunTrace(seed: seed) + lastReplayError = nil + } + + func replayFromSavedTrace() { + do { + let trace = try traceStore.load() + lastReplayError = nil + lastError = nil + Task { @MainActor in + await replay(trace: trace) + } + } catch { + lastError = "Load trace failed: \(error)" + refreshTracePresence() + } + } + func deleteSavedSnapshot() { do { try snapshotStore.delete() @@ -647,6 +718,7 @@ final class RunSession { private func autosaveIfNeeded() { // Best-effort. Never block gameplay flow on persistence, and never clobber lastError. + guard !isReplaying else { return } guard let runState else { return } do { let snapshot = RunSnapshotMapper.makeSnapshot(from: runState) @@ -662,6 +734,174 @@ final class RunSession { hasSavedRunSnapshot = snapshotStore.snapshotExists() } + private func refreshTracePresence() { + hasSavedRunTrace = traceStore.traceExists() + lastTracePath = traceStore.tracePathString() + } + + private func appendTrace(_ action: RunTrace.Action) { + guard !isReplaying else { return } + guard var runTrace else { return } + runTrace.append(action) + self.runTrace = runTrace + } + + private enum ReplayError: Error, Sendable, Equatable { + case routeMismatch(expected: String, actual: String, entryId: Int) + case invalidEnemy(entityId: String, entryId: Int) + } + + private func replay(trace: RunTrace) async { + isReplaying = true + defer { isReplaying = false } + + // Reset and start deterministic run + seed = trace.seed + seedText = String(trace.seed) + startNewRun() + lastReplayError = nil + + // Do not record trace during replay. + runTrace = nil + + for entry in trace.entries { + do { + try applyReplayEntry(entry) + } catch { + lastReplayError = "Replay failed at #\(entry.id): \(error)" + return + } + } + } + + private func applyReplayEntry(_ entry: RunTrace.Entry) throws { + switch entry.action { + case .selectNode(let nodeId): + guard case .map = route else { throw ReplayError.routeMismatch(expected: "map", actual: routeLabel(route), entryId: entry.id) } + selectAccessibleNode(nodeId) + + case .selectEnemy(let entityId): + guard case .battle = route else { throw ReplayError.routeMismatch(expected: "battle", actual: routeLabel(route), entryId: entry.id) } + guard let battleState, battleState.enemies.contains(where: { $0.id == entityId }) else { + throw ReplayError.invalidEnemy(entityId: entityId, entryId: entry.id) + } + selectEnemyTarget(entityId: entityId) + + case .playCard(let handIndex, let targetEnemyEntityId): + guard case .battle = route else { throw ReplayError.routeMismatch(expected: "battle", actual: routeLabel(route), entryId: entry.id) } + if let targetEnemyEntityId { + _ = targetEnemyEntityId + selectEnemyTarget(entityId: targetEnemyEntityId) + } + playCard(handIndex: handIndex) + + case .endTurn: + guard case .battle = route else { throw ReplayError.routeMismatch(expected: "battle", actual: routeLabel(route), entryId: entry.id) } + endTurn() + + case .chooseRelicReward(let take): + guard case .reward(let state) = route, state.phase == .relic else { + throw ReplayError.routeMismatch(expected: "reward(relic)", actual: routeLabel(route), entryId: entry.id) + } + chooseRelicReward(take: take) + + case .chooseCardReward(let raw): + guard case .reward(let state) = route, state.phase == .card else { + throw ReplayError.routeMismatch(expected: "reward(card)", actual: routeLabel(route), entryId: entry.id) + } + chooseCardReward(raw.map { CardID($0) }) + + case .continueAfterChapterEnd: + guard case .chapterEnd = route else { + throw ReplayError.routeMismatch(expected: "chapterEnd", actual: routeLabel(route), entryId: entry.id) + } + continueAfterChapterEnd() + + case .restHeal: + guard case .room(_, .rest) = route else { + throw ReplayError.routeMismatch(expected: "room(rest)", actual: routeLabel(route), entryId: entry.id) + } + restHeal() + + case .restUpgrade(let deckIndex): + guard case .room(_, .rest) = route else { + throw ReplayError.routeMismatch(expected: "room(rest)", actual: routeLabel(route), entryId: entry.id) + } + restUpgradeCard(deckIndex: deckIndex) + + case .shopBuyCard(let offerIndex): + guard case .room(_, .shop) = route else { + throw ReplayError.routeMismatch(expected: "room(shop)", actual: routeLabel(route), entryId: entry.id) + } + buyShopCard(at: offerIndex) + + case .shopBuyRelic(let offerIndex): + guard case .room(_, .shop) = route else { + throw ReplayError.routeMismatch(expected: "room(shop)", actual: routeLabel(route), entryId: entry.id) + } + buyShopRelic(at: offerIndex) + + case .shopBuyConsumable(let offerIndex): + guard case .room(_, .shop) = route else { + throw ReplayError.routeMismatch(expected: "room(shop)", actual: routeLabel(route), entryId: entry.id) + } + buyShopConsumable(at: offerIndex) + + case .shopRemoveCard(let deckIndex): + guard case .room(_, .shop) = route else { + throw ReplayError.routeMismatch(expected: "room(shop)", actual: routeLabel(route), entryId: entry.id) + } + removeCardInShop(deckIndex: deckIndex) + + case .leaveShop: + guard case .room(_, .shop) = route else { + throw ReplayError.routeMismatch(expected: "room(shop)", actual: routeLabel(route), entryId: entry.id) + } + leaveShopRoom() + + case .eventChooseOption(let optionIndex): + guard case .room(_, .event) = route else { + throw ReplayError.routeMismatch(expected: "room(event)", actual: routeLabel(route), entryId: entry.id) + } + chooseEventOption(optionIndex) + + case .eventChooseUpgrade(let deckIndex): + guard case .room(_, .event) = route else { + throw ReplayError.routeMismatch(expected: "room(event)", actual: routeLabel(route), entryId: entry.id) + } + chooseEventUpgradeCard(deckIndex: deckIndex) + + case .eventSkipUpgrade: + guard case .room(_, .event) = route else { + throw ReplayError.routeMismatch(expected: "room(event)", actual: routeLabel(route), entryId: entry.id) + } + skipEventUpgradeChoice() + + case .eventComplete: + guard case .room(_, .event) = route else { + throw ReplayError.routeMismatch(expected: "room(event)", actual: routeLabel(route), entryId: entry.id) + } + completeEventRoom() + } + } + + private func routeLabel(_ route: Route) -> String { + switch route { + case .map: + return "map" + case .room(_, let roomType): + return "room(\(roomType.rawValue))" + case .battle(_, let roomType): + return "battle(\(roomType.rawValue))" + case .reward(let rewardState): + return "reward(\(rewardState.roomType.rawValue)#\(rewardState.phase))" + case .chapterEnd(let prev, let next): + return "chapterEnd(\(prev)->\(next))" + case .runOver(_, let won, let floor): + return "runOver(won:\(won), floor:\(floor))" + } + } + private func finishBattleIfNeeded() { guard let battleEngine, battleEngine.state.isOver else { return } guard var runState else { return } @@ -840,6 +1080,16 @@ final class RunSession { battleSource = .mapNode eventBattleContext = nil refreshSnapshotPresence() + refreshTracePresence() + } + + func deleteSavedTrace() { + do { + try traceStore.delete() + refreshTracePresence() + } catch { + lastError = "Delete trace failed: \(error)" + } } private func consumeNewBattleEventSlice() -> ArraySlice { diff --git a/SaluNative/SaluAVP/ViewModels/RunTrace.swift b/SaluNative/SaluAVP/ViewModels/RunTrace.swift new file mode 100644 index 0000000..bcb03d1 --- /dev/null +++ b/SaluNative/SaluAVP/ViewModels/RunTrace.swift @@ -0,0 +1,57 @@ +import Foundation +import GameCore + +struct RunTrace: Codable, Sendable, Equatable { + static let currentVersion = 1 + + struct Entry: Codable, Sendable, Equatable, Identifiable { + let id: Int + let action: Action + + init(id: Int, action: Action) { + self.id = id + self.action = action + } + } + + enum Action: Codable, Sendable, Equatable { + case selectNode(nodeId: String) + + case selectEnemy(entityId: String) + case playCard(handIndex: Int, targetEnemyEntityId: String?) + case endTurn + + case chooseRelicReward(take: Bool) + case chooseCardReward(cardId: String?) // nil = skip + case continueAfterChapterEnd + + case restHeal + case restUpgrade(deckIndex: Int) + + case shopBuyCard(offerIndex: Int) + case shopBuyRelic(offerIndex: Int) + case shopBuyConsumable(offerIndex: Int) + case shopRemoveCard(deckIndex: Int) + case leaveShop + + case eventChooseOption(optionIndex: Int) + case eventChooseUpgrade(deckIndex: Int) + case eventSkipUpgrade + case eventComplete + } + + let version: Int + let seed: UInt64 + private(set) var entries: [Entry] + + init(seed: UInt64, entries: [Entry] = []) { + self.version = Self.currentVersion + self.seed = seed + self.entries = entries + } + + mutating func append(_ action: Action) { + entries.append(Entry(id: entries.count, action: action)) + } +} + From a9c780d4f0e4ab94c6509f992f7abf039482fbcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Tue, 10 Feb 2026 00:29:01 +0800 Subject: [PATCH 23/29] =?UTF-8?q?Revert=20"feat:=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=9B=9E=E6=94=BE=E9=9D=A2=E6=9D=BF=E5=92=8C=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E8=BD=A8=E8=BF=B9=E5=AD=98=E5=82=A8=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AF=BC=E5=87=BA=E3=80=81=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=92=8C=E6=B8=85=E9=99=A4=E8=BD=A8=E8=BF=B9"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 8f873ba59623f15f56f8469e3891dc75f3966bc3. --- .../ControlPanel/ControlPanelView.swift | 4 - .../SaluAVP/ControlPanel/ReplayPanel.swift | 62 ----- .../Persistence/AVPRunTraceStore.swift | 50 ---- .../SaluAVP/ViewModels/RunSession.swift | 250 ------------------ SaluNative/SaluAVP/ViewModels/RunTrace.swift | 57 ---- 5 files changed, 423 deletions(-) delete mode 100644 SaluNative/SaluAVP/ControlPanel/ReplayPanel.swift delete mode 100644 SaluNative/SaluAVP/Persistence/AVPRunTraceStore.swift delete mode 100644 SaluNative/SaluAVP/ViewModels/RunTrace.swift diff --git a/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift b/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift index 8d3634f..8bd6338 100644 --- a/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift +++ b/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift @@ -84,10 +84,6 @@ struct ControlPanelView: View { Divider() - ReplayPanel() - - Divider() - VStack(alignment: .leading, spacing: 8) { Text("Card Display Mode") .font(.caption) diff --git a/SaluNative/SaluAVP/ControlPanel/ReplayPanel.swift b/SaluNative/SaluAVP/ControlPanel/ReplayPanel.swift deleted file mode 100644 index 7453fd1..0000000 --- a/SaluNative/SaluAVP/ControlPanel/ReplayPanel.swift +++ /dev/null @@ -1,62 +0,0 @@ -import SwiftUI - -struct ReplayPanel: View { - @Environment(RunSession.self) private var runSession - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Trace / Replay") - .font(.caption) - .foregroundStyle(.secondary) - - HStack(spacing: 8) { - Button("Export Trace") { - runSession.exportRunTrace() - } - .buttonStyle(.bordered) - .disabled(runSession.runTrace == nil) - - Button("Replay") { - runSession.replayFromSavedTrace() - } - .buttonStyle(.borderedProminent) - .disabled(!runSession.hasSavedRunTrace) - - Button("Clear Trace") { - runSession.clearRunTrace() - } - .buttonStyle(.bordered) - .disabled(runSession.runTrace == nil) - - Button("Delete Trace") { - runSession.deleteSavedTrace() - } - .buttonStyle(.bordered) - .disabled(!runSession.hasSavedRunTrace) - } - - if let trace = runSession.runTrace { - Text("Trace entries: \(trace.entries.count)") - .font(.caption2) - .foregroundStyle(.secondary) - } else { - Text("Trace: off (replay mode or no run).") - .font(.caption2) - .foregroundStyle(.secondary) - } - - if let path = runSession.lastTracePath { - Text("Trace file: \(path)") - .font(.caption2) - .foregroundStyle(.secondary) - } - - if let replayError = runSession.lastReplayError { - Text(replayError) - .font(.caption2) - .foregroundStyle(.red) - } - } - } -} - diff --git a/SaluNative/SaluAVP/Persistence/AVPRunTraceStore.swift b/SaluNative/SaluAVP/Persistence/AVPRunTraceStore.swift deleted file mode 100644 index 714b23d..0000000 --- a/SaluNative/SaluAVP/Persistence/AVPRunTraceStore.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation - -struct AVPRunTraceStore: Sendable { - enum StoreError: Error, Sendable, Equatable { - case missing - case invalidData - } - - private let fileName = "run_trace.json" - - func traceExists() -> Bool { - (try? traceURL().checkResourceIsReachable()) == true - } - - func save(_ trace: RunTrace) throws { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(trace) - let url = try traceURL() - try data.write(to: url, options: [.atomic]) - } - - func load() throws -> RunTrace { - let url = try traceURL() - guard (try? url.checkResourceIsReachable()) == true else { - throw StoreError.missing - } - let data = try Data(contentsOf: url) - let decoder = JSONDecoder() - guard let trace = try? decoder.decode(RunTrace.self, from: data) else { - throw StoreError.invalidData - } - return trace - } - - func delete() throws { - let url = try traceURL() - guard (try? url.checkResourceIsReachable()) == true else { return } - try FileManager.default.removeItem(at: url) - } - - func tracePathString() -> String? { - (try? traceURL().path) - } - - private func traceURL() throws -> URL { - try AVPDataDirectory.rootURL().appendingPathComponent(fileName, isDirectory: false) - } -} - diff --git a/SaluNative/SaluAVP/ViewModels/RunSession.swift b/SaluNative/SaluAVP/ViewModels/RunSession.swift index 0f7c863..f87d510 100644 --- a/SaluNative/SaluAVP/ViewModels/RunSession.swift +++ b/SaluNative/SaluAVP/ViewModels/RunSession.swift @@ -51,16 +51,8 @@ final class RunSession { private(set) var hasSavedRunSnapshot: Bool = false private(set) var lastAutosaveError: String? - private let traceStore = AVPRunTraceStore() - private(set) var hasSavedRunTrace: Bool = false - private(set) var lastTracePath: String? - private(set) var lastReplayError: String? - private var isReplaying: Bool = false - private(set) var runTrace: RunTrace? - init() { refreshSnapshotPresence() - refreshTracePresence() } var selectedEnemyDisplayName: String? { @@ -100,9 +92,7 @@ final class RunSession { battleSource = .mapNode eventBattleContext = nil route = .map - runTrace = RunTrace(seed: seed) refreshSnapshotPresence() - refreshTracePresence() autosaveIfNeeded() } @@ -110,7 +100,6 @@ final class RunSession { guard var runState else { return } guard runState.enterNode(nodeId) else { return } - appendTrace(.selectNode(nodeId: nodeId)) self.runState = runState autosaveIfNeeded() @@ -154,7 +143,6 @@ final class RunSession { func restHeal() { guard case .room(_, .rest) = route else { return } guard var runState else { return } - appendTrace(.restHeal) _ = runState.restAtNode() self.runState = runState restRoomMessage = nil @@ -169,7 +157,6 @@ final class RunSession { return } - appendTrace(.restUpgrade(deckIndex: deckIndex)) self.runState = runState restRoomMessage = nil completeCurrentRoomAndReturnToMap() @@ -192,7 +179,6 @@ final class RunSession { func leaveShopRoom() { guard case .room(_, .shop) = route else { return } - appendTrace(.leaveShop) completeCurrentRoomAndReturnToMap() } @@ -222,7 +208,6 @@ final class RunSession { return } - appendTrace(.shopBuyCard(offerIndex: offerIndex)) runState.gold -= offer.price runState.addCardToDeck(cardId: offer.cardId) var cardOffers = shopState.inventory.cardOffers @@ -256,7 +241,6 @@ final class RunSession { return } - appendTrace(.shopBuyRelic(offerIndex: offerIndex)) runState.gold -= offer.price runState.relicManager.add(offer.relicId) var relicOffers = shopState.inventory.relicOffers @@ -297,7 +281,6 @@ final class RunSession { return } - appendTrace(.shopBuyConsumable(offerIndex: offerIndex)) runState.gold -= offer.price setShopMessage("购买成功:\(CardRegistry.require(offer.cardId).name.resolved(for: .zhHans))", in: &shopState) self.runState = runState @@ -322,7 +305,6 @@ final class RunSession { return } - appendTrace(.shopRemoveCard(deckIndex: deckIndex)) let removedCard = runState.deck[deckIndex] runState.removeCardFromDeck(at: deckIndex) runState.gold -= price @@ -343,7 +325,6 @@ final class RunSession { } let option = eventState.offer.options[optionIndex] - appendTrace(.eventChooseOption(optionIndex: optionIndex)) var failureLines: [String] = [] for effect in option.effects { guard runState.apply(effect) else { @@ -434,7 +415,6 @@ final class RunSession { return } - appendTrace(.eventChooseUpgrade(deckIndex: deckIndex)) let upgradedDef = CardRegistry.require(upgradedId) var lines = baseResultLines lines.append("升级:\(cardDef.name.resolved(for: .zhHans)) -> \(upgradedDef.name.resolved(for: .zhHans))") @@ -452,7 +432,6 @@ final class RunSession { var lines = baseResultLines lines.append("你放弃了升级") - appendTrace(.eventSkipUpgrade) eventState.phase = .resolved(optionIndex: optionIndex, resultLines: lines) eventState.message = nil eventRoomState = eventState @@ -462,7 +441,6 @@ final class RunSession { guard case .room(_, .event) = route else { return } guard let eventRoomState else { return } guard case .resolved = eventRoomState.phase else { return } - appendTrace(.eventComplete) completeCurrentRoomAndReturnToMap() } @@ -471,14 +449,6 @@ final class RunSession { guard let battleEngine else { return } guard battleEngine.pendingInput == nil else { return } guard battleEngine.state.hand.indices.contains(handIndex) else { return } - - let targetEntityId: String? = { - guard let selectedEnemyIndex, let battleState else { return nil } - guard battleState.enemies.indices.contains(selectedEnemyIndex) else { return nil } - return battleState.enemies[selectedEnemyIndex].id - }() - appendTrace(.playCard(handIndex: handIndex, targetEnemyEntityId: targetEntityId)) - let targetEnemyIndex = resolveTargetEnemyIndex( for: battleEngine.state.hand[handIndex], in: battleEngine.state @@ -507,7 +477,6 @@ final class RunSession { guard routeIsBattle else { return } guard let battleEngine else { return } guard battleEngine.pendingInput == nil else { return } - appendTrace(.endTurn) _ = battleEngine.handleAction(.endTurn) syncBattleStateFromEngine() battleTargetHint = nil @@ -547,7 +516,6 @@ final class RunSession { func selectEnemyTarget(entityId: String) { guard let battleState else { return } guard let index = battleState.enemies.firstIndex(where: { $0.id == entityId }) else { return } - appendTrace(.selectEnemy(entityId: entityId)) selectEnemyTarget(index: index) } @@ -592,7 +560,6 @@ final class RunSession { self.runState = runState } - appendTrace(.chooseRelicReward(take: take)) rewardState.phase = .card route = .reward(rewardState) autosaveIfNeeded() @@ -615,7 +582,6 @@ final class RunSession { guard offer.canSkip else { return } } - appendTrace(.chooseCardReward(cardId: cardId?.rawValue)) runState.completeCurrentNode() self.runState = runState @@ -637,7 +603,6 @@ final class RunSession { func continueAfterChapterEnd() { guard case .chapterEnd = route else { return } route = .map - appendTrace(.continueAfterChapterEnd) autosaveIfNeeded() } @@ -665,48 +630,12 @@ final class RunSession { clearRoomState(clearEventBattleContext: true) route = restored.isOver ? .runOver(lastNodeId: restored.currentNodeId ?? "unknown", won: restored.won, floor: restored.floor) : .map hasSavedRunSnapshot = true - runTrace = RunTrace(seed: restored.seed) } catch { lastError = "Continue failed: \(error)" refreshSnapshotPresence() } } - func exportRunTrace() { - guard let runTrace else { return } - do { - try traceStore.save(runTrace) - refreshTracePresence() - lastTracePath = traceStore.tracePathString() - lastError = nil - } catch { - lastError = "Export trace failed: \(error)" - } - } - - func clearRunTrace() { - guard let seed else { - runTrace = nil - return - } - runTrace = RunTrace(seed: seed) - lastReplayError = nil - } - - func replayFromSavedTrace() { - do { - let trace = try traceStore.load() - lastReplayError = nil - lastError = nil - Task { @MainActor in - await replay(trace: trace) - } - } catch { - lastError = "Load trace failed: \(error)" - refreshTracePresence() - } - } - func deleteSavedSnapshot() { do { try snapshotStore.delete() @@ -718,7 +647,6 @@ final class RunSession { private func autosaveIfNeeded() { // Best-effort. Never block gameplay flow on persistence, and never clobber lastError. - guard !isReplaying else { return } guard let runState else { return } do { let snapshot = RunSnapshotMapper.makeSnapshot(from: runState) @@ -734,174 +662,6 @@ final class RunSession { hasSavedRunSnapshot = snapshotStore.snapshotExists() } - private func refreshTracePresence() { - hasSavedRunTrace = traceStore.traceExists() - lastTracePath = traceStore.tracePathString() - } - - private func appendTrace(_ action: RunTrace.Action) { - guard !isReplaying else { return } - guard var runTrace else { return } - runTrace.append(action) - self.runTrace = runTrace - } - - private enum ReplayError: Error, Sendable, Equatable { - case routeMismatch(expected: String, actual: String, entryId: Int) - case invalidEnemy(entityId: String, entryId: Int) - } - - private func replay(trace: RunTrace) async { - isReplaying = true - defer { isReplaying = false } - - // Reset and start deterministic run - seed = trace.seed - seedText = String(trace.seed) - startNewRun() - lastReplayError = nil - - // Do not record trace during replay. - runTrace = nil - - for entry in trace.entries { - do { - try applyReplayEntry(entry) - } catch { - lastReplayError = "Replay failed at #\(entry.id): \(error)" - return - } - } - } - - private func applyReplayEntry(_ entry: RunTrace.Entry) throws { - switch entry.action { - case .selectNode(let nodeId): - guard case .map = route else { throw ReplayError.routeMismatch(expected: "map", actual: routeLabel(route), entryId: entry.id) } - selectAccessibleNode(nodeId) - - case .selectEnemy(let entityId): - guard case .battle = route else { throw ReplayError.routeMismatch(expected: "battle", actual: routeLabel(route), entryId: entry.id) } - guard let battleState, battleState.enemies.contains(where: { $0.id == entityId }) else { - throw ReplayError.invalidEnemy(entityId: entityId, entryId: entry.id) - } - selectEnemyTarget(entityId: entityId) - - case .playCard(let handIndex, let targetEnemyEntityId): - guard case .battle = route else { throw ReplayError.routeMismatch(expected: "battle", actual: routeLabel(route), entryId: entry.id) } - if let targetEnemyEntityId { - _ = targetEnemyEntityId - selectEnemyTarget(entityId: targetEnemyEntityId) - } - playCard(handIndex: handIndex) - - case .endTurn: - guard case .battle = route else { throw ReplayError.routeMismatch(expected: "battle", actual: routeLabel(route), entryId: entry.id) } - endTurn() - - case .chooseRelicReward(let take): - guard case .reward(let state) = route, state.phase == .relic else { - throw ReplayError.routeMismatch(expected: "reward(relic)", actual: routeLabel(route), entryId: entry.id) - } - chooseRelicReward(take: take) - - case .chooseCardReward(let raw): - guard case .reward(let state) = route, state.phase == .card else { - throw ReplayError.routeMismatch(expected: "reward(card)", actual: routeLabel(route), entryId: entry.id) - } - chooseCardReward(raw.map { CardID($0) }) - - case .continueAfterChapterEnd: - guard case .chapterEnd = route else { - throw ReplayError.routeMismatch(expected: "chapterEnd", actual: routeLabel(route), entryId: entry.id) - } - continueAfterChapterEnd() - - case .restHeal: - guard case .room(_, .rest) = route else { - throw ReplayError.routeMismatch(expected: "room(rest)", actual: routeLabel(route), entryId: entry.id) - } - restHeal() - - case .restUpgrade(let deckIndex): - guard case .room(_, .rest) = route else { - throw ReplayError.routeMismatch(expected: "room(rest)", actual: routeLabel(route), entryId: entry.id) - } - restUpgradeCard(deckIndex: deckIndex) - - case .shopBuyCard(let offerIndex): - guard case .room(_, .shop) = route else { - throw ReplayError.routeMismatch(expected: "room(shop)", actual: routeLabel(route), entryId: entry.id) - } - buyShopCard(at: offerIndex) - - case .shopBuyRelic(let offerIndex): - guard case .room(_, .shop) = route else { - throw ReplayError.routeMismatch(expected: "room(shop)", actual: routeLabel(route), entryId: entry.id) - } - buyShopRelic(at: offerIndex) - - case .shopBuyConsumable(let offerIndex): - guard case .room(_, .shop) = route else { - throw ReplayError.routeMismatch(expected: "room(shop)", actual: routeLabel(route), entryId: entry.id) - } - buyShopConsumable(at: offerIndex) - - case .shopRemoveCard(let deckIndex): - guard case .room(_, .shop) = route else { - throw ReplayError.routeMismatch(expected: "room(shop)", actual: routeLabel(route), entryId: entry.id) - } - removeCardInShop(deckIndex: deckIndex) - - case .leaveShop: - guard case .room(_, .shop) = route else { - throw ReplayError.routeMismatch(expected: "room(shop)", actual: routeLabel(route), entryId: entry.id) - } - leaveShopRoom() - - case .eventChooseOption(let optionIndex): - guard case .room(_, .event) = route else { - throw ReplayError.routeMismatch(expected: "room(event)", actual: routeLabel(route), entryId: entry.id) - } - chooseEventOption(optionIndex) - - case .eventChooseUpgrade(let deckIndex): - guard case .room(_, .event) = route else { - throw ReplayError.routeMismatch(expected: "room(event)", actual: routeLabel(route), entryId: entry.id) - } - chooseEventUpgradeCard(deckIndex: deckIndex) - - case .eventSkipUpgrade: - guard case .room(_, .event) = route else { - throw ReplayError.routeMismatch(expected: "room(event)", actual: routeLabel(route), entryId: entry.id) - } - skipEventUpgradeChoice() - - case .eventComplete: - guard case .room(_, .event) = route else { - throw ReplayError.routeMismatch(expected: "room(event)", actual: routeLabel(route), entryId: entry.id) - } - completeEventRoom() - } - } - - private func routeLabel(_ route: Route) -> String { - switch route { - case .map: - return "map" - case .room(_, let roomType): - return "room(\(roomType.rawValue))" - case .battle(_, let roomType): - return "battle(\(roomType.rawValue))" - case .reward(let rewardState): - return "reward(\(rewardState.roomType.rawValue)#\(rewardState.phase))" - case .chapterEnd(let prev, let next): - return "chapterEnd(\(prev)->\(next))" - case .runOver(_, let won, let floor): - return "runOver(won:\(won), floor:\(floor))" - } - } - private func finishBattleIfNeeded() { guard let battleEngine, battleEngine.state.isOver else { return } guard var runState else { return } @@ -1080,16 +840,6 @@ final class RunSession { battleSource = .mapNode eventBattleContext = nil refreshSnapshotPresence() - refreshTracePresence() - } - - func deleteSavedTrace() { - do { - try traceStore.delete() - refreshTracePresence() - } catch { - lastError = "Delete trace failed: \(error)" - } } private func consumeNewBattleEventSlice() -> ArraySlice { diff --git a/SaluNative/SaluAVP/ViewModels/RunTrace.swift b/SaluNative/SaluAVP/ViewModels/RunTrace.swift deleted file mode 100644 index bcb03d1..0000000 --- a/SaluNative/SaluAVP/ViewModels/RunTrace.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Foundation -import GameCore - -struct RunTrace: Codable, Sendable, Equatable { - static let currentVersion = 1 - - struct Entry: Codable, Sendable, Equatable, Identifiable { - let id: Int - let action: Action - - init(id: Int, action: Action) { - self.id = id - self.action = action - } - } - - enum Action: Codable, Sendable, Equatable { - case selectNode(nodeId: String) - - case selectEnemy(entityId: String) - case playCard(handIndex: Int, targetEnemyEntityId: String?) - case endTurn - - case chooseRelicReward(take: Bool) - case chooseCardReward(cardId: String?) // nil = skip - case continueAfterChapterEnd - - case restHeal - case restUpgrade(deckIndex: Int) - - case shopBuyCard(offerIndex: Int) - case shopBuyRelic(offerIndex: Int) - case shopBuyConsumable(offerIndex: Int) - case shopRemoveCard(deckIndex: Int) - case leaveShop - - case eventChooseOption(optionIndex: Int) - case eventChooseUpgrade(deckIndex: Int) - case eventSkipUpgrade - case eventComplete - } - - let version: Int - let seed: UInt64 - private(set) var entries: [Entry] - - init(seed: UInt64, entries: [Entry] = []) { - self.version = Self.currentVersion - self.seed = seed - self.entries = entries - } - - mutating func append(_ action: Action) { - entries.append(Entry(id: entries.count, action: action)) - } -} - From b12160317eb53de8f52c4774dd57a96abecc38b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Tue, 10 Feb 2026 00:29:04 +0800 Subject: [PATCH 24/29] =?UTF-8?q?Revert=20"feat:=20P6-=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E5=BF=AB=E7=85=A7=E5=AD=98=E5=82=A8=E5=92=8C?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E3=80=81=E5=8A=A0=E8=BD=BD=E5=92=8C=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E5=BF=AB=E7=85=A7"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 97f7fba4c20b5ecabdb7e1480945fc84d28ed2c3. --- ...p-full-ui-animation-implementation-plan.md | 8 +- .../ControlPanel/ControlPanelView.swift | 30 ------ .../Persistence/AVPDataDirectory.swift | 23 ----- .../Persistence/AVPRunSnapshotStore.swift | 49 ---------- .../SaluAVP/ViewModels/RunSession.swift | 76 --------------- Sources/GameCore/Run/RunSnapshotMapper.swift | 92 ------------------- 6 files changed, 4 insertions(+), 274 deletions(-) delete mode 100644 SaluNative/SaluAVP/Persistence/AVPDataDirectory.swift delete mode 100644 SaluNative/SaluAVP/Persistence/AVPRunSnapshotStore.swift delete mode 100644 Sources/GameCore/Run/RunSnapshotMapper.swift 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 index 2f0a70c..9e173e8 100644 --- 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 @@ -362,9 +362,9 @@ --- -### ✅P6:存档与 Continue(控制面板能力补齐) +### P6:存档与 Continue(控制面板能力补齐) -### ✅Task 18: AVP 快照存储层(RunSnapshot) +### Task 18: AVP 快照存储层(RunSnapshot) **Files:** - Create: `SaluNative/SaluAVP/Persistence/AVPRunSnapshotStore.swift` - Create: `SaluNative/SaluAVP/Persistence/AVPDataDirectory.swift` @@ -379,7 +379,7 @@ **Step 3: 验证** - Manual: 新开 run -> 保存 -> 关闭重开 -> Continue 恢复。 -### ✅Task 19: 控制面板 Continue / Save / Reset UI +### Task 19: 控制面板 Continue / Save / Reset UI **Files:** - Modify: `SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift` - Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` @@ -393,7 +393,7 @@ **Step 3: 验证** - Manual: 有存档和无存档两条路径。 -### ✅Task 20: 自动保存策略 +### Task 20: 自动保存策略 **Files:** - Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` diff --git a/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift b/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift index 8bd6338..a702da8 100644 --- a/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift +++ b/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift @@ -26,31 +26,6 @@ struct ControlPanelView: View { .fontWeight(.semibold) } - HStack(spacing: 8) { - Button("Continue") { - runSession.continueFromSavedSnapshot() - } - .buttonStyle(.borderedProminent) - .disabled(!runSession.hasSavedRunSnapshot) - - Button("Save") { - runSession.saveRunSnapshot() - } - .buttonStyle(.bordered) - .disabled(runSession.runState == nil) - - Button("Delete Save") { - runSession.deleteSavedSnapshot() - } - .buttonStyle(.bordered) - .disabled(!runSession.hasSavedRunSnapshot) - - Button("Reset") { - runSession.resetToControlPanel() - } - .buttonStyle(.bordered) - } - if let error = runSession.lastError { Text(error) .font(.caption) @@ -61,11 +36,6 @@ struct ControlPanelView: View { Text("Route: \(routeLabel(runSession.route)) Current: \(run.currentNodeId ?? "-")") .font(.caption2) .foregroundStyle(.secondary) - if let autosaveError = runSession.lastAutosaveError { - Text("Autosave: \(autosaveError)") - .font(.caption2) - .foregroundStyle(.secondary) - } } else { Text("No run started.") .font(.caption) diff --git a/SaluNative/SaluAVP/Persistence/AVPDataDirectory.swift b/SaluNative/SaluAVP/Persistence/AVPDataDirectory.swift deleted file mode 100644 index 10bfb2d..0000000 --- a/SaluNative/SaluAVP/Persistence/AVPDataDirectory.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -enum AVPDataDirectory { - static func rootURL() throws -> URL { - if let override = ProcessInfo.processInfo.environment["SALU_DATA_DIR"], - !override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - let url = URL(fileURLWithPath: override, isDirectory: true).appendingPathComponent("SaluAVP", isDirectory: true) - try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) - return url - } - - let base = try FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - let url = base.appendingPathComponent("SaluAVP", isDirectory: true) - try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) - return url - } -} - diff --git a/SaluNative/SaluAVP/Persistence/AVPRunSnapshotStore.swift b/SaluNative/SaluAVP/Persistence/AVPRunSnapshotStore.swift deleted file mode 100644 index 06213e5..0000000 --- a/SaluNative/SaluAVP/Persistence/AVPRunSnapshotStore.swift +++ /dev/null @@ -1,49 +0,0 @@ -import Foundation -import GameCore - -struct AVPRunSnapshotStore: Sendable { - enum StoreError: Error, Sendable, Equatable { - case missing - case invalidData - } - - private let fileName = "run_snapshot.json" - - func snapshotExists() -> Bool { - (try? snapshotURL().checkResourceIsReachable()) == true - } - - func save(_ snapshot: RunSnapshot) throws { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(snapshot) - - let url = try snapshotURL() - try data.write(to: url, options: [.atomic]) - } - - func load() throws -> RunSnapshot { - let url = try snapshotURL() - guard (try? url.checkResourceIsReachable()) == true else { - throw StoreError.missing - } - - let data = try Data(contentsOf: url) - let decoder = JSONDecoder() - guard let snapshot = try? decoder.decode(RunSnapshot.self, from: data) else { - throw StoreError.invalidData - } - return snapshot - } - - func delete() throws { - let url = try snapshotURL() - guard (try? url.checkResourceIsReachable()) == true else { return } - try FileManager.default.removeItem(at: url) - } - - private func snapshotURL() throws -> URL { - try AVPDataDirectory.rootURL().appendingPathComponent(fileName, isDirectory: false) - } -} - diff --git a/SaluNative/SaluAVP/ViewModels/RunSession.swift b/SaluNative/SaluAVP/ViewModels/RunSession.swift index f87d510..d78109b 100644 --- a/SaluNative/SaluAVP/ViewModels/RunSession.swift +++ b/SaluNative/SaluAVP/ViewModels/RunSession.swift @@ -47,14 +47,6 @@ final class RunSession { private var battleSource: BattleSource = .mapNode private var eventBattleContext: EventBattleContext? - private let snapshotStore = AVPRunSnapshotStore() - private(set) var hasSavedRunSnapshot: Bool = false - private(set) var lastAutosaveError: String? - - init() { - refreshSnapshotPresence() - } - var selectedEnemyDisplayName: String? { guard let battleState, let selectedEnemyIndex else { return nil } guard battleState.enemies.indices.contains(selectedEnemyIndex) else { return nil } @@ -92,8 +84,6 @@ final class RunSession { battleSource = .mapNode eventBattleContext = nil route = .map - refreshSnapshotPresence() - autosaveIfNeeded() } func selectAccessibleNode(_ nodeId: String) { @@ -101,7 +91,6 @@ final class RunSession { guard runState.enterNode(nodeId) else { return } self.runState = runState - autosaveIfNeeded() guard let node = runState.map.node(withId: nodeId) else { lastError = "Node not found: \(nodeId)" @@ -136,8 +125,6 @@ final class RunSession { } else { route = .map } - - autosaveIfNeeded() } func restHeal() { @@ -562,7 +549,6 @@ final class RunSession { rewardState.phase = .card route = .reward(rewardState) - autosaveIfNeeded() } func chooseCardReward(_ cardId: CardID?) { @@ -596,70 +582,11 @@ final class RunSession { route = .map } } - - autosaveIfNeeded() } func continueAfterChapterEnd() { guard case .chapterEnd = route else { return } route = .map - autosaveIfNeeded() - } - - func saveRunSnapshot() { - guard let runState else { return } - do { - let snapshot = RunSnapshotMapper.makeSnapshot(from: runState) - try snapshotStore.save(snapshot) - hasSavedRunSnapshot = true - lastError = nil - } catch { - lastError = "Save failed: \(error)" - } - } - - func continueFromSavedSnapshot() { - do { - let snapshot = try snapshotStore.load() - let restored = try RunSnapshotMapper.loadRunState(from: snapshot) - seed = restored.seed - seedText = String(restored.seed) - runState = restored - lastError = nil - clearBattleState(preserveSnapshot: false) - clearRoomState(clearEventBattleContext: true) - route = restored.isOver ? .runOver(lastNodeId: restored.currentNodeId ?? "unknown", won: restored.won, floor: restored.floor) : .map - hasSavedRunSnapshot = true - } catch { - lastError = "Continue failed: \(error)" - refreshSnapshotPresence() - } - } - - func deleteSavedSnapshot() { - do { - try snapshotStore.delete() - refreshSnapshotPresence() - } catch { - lastError = "Delete save failed: \(error)" - } - } - - private func autosaveIfNeeded() { - // Best-effort. Never block gameplay flow on persistence, and never clobber lastError. - guard let runState else { return } - do { - let snapshot = RunSnapshotMapper.makeSnapshot(from: runState) - try snapshotStore.save(snapshot) - hasSavedRunSnapshot = true - lastAutosaveError = nil - } catch { - lastAutosaveError = String(describing: error) - } - } - - private func refreshSnapshotPresence() { - hasSavedRunSnapshot = snapshotStore.snapshotExists() } private func finishBattleIfNeeded() { @@ -721,12 +648,10 @@ final class RunSession { relicReward: relicReward ) ) - autosaveIfNeeded() } else { clearBattleState(preserveSnapshot: false) clearRoomState(clearEventBattleContext: true) route = .runOver(lastNodeId: nodeId, won: false, floor: runState.floor) - autosaveIfNeeded() } } @@ -839,7 +764,6 @@ final class RunSession { eventRoomState = nil battleSource = .mapNode eventBattleContext = nil - refreshSnapshotPresence() } private func consumeNewBattleEventSlice() -> ArraySlice { diff --git a/Sources/GameCore/Run/RunSnapshotMapper.swift b/Sources/GameCore/Run/RunSnapshotMapper.swift deleted file mode 100644 index 3e55e7a..0000000 --- a/Sources/GameCore/Run/RunSnapshotMapper.swift +++ /dev/null @@ -1,92 +0,0 @@ -// RunSnapshot <-> RunState mapping lives in GameCore (pure logic, no I/O). - -public enum RunSnapshotMapper { - public enum LoadError: Error, Sendable, Equatable { - case incompatibleVersion(Int) - case invalidRoomType(String) - } - - public static func makeSnapshot(from run: RunState) -> RunSnapshot { - RunSnapshot( - version: RunSaveVersion.current, - seed: run.seed, - floor: run.floor, - maxFloor: run.maxFloor, - gold: run.gold, - mapNodes: run.map.map { node in - RunSnapshot.NodeData( - id: node.id, - row: node.row, - column: node.column, - roomType: node.roomType.rawValue, - connections: node.connections, - isCompleted: node.isCompleted, - isAccessible: node.isAccessible - ) - }, - currentNodeId: run.currentNodeId, - player: RunSnapshot.PlayerData( - maxHP: run.player.maxHP, - currentHP: run.player.currentHP, - statuses: Dictionary( - uniqueKeysWithValues: run.player.statuses.all.map { ($0.id.rawValue, $0.stacks) } - ) - ), - deck: run.deck.map { card in - RunSnapshot.CardData(id: card.id, cardId: card.cardId.rawValue) - }, - relicIds: run.relicManager.all.map(\.rawValue), - isOver: run.isOver, - won: run.won - ) - } - - public static func loadRunState(from snapshot: RunSnapshot) throws -> RunState { - guard RunSaveVersion.isCompatible(snapshot.version) else { - throw LoadError.incompatibleVersion(snapshot.version) - } - - let map: [MapNode] = try snapshot.mapNodes.map { node in - guard let roomType = RoomType(rawValue: node.roomType) else { - throw LoadError.invalidRoomType(node.roomType) - } - return MapNode( - id: node.id, - row: node.row, - column: node.column, - roomType: roomType, - connections: node.connections, - isCompleted: node.isCompleted, - isAccessible: node.isAccessible - ) - } - - var player = Entity(id: "player", name: LocalizedText("安德", "Ander"), maxHP: snapshot.player.maxHP) - player.currentHP = snapshot.player.currentHP - for (raw, stacks) in snapshot.player.statuses { - player.statuses.set(StatusID(raw), stacks: stacks) - } - - let deck = snapshot.deck.map { Card(id: $0.id, cardId: CardID($0.cardId)) } - var relicManager = RelicManager() - for raw in snapshot.relicIds { - relicManager.add(RelicID(raw)) - } - - var run = RunState( - player: player, - deck: deck, - gold: snapshot.gold, - relicManager: relicManager, - map: map, - seed: snapshot.seed, - floor: snapshot.floor, - maxFloor: snapshot.maxFloor - ) - run.currentNodeId = snapshot.currentNodeId - run.isOver = snapshot.isOver - run.won = snapshot.won - return run - } -} - From 1192f09b2c3d294cb4eb4a5e9c3181ed2fefb18c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Tue, 10 Feb 2026 00:40:26 +0800 Subject: [PATCH 25/29] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E6=96=87?= =?UTF-8?q?=E6=A1=A3=EF=BC=8C=E6=B7=BB=E5=8A=A0=E5=85=B3=E4=BA=8E=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E8=A7=84=E5=88=99=E4=B8=8E=E7=BB=93=E7=AE=97=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E7=9A=84=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...\210\217\344\270\232\345\212\241\350\257\264\346\230\216.md" | 1 + ... \345\256\236\347\216\260\357\274\210SaluAVP\357\274\211.md" | 2 +- README-en.md | 2 +- README.md | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) 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/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` From c835efafd34988f9e2405ee11b847d874ca0c65e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Tue, 10 Feb 2026 00:40:41 +0800 Subject: [PATCH 26/29] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E5=9B=9E=E5=BD=92=E6=B8=85=E5=8D=95=EF=BC=8C=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E6=A0=B8=E5=BF=83=E7=8E=A9=E6=B3=95=E9=97=AD=E7=8E=AF?= =?UTF-8?q?=E5=B9=B6=E9=81=BF=E5=85=8D=E5=8A=9F=E8=83=BD=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...36\345\275\222\346\270\205\345\215\225.md" | 85 +++++++++++++++++++ ...p-full-ui-animation-implementation-plan.md | 33 +++---- .../GameCoreTests/BattleEngineFlowTests.swift | 24 +++++- 3 files changed, 125 insertions(+), 17 deletions(-) create mode 100644 ".github/docs/SaluAVP-\346\211\213\345\212\250\345\233\236\345\275\222\346\270\205\345\215\225.md" 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/plans/2026-02-09-saluavp-full-ui-animation-implementation-plan.md b/.github/plans/2026-02-09-saluavp-full-ui-animation-implementation-plan.md index 9e173e8..c838324 100644 --- 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 @@ -2,14 +2,14 @@ > 执行方式:建议使用 `executing-plans` 按批次实现与验收。 -**Goal(目标):** 以 XR 原生、3D 原生为唯一方向,对 `SaluAVP` 做破坏性重构:完整补齐战斗动画、目标选择、多敌人、房间 UI(休息/商店/事件)、奖励链路(含精英/Boss 遗物)、存档 Continue 与回放观测,并在每个阶段及时删除老旧实现与兼容层。 +**Goal(目标):** 以 XR 原生、3D 原生为唯一方向,对 `SaluAVP` 做破坏性重构:完整补齐战斗动画、目标选择、多敌人、房间 UI(休息/商店/事件)、奖励链路(含精英/Boss 遗物),并在每个阶段及时删除老旧实现与兼容层。 **Non-goals(非目标):** 不做写实资产管线重建;不引入网络同步;不修改 `GameCore` 既有规则语义;不把 `RealityKit` 代码抽到 `Sources/`;不保留旧 AVP UI/路由 API 的向后兼容桥接。 **Approach(方案):** 1) 先建立“状态推进(GameCore)”与“表现驱动(AVP)”之间的事件桥接层,替换当前 `ImmersiveRootView` 大一统渲染。 2) 优先做可复现、可验证的动画闭环(抽牌、出牌、受击、死亡、牌堆变化),并直接移除旧静态重建路径。 -3) 房间、奖励、存档、回放全部用新路由与新面板实现,不做旧路由兼容。 +3) 房间与奖励用新路由与新面板实现,不做旧路由兼容。 4) 每个阶段必须包含“实现 + 删除老代码 + 验证”三连动作,禁止新旧双轨长期并存。 **Acceptance(验收):** @@ -17,10 +17,8 @@ 2) 支持多敌人并可明确选目标;`.singleEnemy` 卡在多敌人战斗中可稳定落点。 3) 休息、商店、事件三类房间在 AVP 可完成完整交互并正确推进 `RunState`。 4) 精英/Boss 胜利后奖励链路与 CLI 业务一致(金币/卡牌/遗物)。 -5) 2D 控制面板支持 Continue(从快照恢复)与自动保存。 -6) 可导出一局关键选择路径并用于重放验证。 -7) 旧 AVP 占位逻辑被删除(无旧 room panel 占位完成路径、无旧 battle 静态分支)。 -8) 修改 `SaluNative/**` 后 `xcodebuild` 可通过;修改 `Sources/**` 后 `swift test` 可通过。 +5) 旧 AVP 占位逻辑被删除(无旧 room panel 占位完成路径、无旧 battle 静态分支)。 +6) 修改 `SaluNative/**` 后 `xcodebuild` 可通过;修改 `Sources/**` 后 `swift test` 可通过。 --- @@ -30,7 +28,7 @@ 2) 不保留“兼容桥接层”超过一个任务周期:新实现落地的同批次必须删除旧实现。 3) 禁止新旧双轨渲染:同一能力只保留一条主路径。 4) 2D Window 仅承担控制面板与调试入口;核心玩法必须在 Immersive 3D 中完成。 -5) 存档兼容策略仅保证 `GameCore.RunSnapshot` 语义,不保证旧 AVP 私有结构可读。 +5) 2D 面板允许作为 HUD(例如商店商品信息/购买按钮、奖励选择),但不做“厚面板”或伪 3D UI。 --- @@ -362,7 +360,9 @@ --- -### P6:存档与 Continue(控制面板能力补齐) +### P6(Dropped):存档与 Continue(控制面板能力补齐) + +> 2026-02-09 决策:当前阶段不实现 AVP 存档/读档/Continue。相关实现已在仓库中回滚;本计划后续仅保留说明,不再执行本阶段任务。 ### Task 18: AVP 快照存储层(RunSnapshot) **Files:** @@ -408,7 +408,9 @@ --- -### P7:可观测性与回放(Determinism + Replay) +### P7(Dropped):可观测性与回放(Determinism + Replay) + +> 2026-02-09 决策:当前阶段不实现 AVP trace/replay。相关实现已在仓库中回滚;本计划后续仅保留说明,不再执行本阶段任务。 ### Task 21: 选择路径记录模型 **Files:** @@ -445,11 +447,10 @@ ### Task 23: GameCore 相关新增/变更测试补齐 **Files:** - Modify: `Tests/GameCoreTests/BattleEngineFlowTests.swift` -- Create: `Tests/GameCoreTests/BattleSeedAndEncounterParityTests.swift` -- Create: `Tests/GameCoreTests/RunSnapshotCodableRoundTripTests.swift` **Step 1: 最小验收** -- 新增逻辑(多敌人目标、奖励链路、快照读写)有单测覆盖。 +- 新增/变更逻辑(多敌人目标、奖励链路关键生成器)有单测覆盖。 +- 不新增 AVP 存档/回放相关测试要求(本阶段已明确 Drop)。 **Step 2: 验证** - Run: `swift test --filter GameCoreTests` @@ -460,7 +461,7 @@ - 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` @@ -508,10 +509,10 @@ - `swift test --filter GameCoreTests` - 手动:普通/精英/Boss 各 1 场,验证奖励一致性。 -3) 完成 P6-P8 后:交付回归 +3) 完成 P8 后:交付回归 - `swift test` - `xcodebuild ... build` -- 手动:新开局、Continue、重放导入全链路。 +- 手动:新开局、地图推进到各房间、完成一幕并继续下一幕(无存档/回放)。 --- @@ -519,4 +520,4 @@ 1) 已确认:本轮不纳入“甩牌/投掷命中(B1)”,留在后续迭代。 2) 事件房间中的“文本演出深度”目标(仅功能可用 vs 有完整剧情排版和动效)。 -3) 存档策略是否只保留“单槽自动保存”,还是支持多槽(多槽会显著增加 UI 与管理复杂度)。 +3) 商店“删牌服务”的交互形式:2D HUD 面板选择卡牌列表 vs 3D 牌堆实体点选(后者更沉浸但工期更长)。 diff --git a/Tests/GameCoreTests/BattleEngineFlowTests.swift b/Tests/GameCoreTests/BattleEngineFlowTests.swift index 69cab1d..c3f2c3b 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() { @@ -173,4 +196,3 @@ final class BattleEngineFlowTests: XCTestCase { XCTAssertEqual(engine.state.player.statuses.stacks(of: "poison"), 1) } } - From 5875d83594e45a216713d8d92e0b002ef809e9e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Tue, 10 Feb 2026 01:15:27 +0800 Subject: [PATCH 27/29] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20AVP=20?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E4=BB=BB=E5=8A=A1=E5=AE=A1=E8=AE=A1=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=EF=BC=8C=E5=8C=85=E5=90=AB=20TODO=20=E6=B8=85?= =?UTF-8?q?=E5=8D=95=E5=92=8C=E5=8F=91=E7=8E=B0=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...luavp-full-ui-animation-plan-task-audit.md | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 .github/plans/2026-02-10-saluavp-full-ui-animation-plan-task-audit.md diff --git a/.github/plans/2026-02-10-saluavp-full-ui-animation-plan-task-audit.md b/.github/plans/2026-02-10-saluavp-full-ui-animation-plan-task-audit.md new file mode 100644 index 0000000..38d383d --- /dev/null +++ b/.github/plans/2026-02-10-saluavp-full-ui-animation-plan-task-audit.md @@ -0,0 +1,210 @@ +# SaluAVP Plan Task Audit Report + +- Repo root: `/Users/chii_magnus/Github_OpenSource/salu` +- Target plan: `.github/plans/2026-02-09-saluavp-full-ui-animation-implementation-plan.md` +- Auditor: `plan-task-auditor` +- Scope note: Plan P6/P7 已在计划内标注 `Dropped`(本阶段不做存档/回放);本报告会把相关 Task 仍列入 TODO,但按“已 Drop”审查其代码/文件一致性。 + +--- + +## TODO Board (N=26) + +- [ ] Task 1: 建立 AVP 战斗事件消费接口(允许破坏旧 AVP 接口) +- [ ] Task 2: 抽离战斗渲染器,降低 `ImmersiveRootView` 复杂度 +- [ ] Task 3: 引入动画队列(先占位,不改交互) +- [ ] Task 4: 抽牌动画(DrawPile -> Hand) +- [ ] Task 5: 出牌动画(Hand -> Enemy / Pile) +- [ ] Task 6: 受击、格挡、死亡反馈 +- [ ] Task 7: 回合切换与 HUD 动效 +- [ ] Task 8: RunSession 启用多敌人遭遇初始化 +- [ ] Task 9: 战斗目标选择交互 +- [ ] Task 10: 目标选择边界处理与提示 +- [ ] Task 11: Rest 房间交互(休息/升级/对话) +- [ ] Task 12: Shop 房间交互(买卡/买遗物/买消耗/删牌) +- [ ] Task 13: Event 房间交互(选项 + Follow-up) +- [ ] Task 14: Event 触发精英战(followUp.startEliteBattle) +- [ ] Task 15: 统一 AVP 奖励路由模型 +- [ ] Task 16: 遗物奖励面板(精英/Boss) +- [ ] Task 17: Boss 章节收束和下一幕衔接 +- [ ] Task 18: AVP 快照存储层(RunSnapshot)(Dropped) +- [ ] Task 19: 控制面板 Continue / Save / Reset UI (Dropped) +- [ ] Task 20: 自动保存策略 (Dropped) +- [ ] Task 21: 选择路径记录模型 (Dropped) +- [ ] Task 22: Trace 导出与重放模式(开发向)(Dropped) +- [ ] Task 23: GameCore 相关新增/变更测试补齐 +- [ ] Task 24: AVP 手动回归清单固化 +- [ ] Task 25: 全量验证与交付前收敛 +- [ ] Task 26: 老旧代码清理闸门(每阶段结束必做) + +--- + +## Task-to-File Map (Existence) + +- Task 1: + - OK `SaluNative/SaluAVP/ViewModels/BattlePresentationEvent.swift` + - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` + - OK `Tests/GameCoreTests/BattleEventDescriptionTests.swift` + - OK `Tests/GameCoreTests/BattleEngineFlowEventOrderTests.swift` +- Task 2: + - OK `SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift` + - OK `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` + - OK `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` +- Task 3: + - OK `SaluNative/SaluAVP/Immersive/BattleAnimationQueue.swift` + - OK `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` +- Task 4: + - OK `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` + - OK `SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift` +- Task 5: + - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` + - OK `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` +- Task 6: + - OK `SaluNative/SaluAVP/Immersive/FloatingTextFactory.swift` + - OK `SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift` + - OK `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` +- Task 7: + - OK `SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift` + - OK `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` +- Task 8: + - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` +- Task 9: + - OK `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` + - OK `SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift` + - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` +- Task 10: + - OK `SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift` + - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` +- Task 11: + - OK `SaluNative/SaluAVP/Immersive/RestRoomPanel.swift` + - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` + - OK `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` +- Task 12: + - OK `SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift` + - OK `SaluNative/SaluAVP/ViewModels/ShopRoomState.swift` + - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` +- Task 13: + - OK `SaluNative/SaluAVP/Immersive/EventRoomPanel.swift` + - OK `SaluNative/SaluAVP/ViewModels/EventRoomState.swift` + - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` +- Task 14: + - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` + - OK `SaluNative/SaluAVP/Immersive/EventRoomPanel.swift` +- Task 15: + - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` + - OK `SaluNative/SaluAVP/ViewModels/RewardRouteState.swift` +- Task 16: + - OK `SaluNative/SaluAVP/Immersive/RelicRewardPanel.swift` + - OK `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` +- Task 17: + - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` + - OK `SaluNative/SaluAVP/Immersive/ChapterEndPanel.swift` +- Task 18 (Dropped): + - MISSING `SaluNative/SaluAVP/Persistence/AVPRunSnapshotStore.swift` (expected dropped) + - MISSING `SaluNative/SaluAVP/Persistence/AVPDataDirectory.swift` (expected dropped) + - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` (contains no persistence) +- Task 19 (Dropped): + - OK `SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift` (contains no Continue/Save UI) + - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` (contains no Continue/Save logic) +- Task 20 (Dropped): + - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` (contains no autosave) +- Task 21 (Dropped): + - MISSING `SaluNative/SaluAVP/ViewModels/RunTrace.swift` (expected dropped) + - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` (contains no trace) +- Task 22 (Dropped): + - MISSING `SaluNative/SaluAVP/ControlPanel/ReplayPanel.swift` (expected dropped) + - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` (contains no replay) +- Task 23: + - OK `Tests/GameCoreTests/BattleEngineFlowTests.swift` +- Task 24: + - OK `.github/docs/SaluAVP-手动回归清单.md` + - OK `.github/plans/Apple Vision Pro 原生 3D 实现(SaluAVP).md` +- Task 25: + - OK `README.md` + - OK `README-en.md` + - OK `.github/docs/Salu游戏业务说明.md` +- Task 26: + - OK `.github/docs/SaluAVP-手动回归清单.md` + - OK `.github/plans/2026-02-09-saluavp-full-ui-animation-implementation-plan.md` + +--- + +## Findings (Open First) + +## Finding F-01 + +- Task: `Task 6: 受击、格挡、死亡反馈` +- Severity: `High` +- Status: `Open` +- Location: `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift:245` +- Summary: `damageDealt/blockGained` 的反馈永远打在 `enemyRoot.children.first`,多敌人时会给错目标;并且无法区分“玩家受击”和“某个敌人受击”。 +- Risk: 多敌人战斗反馈错误,目标选择的价值被削弱;容易造成“我明明打了 B,但动画/飘字出现在 A”。 +- Expected fix: 最小改动下让 hit/block 反馈基于“稳定目标标识”路由到正确实体;不依赖名字字符串匹配(避免双同名敌人歧义)。 +- Validation: `swift test --filter GameCoreTests` + `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build` +- Resolution evidence: (pending) + +## Finding F-02 + +- Task: `Task 2: 抽离战斗渲染器,降低 ImmersiveRootView 复杂度` +- Severity: `High` +- Status: `Open` +- Location: `SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift:85` +- Summary: `enemyRoot` 在每次 `render(...)` 都会 `removeFromParent()` 并重建,导致对敌人的 pulse/受击缩放等“持续动画”在下一帧被销毁,表现为抖一下就瞬间复位。 +- Risk: 动画系统看似工作,但核心反馈持续时间被帧刷新打断;多敌人时更明显(每帧重建更重,也更丑)。 +- Expected fix: 让敌人渲染从“每帧重建”改为“保留实体并增量更新”(按 enemy id 复用、按 alive/selected 状态更新材质/marker),仅在敌人集合变化时增删。 +- Validation: `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build` + 手动在多敌人战斗里观察受击脉冲持续 0.2s 以上不被重置。 +- Resolution evidence: (pending) + +## Finding F-03 + +- Task: `Task 1: 建立 AVP 战斗事件消费接口(允许破坏旧 AVP 接口)` +- Severity: `Medium` +- Status: `Open` +- Location: `SaluNative/SaluAVP/ViewModels/RunSession.swift:966` (see `clearBattleState(preserveSnapshot:)`) +- Summary: `playerWon` 进入 `reward` 路由时,`clearBattleState(preserveSnapshot: true)` 会把 `lastConsumedBattleEventIndex` 重置为 0,导致在奖励界面再次“从头消费一遍 battleEvents”,违反“自上次消费后新增事件”的语义。 +- Risk: 奖励界面可能重复触发抽牌/回合等动画队列,表现为随机闪动/噪声;也会让 event bridge 的“增量消费”难以推理。 +- Expected fix: `preserveSnapshot == true` 时不要重置 `lastConsumedBattleEventIndex`(或直接推进到 `battleEvents.count`),并明确 reward 界面不回放整场战斗事件。 +- Validation: 手动:打一场战斗胜利后进入奖励界面,不再触发“抽牌/回合开始”类动画;并保持后续回到地图正常。 +- Resolution evidence: (pending) + +## Finding F-04 + +- Task: `Task 5: 出牌动画(Hand -> Enemy / Pile)` +- Severity: `Medium` +- Status: `Open` +- Location: `SaluNative/SaluAVP/ViewModels/RunSession.swift:788` +- Summary: 出牌动画当前只从手牌飞向牌堆(discard/exhaust),没有“命中目标”的视觉阶段;且 `PlayedCardPresentationContext` 不包含目标实体信息。 +- Risk: 出牌缺乏因果链(打到谁),“选目标”与“造成伤害”之间缺少视觉连接,尤其多敌人战斗可读性差。 +- Expected fix: 在 AVP 层为 `.played` 事件补足“本次出牌的目标 enemyId(若有)”,动画先飞向目标再落入牌堆;不需要改 GameCore 规则。 +- Validation: 手动:多敌人战斗选中目标出牌,卡牌先飞向目标再回收;无目标牌仍飞向牌堆。 +- Resolution evidence: (pending) + +## Finding F-05 + +- Task: `Task 18: AVP 快照存储层(RunSnapshot)` +- Severity: `Low` +- Status: `Open` +- Location: `.github/plans/2026-02-09-saluavp-full-ui-animation-implementation-plan.md:367` +- Summary: Plan P6/P7 已标 `Dropped`,但 Task 18/21/22 的文件清单仍指向当前不存在的实现文件,容易误导后续执行者。 +- Risk: 执行偏离当前决策;审查时无法快速判断“缺文件是 bug 还是刻意 drop”。 +- Expected fix: 将 Task 18/21/22 标题也明确标注 `Dropped`,并在 Files 段落注明“已回滚/本阶段不执行”。 +- Validation: 文档审查即可(无代码验证要求)。 +- Resolution evidence: (pending) + +--- + +## Fix Log (Reserved) + +> 本节在修复阶段填写:每条 Finding 的改动摘要与对应验证证据。 + +--- + +## Validation Log (Reserved) + +- (pending) + +--- + +## Current Status + +- Findings Open: `F-01..F-05` +- Next step: Apply fixes in priority order (High -> Medium -> Low), then update this report with `Resolved` statuses and validation evidence. From 9c7efd80c0e824382064375acbb635df50efe4c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Tue, 10 Feb 2026 01:15:54 +0800 Subject: [PATCH 28/29] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20AVP=20?= =?UTF-8?q?=E6=88=98=E6=96=97=E4=BA=8B=E4=BB=B6=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E5=AE=9E=E4=BD=93=20ID=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E5=8A=A8=E7=94=BB=E5=8F=8D=E9=A6=88?= =?UTF-8?q?=20-=20=E4=BF=AE=E6=94=B9=20BattleEvent=20=E7=BB=93=E6=9E=84?= =?UTF-8?q?=EF=BC=8C=E6=96=B0=E5=A2=9E=20targetEntityId=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=20-=20=E6=9B=B4=E6=96=B0=20BattleEngine=EF=BC=8Cemit?= =?UTF-8?q?=20=E4=BA=8B=E4=BB=B6=E6=97=B6=E4=BC=A0=E9=80=92=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E5=AE=9E=E4=BD=93=20ID=20-=20=E8=B0=83=E6=95=B4=20Bat?= =?UTF-8?q?tleAnimationQueue=20=E5=92=8C=20BattleAnimationSystem=EF=BC=8C?= =?UTF-8?q?=E7=A1=AE=E4=BF=9D=E5=8A=A8=E7=94=BB=E5=8F=8D=E9=A6=88=E5=9F=BA?= =?UTF-8?q?=E4=BA=8E=E7=9B=AE=E6=A0=87=E5=AE=9E=E4=BD=93=20ID=20-=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3=E6=B5=8B=E8=AF=95=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=EF=BC=8C=E7=A1=AE=E4=BF=9D=E6=96=B0=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E7=9A=84=E6=AD=A3=E7=A1=AE=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...p-full-ui-animation-implementation-plan.md | 20 +++++-- ...luavp-full-ui-animation-plan-task-audit.md | 34 +++++++----- .../Immersive/BattleAnimationQueue.swift | 10 ++-- .../Immersive/BattleAnimationSystem.swift | 53 +++++++++++++++---- .../Immersive/BattleSceneRenderer.swift | 48 +++++++++++++++-- .../ViewModels/BattlePresentationEvent.swift | 1 + .../SaluAVP/ViewModels/RunSession.swift | 22 ++++++-- .../GameCLI/Components/EventFormatter.swift | 10 ++-- Sources/GameCore/Battle/BattleEngine.swift | 46 +++++++++++++--- Sources/GameCore/Events.swift | 27 ++++++---- .../GameCoreTests/BattleEngineFlowTests.swift | 1 + .../BattleEventDescriptionTests.swift | 12 ++--- .../SeerRelicsAndMadnessTests.swift | 2 +- 13 files changed, 215 insertions(+), 71 deletions(-) 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 index c838324..c73eb44 100644 --- 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 @@ -364,7 +364,9 @@ > 2026-02-09 决策:当前阶段不实现 AVP 存档/读档/Continue。相关实现已在仓库中回滚;本计划后续仅保留说明,不再执行本阶段任务。 -### Task 18: AVP 快照存储层(RunSnapshot) +### Task 18(Dropped): AVP 快照存储层(RunSnapshot) + +> Dropped:本阶段不实现;相关代码已回滚。以下内容仅保留为历史设计记录。 **Files:** - Create: `SaluNative/SaluAVP/Persistence/AVPRunSnapshotStore.swift` - Create: `SaluNative/SaluAVP/Persistence/AVPDataDirectory.swift` @@ -379,7 +381,9 @@ **Step 3: 验证** - Manual: 新开 run -> 保存 -> 关闭重开 -> Continue 恢复。 -### Task 19: 控制面板 Continue / Save / Reset UI +### Task 19(Dropped): 控制面板 Continue / Save / Reset UI + +> Dropped:本阶段不实现;相关代码已回滚。以下内容仅保留为历史设计记录。 **Files:** - Modify: `SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift` - Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` @@ -393,7 +397,9 @@ **Step 3: 验证** - Manual: 有存档和无存档两条路径。 -### Task 20: 自动保存策略 +### Task 20(Dropped): 自动保存策略 + +> Dropped:本阶段不实现;相关代码已回滚。以下内容仅保留为历史设计记录。 **Files:** - Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` @@ -412,7 +418,9 @@ > 2026-02-09 决策:当前阶段不实现 AVP trace/replay。相关实现已在仓库中回滚;本计划后续仅保留说明,不再执行本阶段任务。 -### Task 21: 选择路径记录模型 +### Task 21(Dropped): 选择路径记录模型 + +> Dropped:本阶段不实现;相关代码已回滚。以下内容仅保留为历史设计记录。 **Files:** - Create: `SaluNative/SaluAVP/ViewModels/RunTrace.swift` - Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` @@ -426,7 +434,9 @@ **Step 3: 验证** - Manual: 完成 1 场战斗后导出 trace,内容完整。 -### Task 22: Trace 导出与重放模式(开发向) +### Task 22(Dropped): Trace 导出与重放模式(开发向) + +> Dropped:本阶段不实现;相关代码已回滚。以下内容仅保留为历史设计记录。 **Files:** - Create: `SaluNative/SaluAVP/ControlPanel/ReplayPanel.swift` - Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` diff --git a/.github/plans/2026-02-10-saluavp-full-ui-animation-plan-task-audit.md b/.github/plans/2026-02-10-saluavp-full-ui-animation-plan-task-audit.md index 38d383d..9160252 100644 --- a/.github/plans/2026-02-10-saluavp-full-ui-animation-plan-task-audit.md +++ b/.github/plans/2026-02-10-saluavp-full-ui-animation-plan-task-audit.md @@ -134,77 +134,83 @@ - Task: `Task 6: 受击、格挡、死亡反馈` - Severity: `High` -- Status: `Open` +- Status: `Resolved` - Location: `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift:245` - Summary: `damageDealt/blockGained` 的反馈永远打在 `enemyRoot.children.first`,多敌人时会给错目标;并且无法区分“玩家受击”和“某个敌人受击”。 - Risk: 多敌人战斗反馈错误,目标选择的价值被削弱;容易造成“我明明打了 B,但动画/飘字出现在 A”。 - Expected fix: 最小改动下让 hit/block 反馈基于“稳定目标标识”路由到正确实体;不依赖名字字符串匹配(避免双同名敌人歧义)。 - Validation: `swift test --filter GameCoreTests` + `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build` -- Resolution evidence: (pending) +- Resolution evidence: `BattleEvent` 为 damage/block/status 相关事件补齐 entity id;AVP 动画队列把 `targetEntityId` 写入 job,并在动画系统里按 `enemy:` 精确寻址(玩家则落到手牌根节点附近)。验证见下方 Validation Log。 ## Finding F-02 - Task: `Task 2: 抽离战斗渲染器,降低 ImmersiveRootView 复杂度` - Severity: `High` -- Status: `Open` +- Status: `Resolved` - Location: `SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift:85` - Summary: `enemyRoot` 在每次 `render(...)` 都会 `removeFromParent()` 并重建,导致对敌人的 pulse/受击缩放等“持续动画”在下一帧被销毁,表现为抖一下就瞬间复位。 - Risk: 动画系统看似工作,但核心反馈持续时间被帧刷新打断;多敌人时更明显(每帧重建更重,也更丑)。 - Expected fix: 让敌人渲染从“每帧重建”改为“保留实体并增量更新”(按 enemy id 复用、按 alive/selected 状态更新材质/marker),仅在敌人集合变化时增删。 - Validation: `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build` + 手动在多敌人战斗里观察受击脉冲持续 0.2s 以上不被重置。 -- Resolution evidence: (pending) +- Resolution evidence: `BattleSceneRenderer.renderEnemies(...)` 改为按 enemy id 复用实体并增量更新,避免每帧销毁敌人实体导致动画中断。构建验证见 Validation Log。 ## Finding F-03 - Task: `Task 1: 建立 AVP 战斗事件消费接口(允许破坏旧 AVP 接口)` - Severity: `Medium` -- Status: `Open` +- Status: `Resolved` - Location: `SaluNative/SaluAVP/ViewModels/RunSession.swift:966` (see `clearBattleState(preserveSnapshot:)`) - Summary: `playerWon` 进入 `reward` 路由时,`clearBattleState(preserveSnapshot: true)` 会把 `lastConsumedBattleEventIndex` 重置为 0,导致在奖励界面再次“从头消费一遍 battleEvents”,违反“自上次消费后新增事件”的语义。 - Risk: 奖励界面可能重复触发抽牌/回合等动画队列,表现为随机闪动/噪声;也会让 event bridge 的“增量消费”难以推理。 - Expected fix: `preserveSnapshot == true` 时不要重置 `lastConsumedBattleEventIndex`(或直接推进到 `battleEvents.count`),并明确 reward 界面不回放整场战斗事件。 - Validation: 手动:打一场战斗胜利后进入奖励界面,不再触发“抽牌/回合开始”类动画;并保持后续回到地图正常。 -- Resolution evidence: (pending) +- Resolution evidence: `clearBattleState(preserveSnapshot: true)` 现在会把 `lastConsumedBattleEventIndex` 置为 `battleEvents.count`,reward 界面不再回放整场战斗事件。 ## Finding F-04 - Task: `Task 5: 出牌动画(Hand -> Enemy / Pile)` - Severity: `Medium` -- Status: `Open` +- Status: `Resolved` - Location: `SaluNative/SaluAVP/ViewModels/RunSession.swift:788` - Summary: 出牌动画当前只从手牌飞向牌堆(discard/exhaust),没有“命中目标”的视觉阶段;且 `PlayedCardPresentationContext` 不包含目标实体信息。 - Risk: 出牌缺乏因果链(打到谁),“选目标”与“造成伤害”之间缺少视觉连接,尤其多敌人战斗可读性差。 - Expected fix: 在 AVP 层为 `.played` 事件补足“本次出牌的目标 enemyId(若有)”,动画先飞向目标再落入牌堆;不需要改 GameCore 规则。 - Validation: 手动:多敌人战斗选中目标出牌,卡牌先飞向目标再回收;无目标牌仍飞向牌堆。 -- Resolution evidence: (pending) +- Resolution evidence: `PlayedCardPresentationContext` 增加 `targetEnemyEntityId`,`BattleAnimationSystem` 出牌动画在有目标时先飞向 `enemy:` 再落入牌堆。 ## Finding F-05 - Task: `Task 18: AVP 快照存储层(RunSnapshot)` - Severity: `Low` -- Status: `Open` +- Status: `Resolved` - Location: `.github/plans/2026-02-09-saluavp-full-ui-animation-implementation-plan.md:367` - Summary: Plan P6/P7 已标 `Dropped`,但 Task 18/21/22 的文件清单仍指向当前不存在的实现文件,容易误导后续执行者。 - Risk: 执行偏离当前决策;审查时无法快速判断“缺文件是 bug 还是刻意 drop”。 - Expected fix: 将 Task 18/21/22 标题也明确标注 `Dropped`,并在 Files 段落注明“已回滚/本阶段不执行”。 - Validation: 文档审查即可(无代码验证要求)。 -- Resolution evidence: (pending) +- Resolution evidence: Task 18-22 标题已标注 `Dropped` 并追加说明行(保留为历史设计记录)。 --- ## Fix Log (Reserved) -> 本节在修复阶段填写:每条 Finding 的改动摘要与对应验证证据。 +- F-01: `BattleEvent` 补齐 entity id;AVP hit/block 动画按 id 寻址目标实体。 +- F-02: 战斗敌人渲染改为增量更新(按 enemy id 复用实体),避免每帧重建打断动画。 +- F-03: reward 快照模式下不再重置 battle event 消费游标,避免奖励界面回放整场事件。 +- F-04: 出牌动画补齐目标 enemy id,支持“飞向目标再回收至牌堆”。 +- F-05: Plan 中 Task 18-22 明确标注 `Dropped` 并说明已回滚。 --- ## Validation Log (Reserved) -- (pending) +- `swift test --filter GameCoreTests` PASS +- `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build` PASS --- ## Current Status -- Findings Open: `F-01..F-05` -- Next step: Apply fixes in priority order (High -> Medium -> Low), then update this report with `Resolved` statuses and validation evidence. +- Findings Open: none +- Residual risks: + - 需要手动确认:多敌人战斗中,enemy 位置更新不会与受击 pulse 产生视觉冲突(目前 pulse 只做 scale,不改 translation)。 diff --git a/SaluNative/SaluAVP/Immersive/BattleAnimationQueue.swift b/SaluNative/SaluAVP/Immersive/BattleAnimationQueue.swift index c4ccace..0e259f0 100644 --- a/SaluNative/SaluAVP/Immersive/BattleAnimationQueue.swift +++ b/SaluNative/SaluAVP/Immersive/BattleAnimationQueue.swift @@ -71,24 +71,26 @@ struct BattleAnimationQueue { AnimationJob(sequence: event.sequence, kind: .pileUpdate, summary: "pileUpdate:play") ] - case .damageDealt(_, _, let amount, let blocked): + case .damageDealt(_, _, let targetEntityId, _, let amount, let blocked): return [ AnimationJob( sequence: event.sequence, kind: .hit, summary: "damageDealt", amount: amount, - blocked: blocked + blocked: blocked, + entityId: targetEntityId ) ] - case .blockGained(_, let amount): + case .blockGained(let targetEntityId, _, let amount): return [ AnimationJob( sequence: event.sequence, kind: .block, summary: "blockGained", - amount: amount + amount: amount, + entityId: targetEntityId ) ] diff --git a/SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift b/SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift index 1ff7315..ba98717 100644 --- a/SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift +++ b/SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift @@ -86,12 +86,13 @@ final class BattleAnimationSystem { job: job, in: battleLayer, handRoot: handRoot, + enemyRoot: enemyRoot, pilesRoot: pilesRoot ) case .hit: - playDamageFeedback(job: job, in: battleLayer, enemyRoot: enemyRoot) + playDamageFeedback(job: job, in: battleLayer, handRoot: handRoot, enemyRoot: enemyRoot) case .block: - playBlockFeedback(job: job, in: battleLayer, enemyRoot: enemyRoot) + playBlockFeedback(job: job, in: battleLayer, handRoot: handRoot, enemyRoot: enemyRoot) case .die: playDeathAnimation(job: job, in: battleLayer, enemyRoot: enemyRoot) case .turnStart: @@ -192,6 +193,7 @@ final class BattleAnimationSystem { job: BattleAnimationQueue.AnimationJob, in battleLayer: RealityKit.Entity, handRoot: RealityKit.Entity?, + enemyRoot: RealityKit.Entity?, pilesRoot: RealityKit.Entity? ) { guard let context = job.playedCardContext else { return } @@ -226,23 +228,41 @@ final class BattleAnimationSystem { endTransform.scale = SIMD3(repeating: 0.58) endTransform.rotation = pileTransform.rotation * simd_quatf(angle: .pi * 0.75, axis: [0, 1, 0]) - 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)) + 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() } - tempCard?.removeFromParent() } } private func playDamageFeedback( job: BattleAnimationQueue.AnimationJob, in battleLayer: RealityKit.Entity, + handRoot: RealityKit.Entity?, enemyRoot: RealityKit.Entity? ) { - guard let target = enemyRoot?.children.first else { return } + 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 { @@ -267,9 +287,10 @@ final class BattleAnimationSystem { private func playBlockFeedback( job: BattleAnimationQueue.AnimationJob, in battleLayer: RealityKit.Entity, + handRoot: RealityKit.Entity?, enemyRoot: RealityKit.Entity? ) { - guard let target = enemyRoot?.children.first else { return } + 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( @@ -326,6 +347,16 @@ final class BattleAnimationSystem { } } + 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 } diff --git a/SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift b/SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift index 4aa07a1..2faaaa2 100644 --- a/SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift +++ b/SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift @@ -82,7 +82,6 @@ final class BattleSceneRenderer { enemyRoot: enemyRoot ) - enemyRoot.children.forEach { $0.removeFromParent() } renderEnemies(enemies: engine.state.enemies, selectedEnemyIndex: selectedEnemyIndex, in: enemyRoot) let hand = engine.state.hand @@ -168,7 +167,6 @@ final class BattleSceneRenderer { enemyRoot: enemyRoot ) - enemyRoot.children.forEach { $0.removeFromParent() } renderEnemies(enemies: state.enemies, selectedEnemyIndex: nil, in: enemyRoot) if let headAnchor = battleLayer.findEntity(named: Names.battleHeadAnchor) { @@ -316,12 +314,54 @@ final class BattleSceneRenderer { 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 = makeEnemyEntity(enemy: enemy, isSelected: isSelected) + 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) - enemyRoot.addChild(enemyEntity) + } + } + + 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() } } diff --git a/SaluNative/SaluAVP/ViewModels/BattlePresentationEvent.swift b/SaluNative/SaluAVP/ViewModels/BattlePresentationEvent.swift index f1f9f86..db61648 100644 --- a/SaluNative/SaluAVP/ViewModels/BattlePresentationEvent.swift +++ b/SaluNative/SaluAVP/ViewModels/BattlePresentationEvent.swift @@ -9,6 +9,7 @@ enum PlayedCardDestinationPile: String, Sendable, Equatable { struct PlayedCardPresentationContext: Sendable, Equatable { let sourceHandIndex: Int let destinationPile: PlayedCardDestinationPile + let targetEnemyEntityId: String? } /// AVP 表现层使用的战斗事件包装,包含稳定序号以支持动画队列消费。 diff --git a/SaluNative/SaluAVP/ViewModels/RunSession.swift b/SaluNative/SaluAVP/ViewModels/RunSession.swift index d78109b..fecb969 100644 --- a/SaluNative/SaluAVP/ViewModels/RunSession.swift +++ b/SaluNative/SaluAVP/ViewModels/RunSession.swift @@ -443,6 +443,13 @@ final class RunSession { 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( @@ -451,7 +458,11 @@ final class RunSession { ) ) syncBattleStateFromEngine() - capturePlayedCardContexts(startIndex: eventStartIndex, sourceHandIndex: handIndex) + capturePlayedCardContexts( + startIndex: eventStartIndex, + sourceHandIndex: handIndex, + targetEnemyEntityId: targetEnemyEntityId + ) if succeeded { battleTargetHint = nil } else { @@ -785,7 +796,7 @@ final class RunSession { sanitizeSelectedEnemyIndex() } - private func capturePlayedCardContexts(startIndex: Int, sourceHandIndex: Int) { + private func capturePlayedCardContexts(startIndex: Int, sourceHandIndex: Int, targetEnemyEntityId: String?) { guard startIndex < battleEvents.count else { return } let newEvents = battleEvents[startIndex.. 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/BattleEngineFlowTests.swift b/Tests/GameCoreTests/BattleEngineFlowTests.swift index c3f2c3b..62836e3 100644 --- a/Tests/GameCoreTests/BattleEngineFlowTests.swift +++ b/Tests/GameCoreTests/BattleEngineFlowTests.swift @@ -168,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 )), diff --git a/Tests/GameCoreTests/BattleEventDescriptionTests.swift b/Tests/GameCoreTests/BattleEventDescriptionTests.swift index bfa6a32..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,8 @@ 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"), "弃牌"), 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 From b9c621eed0440783dc155dfbc92de3b6417e2072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=92=F0=9D=93=B1=F0=9D=93=B2=F0=9D=93=B2=20?= =?UTF-8?q?=F0=9D=93=9C=F0=9D=93=AA=F0=9D=93=B0=F0=9D=93=B7=F0=9D=93=BE?= =?UTF-8?q?=F0=9D=93=BC?= <151696847+chiimagnus@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:03:22 +0800 Subject: [PATCH 29/29] =?UTF-8?q?docs=EF=BC=9A=E5=AE=A1=E6=A0=B8=E5=AE=8C?= =?UTF-8?q?=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...luavp-full-ui-animation-plan-task-audit.md | 216 ------------------ 1 file changed, 216 deletions(-) delete mode 100644 .github/plans/2026-02-10-saluavp-full-ui-animation-plan-task-audit.md diff --git a/.github/plans/2026-02-10-saluavp-full-ui-animation-plan-task-audit.md b/.github/plans/2026-02-10-saluavp-full-ui-animation-plan-task-audit.md deleted file mode 100644 index 9160252..0000000 --- a/.github/plans/2026-02-10-saluavp-full-ui-animation-plan-task-audit.md +++ /dev/null @@ -1,216 +0,0 @@ -# SaluAVP Plan Task Audit Report - -- Repo root: `/Users/chii_magnus/Github_OpenSource/salu` -- Target plan: `.github/plans/2026-02-09-saluavp-full-ui-animation-implementation-plan.md` -- Auditor: `plan-task-auditor` -- Scope note: Plan P6/P7 已在计划内标注 `Dropped`(本阶段不做存档/回放);本报告会把相关 Task 仍列入 TODO,但按“已 Drop”审查其代码/文件一致性。 - ---- - -## TODO Board (N=26) - -- [ ] Task 1: 建立 AVP 战斗事件消费接口(允许破坏旧 AVP 接口) -- [ ] Task 2: 抽离战斗渲染器,降低 `ImmersiveRootView` 复杂度 -- [ ] Task 3: 引入动画队列(先占位,不改交互) -- [ ] Task 4: 抽牌动画(DrawPile -> Hand) -- [ ] Task 5: 出牌动画(Hand -> Enemy / Pile) -- [ ] Task 6: 受击、格挡、死亡反馈 -- [ ] Task 7: 回合切换与 HUD 动效 -- [ ] Task 8: RunSession 启用多敌人遭遇初始化 -- [ ] Task 9: 战斗目标选择交互 -- [ ] Task 10: 目标选择边界处理与提示 -- [ ] Task 11: Rest 房间交互(休息/升级/对话) -- [ ] Task 12: Shop 房间交互(买卡/买遗物/买消耗/删牌) -- [ ] Task 13: Event 房间交互(选项 + Follow-up) -- [ ] Task 14: Event 触发精英战(followUp.startEliteBattle) -- [ ] Task 15: 统一 AVP 奖励路由模型 -- [ ] Task 16: 遗物奖励面板(精英/Boss) -- [ ] Task 17: Boss 章节收束和下一幕衔接 -- [ ] Task 18: AVP 快照存储层(RunSnapshot)(Dropped) -- [ ] Task 19: 控制面板 Continue / Save / Reset UI (Dropped) -- [ ] Task 20: 自动保存策略 (Dropped) -- [ ] Task 21: 选择路径记录模型 (Dropped) -- [ ] Task 22: Trace 导出与重放模式(开发向)(Dropped) -- [ ] Task 23: GameCore 相关新增/变更测试补齐 -- [ ] Task 24: AVP 手动回归清单固化 -- [ ] Task 25: 全量验证与交付前收敛 -- [ ] Task 26: 老旧代码清理闸门(每阶段结束必做) - ---- - -## Task-to-File Map (Existence) - -- Task 1: - - OK `SaluNative/SaluAVP/ViewModels/BattlePresentationEvent.swift` - - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` - - OK `Tests/GameCoreTests/BattleEventDescriptionTests.swift` - - OK `Tests/GameCoreTests/BattleEngineFlowEventOrderTests.swift` -- Task 2: - - OK `SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift` - - OK `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` - - OK `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` -- Task 3: - - OK `SaluNative/SaluAVP/Immersive/BattleAnimationQueue.swift` - - OK `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` -- Task 4: - - OK `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` - - OK `SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift` -- Task 5: - - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` - - OK `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` -- Task 6: - - OK `SaluNative/SaluAVP/Immersive/FloatingTextFactory.swift` - - OK `SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift` - - OK `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` -- Task 7: - - OK `SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift` - - OK `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift` -- Task 8: - - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` -- Task 9: - - OK `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` - - OK `SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift` - - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` -- Task 10: - - OK `SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift` - - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` -- Task 11: - - OK `SaluNative/SaluAVP/Immersive/RestRoomPanel.swift` - - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` - - OK `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` -- Task 12: - - OK `SaluNative/SaluAVP/Immersive/ShopRoomPanel.swift` - - OK `SaluNative/SaluAVP/ViewModels/ShopRoomState.swift` - - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` -- Task 13: - - OK `SaluNative/SaluAVP/Immersive/EventRoomPanel.swift` - - OK `SaluNative/SaluAVP/ViewModels/EventRoomState.swift` - - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` -- Task 14: - - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` - - OK `SaluNative/SaluAVP/Immersive/EventRoomPanel.swift` -- Task 15: - - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` - - OK `SaluNative/SaluAVP/ViewModels/RewardRouteState.swift` -- Task 16: - - OK `SaluNative/SaluAVP/Immersive/RelicRewardPanel.swift` - - OK `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` -- Task 17: - - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` - - OK `SaluNative/SaluAVP/Immersive/ChapterEndPanel.swift` -- Task 18 (Dropped): - - MISSING `SaluNative/SaluAVP/Persistence/AVPRunSnapshotStore.swift` (expected dropped) - - MISSING `SaluNative/SaluAVP/Persistence/AVPDataDirectory.swift` (expected dropped) - - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` (contains no persistence) -- Task 19 (Dropped): - - OK `SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift` (contains no Continue/Save UI) - - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` (contains no Continue/Save logic) -- Task 20 (Dropped): - - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` (contains no autosave) -- Task 21 (Dropped): - - MISSING `SaluNative/SaluAVP/ViewModels/RunTrace.swift` (expected dropped) - - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` (contains no trace) -- Task 22 (Dropped): - - MISSING `SaluNative/SaluAVP/ControlPanel/ReplayPanel.swift` (expected dropped) - - OK `SaluNative/SaluAVP/ViewModels/RunSession.swift` (contains no replay) -- Task 23: - - OK `Tests/GameCoreTests/BattleEngineFlowTests.swift` -- Task 24: - - OK `.github/docs/SaluAVP-手动回归清单.md` - - OK `.github/plans/Apple Vision Pro 原生 3D 实现(SaluAVP).md` -- Task 25: - - OK `README.md` - - OK `README-en.md` - - OK `.github/docs/Salu游戏业务说明.md` -- Task 26: - - OK `.github/docs/SaluAVP-手动回归清单.md` - - OK `.github/plans/2026-02-09-saluavp-full-ui-animation-implementation-plan.md` - ---- - -## Findings (Open First) - -## Finding F-01 - -- Task: `Task 6: 受击、格挡、死亡反馈` -- Severity: `High` -- Status: `Resolved` -- Location: `SaluNative/SaluAVP/Immersive/BattleAnimationSystem.swift:245` -- Summary: `damageDealt/blockGained` 的反馈永远打在 `enemyRoot.children.first`,多敌人时会给错目标;并且无法区分“玩家受击”和“某个敌人受击”。 -- Risk: 多敌人战斗反馈错误,目标选择的价值被削弱;容易造成“我明明打了 B,但动画/飘字出现在 A”。 -- Expected fix: 最小改动下让 hit/block 反馈基于“稳定目标标识”路由到正确实体;不依赖名字字符串匹配(避免双同名敌人歧义)。 -- Validation: `swift test --filter GameCoreTests` + `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build` -- Resolution evidence: `BattleEvent` 为 damage/block/status 相关事件补齐 entity id;AVP 动画队列把 `targetEntityId` 写入 job,并在动画系统里按 `enemy:` 精确寻址(玩家则落到手牌根节点附近)。验证见下方 Validation Log。 - -## Finding F-02 - -- Task: `Task 2: 抽离战斗渲染器,降低 ImmersiveRootView 复杂度` -- Severity: `High` -- Status: `Resolved` -- Location: `SaluNative/SaluAVP/Immersive/BattleSceneRenderer.swift:85` -- Summary: `enemyRoot` 在每次 `render(...)` 都会 `removeFromParent()` 并重建,导致对敌人的 pulse/受击缩放等“持续动画”在下一帧被销毁,表现为抖一下就瞬间复位。 -- Risk: 动画系统看似工作,但核心反馈持续时间被帧刷新打断;多敌人时更明显(每帧重建更重,也更丑)。 -- Expected fix: 让敌人渲染从“每帧重建”改为“保留实体并增量更新”(按 enemy id 复用、按 alive/selected 状态更新材质/marker),仅在敌人集合变化时增删。 -- Validation: `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build` + 手动在多敌人战斗里观察受击脉冲持续 0.2s 以上不被重置。 -- Resolution evidence: `BattleSceneRenderer.renderEnemies(...)` 改为按 enemy id 复用实体并增量更新,避免每帧销毁敌人实体导致动画中断。构建验证见 Validation Log。 - -## Finding F-03 - -- Task: `Task 1: 建立 AVP 战斗事件消费接口(允许破坏旧 AVP 接口)` -- Severity: `Medium` -- Status: `Resolved` -- Location: `SaluNative/SaluAVP/ViewModels/RunSession.swift:966` (see `clearBattleState(preserveSnapshot:)`) -- Summary: `playerWon` 进入 `reward` 路由时,`clearBattleState(preserveSnapshot: true)` 会把 `lastConsumedBattleEventIndex` 重置为 0,导致在奖励界面再次“从头消费一遍 battleEvents”,违反“自上次消费后新增事件”的语义。 -- Risk: 奖励界面可能重复触发抽牌/回合等动画队列,表现为随机闪动/噪声;也会让 event bridge 的“增量消费”难以推理。 -- Expected fix: `preserveSnapshot == true` 时不要重置 `lastConsumedBattleEventIndex`(或直接推进到 `battleEvents.count`),并明确 reward 界面不回放整场战斗事件。 -- Validation: 手动:打一场战斗胜利后进入奖励界面,不再触发“抽牌/回合开始”类动画;并保持后续回到地图正常。 -- Resolution evidence: `clearBattleState(preserveSnapshot: true)` 现在会把 `lastConsumedBattleEventIndex` 置为 `battleEvents.count`,reward 界面不再回放整场战斗事件。 - -## Finding F-04 - -- Task: `Task 5: 出牌动画(Hand -> Enemy / Pile)` -- Severity: `Medium` -- Status: `Resolved` -- Location: `SaluNative/SaluAVP/ViewModels/RunSession.swift:788` -- Summary: 出牌动画当前只从手牌飞向牌堆(discard/exhaust),没有“命中目标”的视觉阶段;且 `PlayedCardPresentationContext` 不包含目标实体信息。 -- Risk: 出牌缺乏因果链(打到谁),“选目标”与“造成伤害”之间缺少视觉连接,尤其多敌人战斗可读性差。 -- Expected fix: 在 AVP 层为 `.played` 事件补足“本次出牌的目标 enemyId(若有)”,动画先飞向目标再落入牌堆;不需要改 GameCore 规则。 -- Validation: 手动:多敌人战斗选中目标出牌,卡牌先飞向目标再回收;无目标牌仍飞向牌堆。 -- Resolution evidence: `PlayedCardPresentationContext` 增加 `targetEnemyEntityId`,`BattleAnimationSystem` 出牌动画在有目标时先飞向 `enemy:` 再落入牌堆。 - -## Finding F-05 - -- Task: `Task 18: AVP 快照存储层(RunSnapshot)` -- Severity: `Low` -- Status: `Resolved` -- Location: `.github/plans/2026-02-09-saluavp-full-ui-animation-implementation-plan.md:367` -- Summary: Plan P6/P7 已标 `Dropped`,但 Task 18/21/22 的文件清单仍指向当前不存在的实现文件,容易误导后续执行者。 -- Risk: 执行偏离当前决策;审查时无法快速判断“缺文件是 bug 还是刻意 drop”。 -- Expected fix: 将 Task 18/21/22 标题也明确标注 `Dropped`,并在 Files 段落注明“已回滚/本阶段不执行”。 -- Validation: 文档审查即可(无代码验证要求)。 -- Resolution evidence: Task 18-22 标题已标注 `Dropped` 并追加说明行(保留为历史设计记录)。 - ---- - -## Fix Log (Reserved) - -- F-01: `BattleEvent` 补齐 entity id;AVP hit/block 动画按 id 寻址目标实体。 -- F-02: 战斗敌人渲染改为增量更新(按 enemy id 复用实体),避免每帧重建打断动画。 -- F-03: reward 快照模式下不再重置 battle event 消费游标,避免奖励界面回放整场事件。 -- F-04: 出牌动画补齐目标 enemy id,支持“飞向目标再回收至牌堆”。 -- F-05: Plan 中 Task 18-22 明确标注 `Dropped` 并说明已回滚。 - ---- - -## Validation Log (Reserved) - -- `swift test --filter GameCoreTests` PASS -- `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build` PASS - ---- - -## Current Status - -- Findings Open: none -- Residual risks: - - 需要手动确认:多敌人战斗中,enemy 位置更新不会与受击 pulse 产生视觉冲突(目前 pulse 只做 scale,不改 translation)。