From 7a130acc4b790fc9431f896382b79845a4d352b3 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: Fri, 6 Feb 2026 19:09:47 +0800 Subject: [PATCH 01/13] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20Apple=20Visi?= =?UTF-8?q?on=20Pro=203D=20=E5=AE=9E=E7=8E=B0=E6=96=87=E6=A1=A3=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=9B=B4=E6=96=B0=E6=97=B6=E9=97=B4=E5=B9=B6?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AE=9E=E7=8E=B0=E6=96=B9=E5=90=91=E4=B8=8E?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=8B=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...P-P2-3D-battle-loop-implementation-plan.md | 208 ++++++++++++++++++ ...216\260\357\274\210SaluAVP\357\274\211.md" | 7 +- 2 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 .github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md diff --git a/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md b/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md new file mode 100644 index 0000000..d344972 --- /dev/null +++ b/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md @@ -0,0 +1,208 @@ +# SaluAVP P2:3D 战斗闭环(同一个 ImmersiveSpace)实施计划 + +> 执行方式:建议使用 `executing-plans` 分批实现与验收(每批次都跑一次 `xcodebuild` 回归)。 + +**Goal(目标):** 在 `SaluAVP` 的同一个 `ImmersiveSpace` 内打通战斗闭环:从地图进入战斗 → 3D 敌人 + 3D 手牌交互出牌 → 敌人回合 → 胜负结算 → 回到地图继续推进(或 run over)。 + +**Non-goals(非目标):** +- 不做“甩牌/投掷命中敌人”的手势(先记录为后续增强)。 +- 不做多敌人(先记录为后续扩展);本阶段只支持单敌人(`BattleEngine` 会自动解析 `targetEnemyIndex == nil` 的目标)。 +- 不做完整 VFX/动画/资产管线;敌人与卡牌先用占位几何体,后续替换为真实 3D 资源。 +- 不做存档持久化(P3 再做)。 + +**Approach(方案):** +- `RunSession` 扩展战斗路由与 `BattleEngine` 生命周期:进入战斗节点时创建引擎并 `startBattle()`;玩家交互转换为 `PlayerAction`;战斗结束后用 `RunState.updateFromBattle(playerHP:)` + `completeCurrentNode()` 推进冒险并回到地图。 +- `ImmersiveRootView` 在同一个 `RealityView` 中按 `route` 切换渲染层:`mapLayer` ↔ `battleLayer`(地图隐藏/移除)。 +- 手牌使用 RealityKit `ModelEntity`(3D 卡牌)呈现并可点选出牌;目标先按“单敌人自动目标”走通流程(后续再扩展到“拖拽/甩牌命中 + 多敌人目标”)。 +- 可复现性:战斗 seed 由(run seed + floor + nodeId)稳定派生,禁止使用 `hashValue`(非稳定)。 + +**Acceptance(验收):** +- `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build` 通过。 +- Simulator:从地图进入战斗(单敌人)后能通过点选 3D 卡牌进行出牌、结束回合,直至胜/负;胜利后回地图继续推进;失败后进入 run over。 +- 同 seed + 同节点选择路径:每次进入同一节点战斗的敌人类型/初始 HP 可复现(由派生 seed 决定)。 + +--- + +## Plan A(主方案) + +### P2.0:记录 deferred(避免遗忘/范围漂移) + +### Task 0: 把“甩牌命中”和“多敌人”记录为后续里程碑 + +**Files:** +-Create: `.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md` + +**Step 1: 记录后续增强点(本文件末尾的 Backlog)** + +**Step 2:(可选)在主计划 `.github/plans/Apple Vision Pro 原生 3D 实现(SaluAVP).md` 的 P2 段落补一行“已选择:3D 卡牌(RealityKit)/ 单敌人 MVP / 甩牌与多敌人 deferred”。** + +Verify: 无(文档变更)。 + +--- + +### P2.1:稳定派生 seed(可测试,避免 UI 层随意拼) + +### Task 1: 在 GameCore 增加稳定字符串哈希 + seed 派生工具 + +**Files:** +-Create: `Sources/GameCore/Kernel/StableHash.swift` +-Create: `Sources/GameCore/Kernel/SeedDerivation.swift` +-Test: `Tests/GameCoreTests/SeedDerivationTests.swift` + +**Step 1: 写失败测试** +- 覆盖:同输入多次结果一致;不同 nodeId 结果不同;不使用 `hashValue`。 + +Run: `swift test --filter GameCoreTests.SeedDerivationTests` +Expected: FAIL(类型/函数不存在) + +**Step 2: 写最小实现让测试通过** +- `StableHash.fnv1a64(_:)`:对 `String.utf8` 做 FNV-1a 64-bit。 +- `SeedDerivation.battleSeed(runSeed:floor:nodeId:)`:`runSeed ^ fnv1a64(nodeId) &+ UInt64(floor) &* 1_000_000_000`(或其它简单可复现组合;保持 64-bit 溢出语义)。 + +Run: `swift test --filter GameCoreTests.SeedDerivationTests` +Expected: PASS + +**Step 3: 回归 GameCore** +Run: `swift test --filter GameCoreTests` +Expected: PASS + +--- + +### P2.2:RunSession 增加战斗路由与引擎生命周期 + +### Task 2: 扩展 RunSession 支持 `.battle` route + BattleEngine + +**Files:** +-Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` + +**Step 1: 增加 route 与状态** +- `RunSession.Route` 新增:`case battle(nodeId: String, roomType: RoomType)` +- 新增可观察状态: + - `var battleEngine: BattleEngine?` + - `var battleNodeId: String?`(可选,用于断言与回溯) + - `var battleError: String?`(可选) + +**Step 2: 进入节点时分流** +- `selectAccessibleNode(_:)`: + - 若 `node.roomType` 是 `.battle/.elite/.boss`:创建战斗并 `route = .battle(...)` + - 否则维持现有 `route = .room(...)` + +**Step 3: 创建单敌人 BattleEngine(可复现)** +- 从 `runState` 取:`player`、`deck`、`relicManager` +- 用 `SeedDerivation.battleSeed(runSeed:floor:nodeId:)` 得到 battle seed +- 用 `SeededRNG(seed:)` 从对应 Act 的 `ActXEncounterPool.randomWeak(rng:)` 选一次遭遇 +- **MVP:只取 `encounter.enemyIds.first!` 生成 1 个敌人** + - 敌人用 `createEnemy(enemyId:instanceIndex:rng:)`,`instanceIndex = 0` +- 初始化 `BattleEngine(player:enemies:deck:relicManager:seed:)` 并 `startBattle()` + +**Step 4: 暴露最小交互 API** +- `playCard(handIndex:)`:包装 `battleEngine?.handleAction(.playCard(handIndex: handIndex, targetEnemyIndex: nil))` +- `endTurn()`:包装 `battleEngine?.handleAction(.endTurn)` +- `isBattleOver/won`:从 `engine.state.isOver` + `engine.state.playerWon` 读 + +**Step 5: 结束战斗并回到地图** +- 胜利:`runState.updateFromBattle(playerHP: engine.state.player.currentHP)` → `runState.completeCurrentNode()` → `battleEngine = nil` → `route = .map` +- 失败:`runState.updateFromBattle(...)`(会把 run 置为 over)→ `battleEngine = nil` → `route = .runOver(...)` + +Verify: `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build` + +--- + +### P2.3:Battle HUD(先可用/可调试,后再美化) + +### Task 3: 添加 Battle HUD Attachment(End Turn + 状态展示) + +**Files:** +-Create: `SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift` +-Modify: `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` + +**Step 1: 新建 `BattleHUDPanel`(SwiftUI)** +- 展示:玩家 HP、能量、回合数、手牌数量 +- 按钮:`End Turn` +- (可选)展示:最近 5 条 `BattleEvent` 文本(用于快速定位流程) + +**Step 2: 在 `ImmersiveRootView` 中新增 attachment** +- 仅当 `route == .battle` 时显示 +- 放置:用 `BillboardComponent()` 让面板面向用户;位置先固定在战斗区域前上方 + +Verify: `xcodebuild ... build` + +--- + +### P2.4:3D 战斗场景渲染(单敌人 + 3D 手牌) + +### Task 4: 在 ImmersiveRootView 增加 `battleLayer` 并按 route 切换 + +**Files:** +-Modify: `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` + +**Step 1: battle layer 基础结构** +- `mapRoot` 下新增 `battleLayer`(命名例如 `battleLayer`) +- `route == .battle`:`mapLayer.isEnabled = false`、`battleLayer.isEnabled = true` +- `route == .map/.room/.runOver`:反向切换 + +**Step 2: 渲染单敌人(占位体)** +- 从 `runSession.battleEngine?.state.enemies.first` 读数据 +- 用 `ModelEntity`(球/胶囊/盒子)占位,并在实体 `name` 中写入稳定前缀(例如 `enemy:0`) +- 材质/颜色按敌人类型简单区分(后续替换为真实模型) + +**Step 3: 渲染 3D 手牌(占位卡牌)** +- 将“手牌 anchor”绑定到头部(建议:`AnchorEntity(.head)` + 固定 offset),实现“像拿在眼前的一叠牌” +- 为每张 `engine.state.hand` 创建 `ModelEntity`(薄盒子),弧形/扇形排布 +- `entity.name = "card:"`,并加 `CollisionComponent` + `InputTargetComponent` +- 外观:可打出(能量足够)高亮;不可打出变暗 + +**Step 4: 交互:点选卡牌出牌** +- `SpatialTapGesture().targetedToAnyEntity()`: + - 命中 `card:` 且 `route == .battle` → `runSession.playCard(handIndex: idx)` +- 出牌后 UI 更新应由 `RealityView update` 重新渲染手牌(以 `engine.state.hand` 为源) + +**Step 5: 战斗结束检测与自动回收** +- 每次出牌/结束回合后: + - 若 `engine.state.isOver == true`:调用 `RunSession` 的“结算并回地图/RunOver”逻辑 + +Verify: +- `xcodebuild ... build` +- Simulator 手动验收:进入战斗后点几张牌 → `End Turn` → 直到战斗结束 + +--- + +### P2.5:从地图房间接入战斗(用户路径) + +### Task 5: 房间面板对 battle 节点的行为对齐 + +**Files:** +-Modify: `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` + +**Step 1: battle 节点进入方式** +- 建议:点选 battle 节点后 **直接进入 `.battle`**(不先进入 `.room`),减少多一步点击 +- 若保留 `.room`:则 `RoomPanel` 对 battle 类型显示 `Enter Battle`(而不是 `Complete`) + +**Step 2: run over 的 UI** +- `route == .runOver` 时:HUD/面板提供 `New Run` 与 `Close`(回控制面板)即可 + +Verify: Simulator 手动走通 “地图 → 战斗 → 回地图/RunOver” + +--- + +## Backlog(已记录,后续增强) + +### B1:甩牌 / 投掷命中敌人(真机增强 + Simulator 退化) +- 真机:抓起 3D 卡牌 → 释放时根据速度/方向投射 → 命中敌人触发出牌动画与结算 +- Simulator:拖拽卡牌 → 放到敌人身上判定命中(命中后播放飞行动画) +- 需要引入: + - “抓取/拖拽”手势(targeted drag) + - 命中判定(碰撞体 + 接触回调或射线检测) + - 卡牌回收与失败落点处理 + +### B2:多敌人(2–3 敌人)与目标选择 +- 敌人布局:战斗场景内左右排布 +- 目标选择: + - 点选敌人锁定目标;或拖拽/投掷命中指定敌人 + - `PlayerAction.playCard(targetEnemyIndex: selectedIndex)` +- 注意:`BattleEngine` 在多敌人时对 `.singleEnemy` 卡牌要求显式目标(否则会报 “该牌需要选择目标”) + +### B3:真实 3D 模型资产(RealityKitContent) +- 将敌人与卡牌外观替换为 `.usdz/.reality` 资产 +- 需要:资源加载失败降级为占位几何体,避免沉浸空间白屏 + 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 ba45b18..77d81f1 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" @@ -1,7 +1,7 @@ --- title: Apple Vision Pro 原生 3D 实现(SaluAVP) date: 2026-01-29 -updated: 2026-01-31 +updated: 2026-02-06 architecture: visionOS-only App + Immersive-first (RealityKit) target: SaluAVP --- @@ -179,6 +179,11 @@ SaluNative/ - ImmersiveSpace 中渲染:玩家/敌人/手牌/能量/日志(形式不限,先可读可用)。 - Session 桥接 `GameCore` 的战斗推进(当前阶段优先放在 `SaluNative/SaluAVP/ViewModels/`;需要跨 Target 复用时再引入 `SaluNative/Shared/`)。 - 战斗结束后:更新 `RunState`(例如 `updateFromBattle(playerHP:)`)→ 应用奖励/推进地图(奖励可先最小化)。 +- 已选实现方向(MVP): + - 同一个 `ImmersiveSpace` 内切换 `map` ↔ `battle`(地图隐藏/移除,战斗结束再回地图)。 + - 手牌使用 RealityKit 3D 实体(`ModelEntity`)呈现与交互(先点选出牌;后续再做“甩牌命中”)。 + - 先支持单敌人(多敌人 + 目标选择 deferred)。 + - 详细任务拆分见:`.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md` - DoD: - 战斗可完整结束(胜/负),并能回到地图继续推进。 From 60ecdf307f2bff74d7ac5fac3647a28cce075209 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: Fri, 6 Feb 2026 19:55:59 +0800 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=88=98?= =?UTF-8?q?=E6=96=97=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=8C=85?= =?UTF-8?q?=E6=8B=AC=E6=88=98=E6=96=97=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=92=8C=E7=A7=8D=E5=AD=90=E6=B4=BE=E7=94=9F=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1、2 是为了让后面的“3D 战斗交互”能稳定、可复现、可维护地跑起来,不然你会很快卡在“为什么这次和上次不一样/为什么进不了战斗/为什么结算不推进”等问题上。 --- .../ControlPanel/ControlPanelView.swift | 2 + .../SaluAVP/Immersive/ImmersiveRootView.swift | 6 + .../SaluAVP/ViewModels/RunSession.swift | 131 +++++++++++++++++- Sources/GameCore/Kernel/SeedDerivation.swift | 20 +++ Sources/GameCore/Kernel/StableHash.swift | 18 +++ Tests/GameCoreTests/SeedDerivationTests.swift | 26 ++++ 6 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 Sources/GameCore/Kernel/SeedDerivation.swift create mode 100644 Sources/GameCore/Kernel/StableHash.swift create mode 100644 Tests/GameCoreTests/SeedDerivationTests.swift diff --git a/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift b/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift index f6445d9..c70c1e9 100644 --- a/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift +++ b/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift @@ -60,6 +60,8 @@ struct ControlPanelView: View { return "map" case .room(_, let roomType): return "room(\(roomType.rawValue))" + case .battle(_, let roomType): + return "battle(\(roomType.rawValue))" case .runOver(_, let won, let floor): return "runOver(won:\(won), floor:\(floor))" } diff --git a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift index 0d58e60..a02a3ab 100644 --- a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift +++ b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift @@ -106,6 +106,9 @@ struct ImmersiveRootView: View { // Fallback: place in front of the map origin. return (true, [0, 0.25, -0.55]) + case .battle: + 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 @@ -275,6 +278,9 @@ private struct RoomPanel: View { .buttonStyle(.borderedProminent) } + case .battle: + EmptyView() + case .runOver(_, let won, let floor): VStack(alignment: .leading, spacing: 10) { Text(won ? "🎉 Victory" : "💀 Defeat") diff --git a/SaluNative/SaluAVP/ViewModels/RunSession.swift b/SaluNative/SaluAVP/ViewModels/RunSession.swift index 8987f4a..82d87b1 100644 --- a/SaluNative/SaluAVP/ViewModels/RunSession.swift +++ b/SaluNative/SaluAVP/ViewModels/RunSession.swift @@ -8,6 +8,7 @@ final class RunSession { enum Route: Equatable, Sendable { case map case room(nodeId: String, roomType: RoomType) + case battle(nodeId: String, roomType: RoomType) case runOver(lastNodeId: String, won: Bool, floor: Int) } @@ -16,6 +17,10 @@ final class RunSession { var lastError: String? var runState: RunState? var route: Route = .map + private(set) var battleEngine: BattleEngine? + private(set) var battleState: BattleState? + private var battleNodeId: String? + private var battleRoomType: RoomType? func startNewRun() { let seed: UInt64 @@ -32,6 +37,10 @@ final class RunSession { runState = RunState.newRun(seed: seed) seedText = String(seed) lastError = nil + battleEngine = nil + battleState = nil + battleNodeId = nil + battleRoomType = nil route = .map } @@ -46,7 +55,12 @@ final class RunSession { return } - route = .room(nodeId: nodeId, roomType: node.roomType) + switch node.roomType { + case .battle, .elite, .boss: + startBattle(nodeId: nodeId, roomType: node.roomType) + default: + route = .room(nodeId: nodeId, roomType: node.roomType) + } } func completeCurrentRoomAndReturnToMap() { @@ -68,6 +82,117 @@ final class RunSession { } } + func playCard(handIndex: Int) { + guard routeIsBattle else { return } + guard let battleEngine else { return } + _ = battleEngine.handleAction(.playCard(handIndex: handIndex, targetEnemyIndex: nil)) + battleState = battleEngine.state + finishBattleIfNeeded() + } + + func endTurn() { + guard routeIsBattle else { return } + guard let battleEngine else { return } + _ = battleEngine.handleAction(.endTurn) + battleState = battleEngine.state + finishBattleIfNeeded() + } + + private var routeIsBattle: Bool { + if case .battle = route { return true } + return false + } + + private func finishBattleIfNeeded() { + guard let battleEngine, battleEngine.state.isOver else { return } + guard var runState else { return } + + let nodeId = battleNodeId ?? runState.currentNodeId ?? "unknown" + runState.updateFromBattle(playerHP: battleEngine.state.player.currentHP) + self.runState = runState + + defer { + self.battleEngine = nil + self.battleState = nil + self.battleNodeId = nil + self.battleRoomType = nil + } + + if battleEngine.state.playerWon == true { + runState.completeCurrentNode() + self.runState = runState + if runState.isOver { + route = .runOver(lastNodeId: nodeId, won: runState.won, floor: runState.floor) + } else { + route = .map + } + } else { + route = .runOver(lastNodeId: nodeId, won: false, floor: runState.floor) + } + } + + private func startBattle(nodeId: String, roomType: RoomType) { + guard let runState else { return } + + let battleSeed = SeedDerivation.battleSeed(runSeed: runState.seed, floor: runState.floor, nodeId: nodeId) + var rng = SeededRNG(seed: battleSeed) + + let enemyId: EnemyID + switch roomType { + case .battle: + let encounter: EnemyEncounter + switch runState.floor { + case 1: + encounter = Act1EncounterPool.randomWeak(rng: &rng) + case 2: + encounter = Act2EncounterPool.randomWeak(rng: &rng) + default: + encounter = Act3EncounterPool.randomWeak(rng: &rng) + } + enemyId = encounter.enemyIds.first ?? "jaw_worm" + + case .elite: + switch runState.floor { + case 1: + enemyId = Act1EnemyPool.randomMedium(rng: &rng) + case 2: + enemyId = Act2EnemyPool.randomMedium(rng: &rng) + default: + enemyId = Act3EnemyPool.randomMedium(rng: &rng) + } + + case .boss: + switch runState.floor { + case 1: + enemyId = "toxic_colossus" + case 2: + enemyId = "cipher" + default: + enemyId = "sequence_progenitor" + } + + default: + lastError = "Not a battle room: \(roomType.rawValue)" + return + } + + let enemy = createEnemy(enemyId: enemyId, instanceIndex: 0, rng: &rng) + let engine = BattleEngine( + player: runState.player, + enemies: [enemy], + deck: runState.deck, + relicManager: runState.relicManager, + seed: battleSeed + ) + engine.startBattle() + + battleEngine = engine + battleState = engine.state + battleNodeId = nodeId + battleRoomType = roomType + route = .battle(nodeId: nodeId, roomType: roomType) + } + private static func generateSeed() -> UInt64 { UInt64(Date().timeIntervalSince1970 * 1000) } @@ -76,5 +201,9 @@ final class RunSession { runState = nil route = .map lastError = nil + battleEngine = nil + battleState = nil + battleNodeId = nil + battleRoomType = nil } } diff --git a/Sources/GameCore/Kernel/SeedDerivation.swift b/Sources/GameCore/Kernel/SeedDerivation.swift new file mode 100644 index 0000000..0b5f26b --- /dev/null +++ b/Sources/GameCore/Kernel/SeedDerivation.swift @@ -0,0 +1,20 @@ +/// Deterministic seed derivation helpers. +/// +/// Used to split a single run seed into stable sub-seeds (battle / event / reward / shop ...) without +/// introducing shared RNG state. +public enum SeedDerivation { + /// Derive a deterministic seed for a battle instance. + /// + /// - Parameters: + /// - runSeed: The run seed. + /// - floor: The current act/floor number (1-based in `RunState`). + /// - nodeId: The map node id (e.g. "3_1"). + public static func battleSeed(runSeed: UInt64, floor: Int, nodeId: String) -> UInt64 { + var s = runSeed + s ^= StableHash.fnv1a64(nodeId) + s ^= UInt64(floor) &* 1_000_000_000 + s ^= 0xBA77_EEED_0000_0000 + return s + } +} + diff --git a/Sources/GameCore/Kernel/StableHash.swift b/Sources/GameCore/Kernel/StableHash.swift new file mode 100644 index 0000000..00a97a7 --- /dev/null +++ b/Sources/GameCore/Kernel/StableHash.swift @@ -0,0 +1,18 @@ +/// Stable hashing utilities (pure Swift, deterministic). +/// +/// - Important: Do NOT use `String.hashValue` for determinism. Swift's hashing is intentionally randomized +/// across processes and versions. +public enum StableHash { + /// FNV-1a 64-bit hash for UTF-8 bytes. + /// + /// Deterministic across platforms and runs. + public static func fnv1a64(_ string: String) -> UInt64 { + var hash: UInt64 = 0xcbf29ce484222325 + for byte in string.utf8 { + hash ^= UInt64(byte) + hash &*= 0x100000001b3 + } + return hash + } +} + diff --git a/Tests/GameCoreTests/SeedDerivationTests.swift b/Tests/GameCoreTests/SeedDerivationTests.swift new file mode 100644 index 0000000..5f9908b --- /dev/null +++ b/Tests/GameCoreTests/SeedDerivationTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import GameCore + +final class SeedDerivationTests: XCTestCase { + func testBattleSeed_isDeterministic_givenSameInputs() { + print("🧪 测试:testBattleSeed_isDeterministic_givenSameInputs") + let a = SeedDerivation.battleSeed(runSeed: 123, floor: 1, nodeId: "3_1") + let b = SeedDerivation.battleSeed(runSeed: 123, floor: 1, nodeId: "3_1") + XCTAssertEqual(a, b) + } + + func testBattleSeed_changesWhenNodeIdChanges() { + print("🧪 测试:testBattleSeed_changesWhenNodeIdChanges") + let a = SeedDerivation.battleSeed(runSeed: 123, floor: 1, nodeId: "3_1") + let b = SeedDerivation.battleSeed(runSeed: 123, floor: 1, nodeId: "3_2") + XCTAssertNotEqual(a, b) + } + + func testBattleSeed_changesWhenFloorChanges() { + print("🧪 测试:testBattleSeed_changesWhenFloorChanges") + let a = SeedDerivation.battleSeed(runSeed: 123, floor: 1, nodeId: "3_1") + let b = SeedDerivation.battleSeed(runSeed: 123, floor: 2, nodeId: "3_1") + XCTAssertNotEqual(a, b) + } +} + From da04cbad6ce81b87e31cfdd02ae6c2084bc293ce 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: Fri, 6 Feb 2026 19:58:30 +0800 Subject: [PATCH 03/13] =?UTF-8?q?docs=EF=BC=9A=E5=AE=8C=E6=88=90=E4=BA=86t?= =?UTF-8?q?ask1=E5=92=8C2=E3=80=82=E5=85=88=E6=8A=8A=E5=8F=AF=E5=A4=8D?= =?UTF-8?q?=E7=8E=B0=E7=9A=84=E6=88=98=E6=96=97=E7=94=9F=E6=88=90=20+=20?= =?UTF-8?q?=E6=88=98=E6=96=97=E7=8A=B6=E6=80=81=E6=9C=BA=E9=97=AD=E7=8E=AF?= =?UTF-8?q?=E6=89=93=E7=89=A2=EF=BC=8C=E5=86=8D=E5=81=9A=203D=20=E8=A1=A8?= =?UTF-8?q?=E7=8E=B0=E5=B1=82=EF=BC=8C=E5=90=A6=E5=88=99=E6=AF=8F=E5=8A=A0?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E4=BA=A4=E4=BA=92=E7=82=B9=E9=83=BD=E4=BC=9A?= =?UTF-8?q?=E5=8F=98=E6=88=90=E9=9A=BE=E8=B0=83=E7=9A=84=E2=80=9C=E7=8E=84?= =?UTF-8?q?=E5=AD=A6=20bug=E2=80=9D=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md b/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md index d344972..2bb83e3 100644 --- a/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md +++ b/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md @@ -27,7 +27,7 @@ ### P2.0:记录 deferred(避免遗忘/范围漂移) -### Task 0: 把“甩牌命中”和“多敌人”记录为后续里程碑 +### ✅Task 0: 把“甩牌命中”和“多敌人”记录为后续里程碑 **Files:** -Create: `.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md` @@ -42,7 +42,7 @@ Verify: 无(文档变更)。 ### P2.1:稳定派生 seed(可测试,避免 UI 层随意拼) -### Task 1: 在 GameCore 增加稳定字符串哈希 + seed 派生工具 +### ✅Task 1: 在 GameCore 增加稳定字符串哈希 + seed 派生工具 **Files:** -Create: `Sources/GameCore/Kernel/StableHash.swift` @@ -70,7 +70,7 @@ Expected: PASS ### P2.2:RunSession 增加战斗路由与引擎生命周期 -### Task 2: 扩展 RunSession 支持 `.battle` route + BattleEngine +### ✅Task 2: 扩展 RunSession 支持 `.battle` route + BattleEngine **Files:** -Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` From 18a76b8408d6a9a96449f25c8d94a519bf00193f 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: Fri, 6 Feb 2026 20:29:45 +0800 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=88=98?= =?UTF-8?q?=E6=96=97HUD=E9=9D=A2=E6=9D=BF=E5=92=8C=E6=88=98=E6=96=97?= =?UTF-8?q?=E5=B1=82=E6=94=AF=E6=8C=81=EF=BC=8C=E5=A2=9E=E5=BC=BA=E6=88=98?= =?UTF-8?q?=E6=96=97=E7=8A=B6=E6=80=81=E6=98=BE=E7=A4=BA=E4=B8=8E=E4=BA=A4?= =?UTF-8?q?=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SaluAVP/Immersive/BattleHUDPanel.swift | 83 +++++++ .../SaluAVP/Immersive/ImmersiveRootView.swift | 217 +++++++++++++++++- 2 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift diff --git a/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift b/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift new file mode 100644 index 0000000..6ae66ec --- /dev/null +++ b/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift @@ -0,0 +1,83 @@ +import SwiftUI +import GameCore + +struct BattleHUDPanel: View { + @Environment(RunSession.self) private var runSession + + var body: some View { + @Bindable var runSession = runSession + + let battleState = runSession.battleState + let engine = runSession.battleEngine + + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text("Battle") + .font(.headline) + if let pending = engine?.pendingInput { + Text("Pending: \(pendingLabel(pending))") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + if let state = battleState { + VStack(alignment: .leading, spacing: 6) { + Text("Player HP \(state.player.currentHP)/\(state.player.maxHP) Block \(state.player.block)") + .font(.caption) + let enemy = state.enemies.first + Text("Enemy: \(enemy.map { $0.name.resolved(for: .zhHans) } ?? "-") HP \(enemy?.currentHP ?? 0)/\(enemy?.maxHP ?? 0) Block \(enemy?.block ?? 0)") + .font(.caption) + Text("Turn \(state.turn) Energy \(state.energy)/\(state.maxEnergy) \(state.isPlayerTurn ? "Player" : "Enemy")") + .font(.caption2) + .foregroundStyle(.secondary) + } + } else { + Text("No battle state.") + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack(spacing: 10) { + Button("End Turn") { + runSession.endTurn() + } + .buttonStyle(.borderedProminent) + .disabled(!(battleState?.isPlayerTurn ?? false) || (engine?.pendingInput != nil)) + + Button("Clear Log") { + engine?.clearEvents() + } + .buttonStyle(.bordered) + .disabled(engine?.events.isEmpty != false) + } + + if let events = engine?.events, !events.isEmpty { + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(events.suffix(8).enumerated()), id: \.offset) { _, event in + Text(event.description) + .font(.caption2) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .frame(maxHeight: 160) + } + } + .padding(12) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .frame(width: 340) + } + + private func pendingLabel(_ pending: BattlePendingInput) -> String { + switch pending { + case .foresight(_, let fromCount): + return "Foresight(\(fromCount))" + } + } +} + diff --git a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift index a02a3ab..8add739 100644 --- a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift +++ b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift @@ -7,9 +7,15 @@ struct ImmersiveRootView: View { @Environment(RunSession.self) private var runSession private let nodeNamePrefix = "node:" + private let cardNamePrefix = "card:" private let roomPanelAttachmentId = "roomPanel" + private let battleHudAttachmentId = "battleHUD" private let mapLayerPrefix = "mapLayer_floor_" private let uiLayerName = "uiLayer" + private let battleLayerName = "battleLayer" + private let battleHeadAnchorName = "battleHeadAnchor" + private let battleHandRootName = "battleHandRoot" + private let battleEnemyRootName = "battleEnemyRoot" var body: some View { RealityView { content, attachments in @@ -24,6 +30,27 @@ struct ImmersiveRootView: View { uiLayer.name = uiLayerName mapRoot.addChild(uiLayer) + 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) + + mapRoot.addChild(battleLayer) content.add(mapRoot) } update: { content, attachments in guard let mapRoot = content.entities.first(where: { $0.name == "mapRoot" }) else { return } @@ -39,6 +66,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 return } @@ -60,6 +88,46 @@ 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) + + mapRoot.addChild(battleLayer) + return battleLayer + }() + + let isInBattle: Bool + if case .battle = runSession.route { + isInBattle = true + } else { + isInBattle = false + } + + mapLayer.isEnabled = !isInBattle + battleLayer.isEnabled = isInBattle + + if isInBattle, let engine = runSession.battleEngine { + renderBattle(engine: engine, in: battleLayer) + } else { + clearBattle(in: battleLayer) + } + if let panel = attachments.entity(for: roomPanelAttachmentId) { panel.name = roomPanelAttachmentId panel.components.set(BillboardComponent()) @@ -69,6 +137,23 @@ struct ImmersiveRootView: View { panel.position = position uiLayer.addChild(panel) } + + if let hud = attachments.entity(for: battleHudAttachmentId) { + hud.name = battleHudAttachmentId + hud.components.set(BillboardComponent()) + hud.components.set(InputTargetComponent()) + hud.isEnabled = isInBattle + + if let headAnchor = battleLayer.findEntity(named: battleHeadAnchorName) { + headAnchor.children.first(where: { $0.name == battleHudAttachmentId })?.removeFromParent() + hud.position = [0, 0.12, -0.22] + headAnchor.addChild(hud) + } else { + uiLayer.children.first(where: { $0.name == battleHudAttachmentId })?.removeFromParent() + hud.position = [0, 0.25, -0.55] + uiLayer.addChild(hud) + } + } } attachments: { Attachment(id: roomPanelAttachmentId) { RoomPanel( @@ -78,15 +163,30 @@ struct ImmersiveRootView: View { onClose: { runSession.resetToControlPanel() } ) } + + Attachment(id: battleHudAttachmentId) { + BattleHUDPanel() + } } .gesture( SpatialTapGesture() .targetedToAnyEntity() .onEnded { value in - guard runSession.route == .map else { return } - guard value.entity.name.hasPrefix(nodeNamePrefix) else { return } - let nodeId = String(value.entity.name.dropFirst(nodeNamePrefix.count)) - runSession.selectAccessibleNode(nodeId) + switch runSession.route { + case .map: + guard value.entity.name.hasPrefix(nodeNamePrefix) else { return } + let nodeId = String(value.entity.name.dropFirst(nodeNamePrefix.count)) + runSession.selectAccessibleNode(nodeId) + + case .battle: + guard value.entity.name.hasPrefix(cardNamePrefix) else { return } + let suffix = value.entity.name.dropFirst(cardNamePrefix.count) + guard let handIndex = Int(suffix) else { return } + runSession.playCard(handIndex: handIndex) + + case .room, .runOver: + break + } } ) } @@ -124,6 +224,115 @@ 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() } + battleLayer.findEntity(named: battleHeadAnchorName)? + .findEntity(named: battleHandRootName)? + .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 + }() + + handRoot.children.forEach { $0.removeFromParent() } + + let hand = engine.state.hand + guard !hand.isEmpty else { return } + + let playable = Set(engine.playableCardIndices) + 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) + entity.name = "\(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.12 + 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) + } + } + + 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) -> 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()) + 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 f686bf1f44811aac2cfd0037e4092318682056a0 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: Fri, 6 Feb 2026 20:33:22 +0800 Subject: [PATCH 05/13] =?UTF-8?q?fix:=20=E2=80=9C=E4=B8=BA=E4=BB=80?= =?UTF-8?q?=E4=B9=88=E8=BF=99=E4=B8=AA=E5=88=9D=E5=A7=8B=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E8=BF=98=E5=9C=A8=E8=BF=99=E9=87=8C=EF=BC=9F=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这是因为 ControlPanelView 是 WindowGroup 的 volumetric window,进入 Immersive 后它默认不会自动消失。 现在改成:打开 Immersive 成功后自动 dismissWindow(controlPanel);退出 Immersive 会自动 openWindow(controlPanel)。 改动在:SaluAVPApp.swift (line 1)、ImmersiveSpaceToggleButton.swift (line 1)、AppModel.swift (line 1) --- SaluNative/SaluAVP/AppModel.swift | 1 + .../SaluAVP/ControlPanel/ImmersiveSpaceToggleButton.swift | 5 ++++- SaluNative/SaluAVP/SaluAVPApp.swift | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/SaluNative/SaluAVP/AppModel.swift b/SaluNative/SaluAVP/AppModel.swift index 9e14067..5c8f652 100644 --- a/SaluNative/SaluAVP/AppModel.swift +++ b/SaluNative/SaluAVP/AppModel.swift @@ -4,6 +4,7 @@ import SwiftUI @MainActor @Observable class AppModel { + static let controlPanelWindowID = "controlPanel" let immersiveSpaceID = "ImmersiveSpace" enum ImmersiveSpaceState { case closed diff --git a/SaluNative/SaluAVP/ControlPanel/ImmersiveSpaceToggleButton.swift b/SaluNative/SaluAVP/ControlPanel/ImmersiveSpaceToggleButton.swift index 22a923a..c7fbc77 100644 --- a/SaluNative/SaluAVP/ControlPanel/ImmersiveSpaceToggleButton.swift +++ b/SaluNative/SaluAVP/ControlPanel/ImmersiveSpaceToggleButton.swift @@ -5,6 +5,8 @@ struct ImmersiveSpaceToggleButton: View { @Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace @Environment(\.openImmersiveSpace) private var openImmersiveSpace + @Environment(\.dismissWindow) private var dismissWindow + @Environment(\.openWindow) private var openWindow var body: some View { Button { @@ -13,11 +15,13 @@ struct ImmersiveSpaceToggleButton: View { case .open: appModel.immersiveSpaceState = .inTransition await dismissImmersiveSpace() + openWindow(id: AppModel.controlPanelWindowID) case .closed: appModel.immersiveSpaceState = .inTransition switch await openImmersiveSpace(id: appModel.immersiveSpaceID) { case .opened: + dismissWindow(id: AppModel.controlPanelWindowID) break case .userCancelled, .error: fallthrough @@ -37,4 +41,3 @@ struct ImmersiveSpaceToggleButton: View { .animation(.none, value: 0) } } - diff --git a/SaluNative/SaluAVP/SaluAVPApp.swift b/SaluNative/SaluAVP/SaluAVPApp.swift index e95eded..5a649be 100644 --- a/SaluNative/SaluAVP/SaluAVPApp.swift +++ b/SaluNative/SaluAVP/SaluAVPApp.swift @@ -7,7 +7,7 @@ struct SaluAVPApp: App { @State private var runSession = RunSession() var body: some Scene { - WindowGroup { + WindowGroup(id: AppModel.controlPanelWindowID) { ControlPanelView() .environment(appModel) .environment(runSession) From caaae17186d66b7b970301228bba7e34fd9cf95f 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: Fri, 6 Feb 2026 20:34:45 +0800 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20=E8=B0=83=E6=95=B4=E6=88=98?= =?UTF-8?q?=E6=96=97HUD=E4=BD=8D=E7=BD=AE=E5=92=8C=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E6=8E=92=E5=88=97=EF=BC=8C=E4=BC=98=E5=8C=96=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=A7=86=E8=A7=92=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SaluAVP/Immersive/BattleHUDPanel.swift | 36 +++++++++++++------ .../SaluAVP/Immersive/ImmersiveRootView.swift | 11 +++--- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift b/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift index 6ae66ec..12ec029 100644 --- a/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift +++ b/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift @@ -3,6 +3,10 @@ import GameCore struct BattleHUDPanel: View { @Environment(RunSession.self) private var runSession + @Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace + @Environment(\.openWindow) private var openWindow + + @State private var isLogExpanded: Bool = false var body: some View { @Bindable var runSession = runSession @@ -14,11 +18,14 @@ struct BattleHUDPanel: View { HStack(spacing: 8) { Text("Battle") .font(.headline) - if let pending = engine?.pendingInput { - Text("Pending: \(pendingLabel(pending))") - .font(.caption2) - .foregroundStyle(.secondary) + + Spacer(minLength: 0) + + Button(isLogExpanded ? "Hide" : "Log") { + isLogExpanded.toggle() } + .font(.caption2) + .buttonStyle(.bordered) } if let state = battleState { @@ -31,6 +38,12 @@ struct BattleHUDPanel: View { Text("Turn \(state.turn) Energy \(state.energy)/\(state.maxEnergy) \(state.isPlayerTurn ? "Player" : "Enemy")") .font(.caption2) .foregroundStyle(.secondary) + + if let pending = engine?.pendingInput { + Text("Pending: \(pendingLabel(pending))") + .font(.caption2) + .foregroundStyle(.secondary) + } } } else { Text("No battle state.") @@ -45,14 +58,16 @@ struct BattleHUDPanel: View { .buttonStyle(.borderedProminent) .disabled(!(battleState?.isPlayerTurn ?? false) || (engine?.pendingInput != nil)) - Button("Clear Log") { - engine?.clearEvents() + Button("Exit") { + Task { @MainActor in + await dismissImmersiveSpace() + openWindow(id: AppModel.controlPanelWindowID) + } } .buttonStyle(.bordered) - .disabled(engine?.events.isEmpty != false) } - if let events = engine?.events, !events.isEmpty { + if isLogExpanded, let events = engine?.events, !events.isEmpty { Divider() ScrollView { VStack(alignment: .leading, spacing: 6) { @@ -64,13 +79,13 @@ struct BattleHUDPanel: View { } } } - .frame(maxHeight: 160) + .frame(maxHeight: 120) } } .padding(12) .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) - .frame(width: 340) + .frame(width: 260) } private func pendingLabel(_ pending: BattlePendingInput) -> String { @@ -80,4 +95,3 @@ struct BattleHUDPanel: View { } } } - diff --git a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift index 8add739..1d89749 100644 --- a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift +++ b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift @@ -146,7 +146,8 @@ struct ImmersiveRootView: View { if let headAnchor = battleLayer.findEntity(named: battleHeadAnchorName) { headAnchor.children.first(where: { $0.name == battleHudAttachmentId })?.removeFromParent() - hud.position = [0, 0.12, -0.22] + // Place HUD near the top-right in the user's view. + hud.position = [0.26, 0.20, -0.38] headAnchor.addChild(hud) } else { uiLayer.children.first(where: { $0.name == battleHudAttachmentId })?.removeFromParent() @@ -290,11 +291,13 @@ struct ImmersiveRootView: View { let dx = Float(index) - center let x = dx * 0.07 - let z = -abs(dx) * 0.02 + // Outer cards should come slightly closer to the user. + let z = abs(dx) * 0.02 entity.position = [x, 0, z] - let yaw = dx * 0.12 - let pitch: Float = -0.18 + // 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) From 323b78ef56d3817f97c9c24f7d10b40b32890013 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: Fri, 6 Feb 2026 22:23:26 +0800 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=9C=B0?= =?UTF-8?q?=E5=9B=BEHUD=E9=9D=A2=E6=9D=BF=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=95=8C=E9=9D=A2=E5=92=8C=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=EF=BC=9B=E6=9B=B4=E6=96=B0RunSession?= =?UTF-8?q?=E4=BB=A5=E6=94=AF=E6=8C=81=E5=A5=96=E5=8A=B1=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SaluAVP/Immersive/ImmersiveRootView.swift | 54 +++++++++++++++---- .../SaluAVP/Immersive/MapHUDPanel.swift | 45 ++++++++++++++++ .../SaluAVP/ViewModels/RunSession.swift | 11 ++++ 3 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 SaluNative/SaluAVP/Immersive/MapHUDPanel.swift diff --git a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift index 1d89749..a6fe944 100644 --- a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift +++ b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift @@ -5,14 +5,18 @@ import GameCore struct ImmersiveRootView: View { @Environment(RunSession.self) private var runSession + @Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace + @Environment(\.openWindow) private var openWindow private let nodeNamePrefix = "node:" private let cardNamePrefix = "card:" private let roomPanelAttachmentId = "roomPanel" private let battleHudAttachmentId = "battleHUD" + private let mapHudAttachmentId = "mapHUD" 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" @@ -30,6 +34,12 @@ struct ImmersiveRootView: View { uiLayer.name = uiLayerName mapRoot.addChild(uiLayer) + // Always-on head anchor for 2D attachments (HUDs). Must NOT live under battleLayer, + // otherwise it will be disabled when we hide battleLayer. + let hudAnchor = AnchorEntity(.head) + hudAnchor.name = hudAnchorName + mapRoot.addChild(hudAnchor) + let battleLayer = RealityKit.Entity() battleLayer.name = battleLayerName battleLayer.isEnabled = false @@ -64,6 +74,13 @@ struct ImmersiveRootView: View { uiLayer.children.forEach { $0.removeFromParent() } + let hudAnchor = mapRoot.findEntity(named: hudAnchorName) ?? { + let hudAnchor = AnchorEntity(.head) + hudAnchor.name = hudAnchorName + mapRoot.addChild(hudAnchor) + return hudAnchor + }() + guard let run = runSession.runState else { mapRoot.children.first(where: { $0.name.hasPrefix(mapLayerPrefix) })?.removeFromParent() mapRoot.findEntity(named: battleLayerName)?.isEnabled = false @@ -144,16 +161,21 @@ struct ImmersiveRootView: View { hud.components.set(InputTargetComponent()) hud.isEnabled = isInBattle - if let headAnchor = battleLayer.findEntity(named: battleHeadAnchorName) { - headAnchor.children.first(where: { $0.name == battleHudAttachmentId })?.removeFromParent() - // Place HUD near the top-right in the user's view. - hud.position = [0.26, 0.20, -0.38] - headAnchor.addChild(hud) - } else { - uiLayer.children.first(where: { $0.name == battleHudAttachmentId })?.removeFromParent() - hud.position = [0, 0.25, -0.55] - uiLayer.addChild(hud) - } + hudAnchor.children.first(where: { $0.name == battleHudAttachmentId })?.removeFromParent() + // Place HUD near the top-right in the user's view (avoid clipping on Simulator). + hud.position = [0.18, 0.15, -0.50] + hudAnchor.addChild(hud) + } + + if let hud = attachments.entity(for: mapHudAttachmentId) { + hud.name = mapHudAttachmentId + hud.components.set(BillboardComponent()) + hud.components.set(InputTargetComponent()) + hud.isEnabled = !isInBattle + + hudAnchor.children.first(where: { $0.name == mapHudAttachmentId })?.removeFromParent() + hud.position = [0.18, 0.17, -0.50] + hudAnchor.addChild(hud) } } attachments: { Attachment(id: roomPanelAttachmentId) { @@ -161,13 +183,23 @@ struct ImmersiveRootView: View { route: runSession.route, onCompleteRoom: { runSession.completeCurrentRoomAndReturnToMap() }, onNewRun: { runSession.startNewRun() }, - onClose: { runSession.resetToControlPanel() } + onClose: { + Task { @MainActor in + runSession.resetToControlPanel() + await dismissImmersiveSpace() + openWindow(id: AppModel.controlPanelWindowID) + } + } ) } Attachment(id: battleHudAttachmentId) { BattleHUDPanel() } + + Attachment(id: mapHudAttachmentId) { + MapHUDPanel() + } } .gesture( SpatialTapGesture() diff --git a/SaluNative/SaluAVP/Immersive/MapHUDPanel.swift b/SaluNative/SaluAVP/Immersive/MapHUDPanel.swift new file mode 100644 index 0000000..3fa781d --- /dev/null +++ b/SaluNative/SaluAVP/Immersive/MapHUDPanel.swift @@ -0,0 +1,45 @@ +import SwiftUI +import GameCore + +struct MapHUDPanel: View { + @Environment(RunSession.self) private var runSession + @Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace + @Environment(\.openWindow) private var openWindow + + var body: some View { + @Bindable var runSession = runSession + + VStack(alignment: .leading, spacing: 8) { + Text("SaluAVP") + .font(.headline) + + if let run = runSession.runState { + Text("Act \(run.floor)/\(run.maxFloor) HP \(run.player.currentHP)/\(run.player.maxHP)") + .font(.caption2) + .foregroundStyle(.secondary) + Text("Gold \(run.gold)") + .font(.caption2) + .foregroundStyle(.secondary) + } else { + Text("No run.") + .font(.caption2) + .foregroundStyle(.secondary) + } + + HStack(spacing: 10) { + Button("Exit") { + Task { @MainActor in + await dismissImmersiveSpace() + openWindow(id: AppModel.controlPanelWindowID) + } + } + .font(.caption) + .buttonStyle(.borderedProminent) + } + } + .padding(10) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .frame(width: 200) + } +} diff --git a/SaluNative/SaluAVP/ViewModels/RunSession.swift b/SaluNative/SaluAVP/ViewModels/RunSession.swift index 82d87b1..d7a44d4 100644 --- a/SaluNative/SaluAVP/ViewModels/RunSession.swift +++ b/SaluNative/SaluAVP/ViewModels/RunSession.swift @@ -108,6 +108,7 @@ final class RunSession { guard var runState else { return } let nodeId = battleNodeId ?? runState.currentNodeId ?? "unknown" + let roomTypeForRewards = battleRoomType ?? .battle runState.updateFromBattle(playerHP: battleEngine.state.player.currentHP) self.runState = runState @@ -119,6 +120,16 @@ final class RunSession { } if battleEngine.state.playerWon == true { + let rewardContext = RewardContext( + seed: runState.seed, + floor: runState.floor, + currentRow: runState.currentRow, + nodeId: nodeId, + roomType: roomTypeForRewards + ) + let goldEarned = GoldRewardStrategy.generateGoldReward(context: rewardContext) + runState.gold += goldEarned + runState.completeCurrentNode() self.runState = runState if runState.isOver { From 7663fd5338c7c0078fde8be7b8e55e708d3d3f58 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: Fri, 6 Feb 2026 22:27:13 +0800 Subject: [PATCH 08/13] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20P2=203D=20?= =?UTF-8?q?=E6=88=98=E6=96=97=E5=BE=AA=E7=8E=AF=E5=AE=9E=E7=8E=B0=E8=AE=A1?= =?UTF-8?q?=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P2.3(Battle HUD):标记为完成,HUD 缩小、日志默认收起、移除 `Panel`,保留 `Exit` - P2.4(3D 战斗层):标记为完成,补充实现细节(使用 `Sphere` 占位、修正手牌扇形朝向与布局) - P2.5(房间接入战斗):标记为完成,补充 `Close` 行为说明(退出 Immersive 并打开控制面板) - 新增 P2.6:战斗胜利发放最小金币奖励(在 RunSession 中生成 RewardContext 并累加金币),并附验证步骤 --- ...P-P2-3D-battle-loop-implementation-plan.md | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md b/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md index 2bb83e3..7918c25 100644 --- a/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md +++ b/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md @@ -20,6 +20,8 @@ - `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build` 通过。 - Simulator:从地图进入战斗(单敌人)后能通过点选 3D 卡牌进行出牌、结束回合,直至胜/负;胜利后回地图继续推进;失败后进入 run over。 - 同 seed + 同节点选择路径:每次进入同一节点战斗的敌人类型/初始 HP 可复现(由派生 seed 决定)。 +- Immersive 期间控制面板窗口默认隐藏(避免“初始页面挡视线”),但在 Immersive 内提供 `Panel/Exit` 入口可随时返回。 + - Update:按反馈移除 `Panel`,仅保留 `Exit`(退出 Immersive 并打开控制面板窗口)。 --- @@ -110,7 +112,7 @@ Verify: `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -de ### P2.3:Battle HUD(先可用/可调试,后再美化) -### Task 3: 添加 Battle HUD Attachment(End Turn + 状态展示) +### ✅Task 3: 添加 Battle HUD Attachment(End Turn + 状态展示) **Files:** -Create: `SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift` @@ -119,11 +121,14 @@ Verify: `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -de **Step 1: 新建 `BattleHUDPanel`(SwiftUI)** - 展示:玩家 HP、能量、回合数、手牌数量 - 按钮:`End Turn` -- (可选)展示:最近 5 条 `BattleEvent` 文本(用于快速定位流程) +- (可选)展示:最近若干条 `BattleEvent` 文本(用于快速定位流程) +- UX 小改(来自截图反馈): + - HUD 缩小,默认收起日志(`Log` 按钮展开) + - 提供 `Exit`(退出 Immersive 并打开控制面板窗口) **Step 2: 在 `ImmersiveRootView` 中新增 attachment** - 仅当 `route == .battle` 时显示 -- 放置:用 `BillboardComponent()` 让面板面向用户;位置先固定在战斗区域前上方 +- 放置:用 `BillboardComponent()` 让面板面向用户;位置固定在视野右上角附近(head anchor 偏移) Verify: `xcodebuild ... build` @@ -131,7 +136,7 @@ Verify: `xcodebuild ... build` ### P2.4:3D 战斗场景渲染(单敌人 + 3D 手牌) -### Task 4: 在 ImmersiveRootView 增加 `battleLayer` 并按 route 切换 +### ✅Task 4: 在 ImmersiveRootView 增加 `battleLayer` 并按 route 切换 **Files:** -Modify: `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` @@ -145,12 +150,14 @@ Verify: `xcodebuild ... build` - 从 `runSession.battleEngine?.state.enemies.first` 读数据 - 用 `ModelEntity`(球/胶囊/盒子)占位,并在实体 `name` 中写入稳定前缀(例如 `enemy:0`) - 材质/颜色按敌人类型简单区分(后续替换为真实模型) + - Note:当前实现使用 `Sphere`(`MeshResource.generateCapsule` 在目标 SDK 不可用) **Step 3: 渲染 3D 手牌(占位卡牌)** - 将“手牌 anchor”绑定到头部(建议:`AnchorEntity(.head)` + 固定 offset),实现“像拿在眼前的一叠牌” - 为每张 `engine.state.hand` 创建 `ModelEntity`(薄盒子),弧形/扇形排布 - `entity.name = "card:"`,并加 `CollisionComponent` + `InputTargetComponent` - 外观:可打出(能量足够)高亮;不可打出变暗 + - UX 小改(来自截图反馈):修正扇形朝向(圆心朝向用户),并让外侧卡牌略靠近用户 **Step 4: 交互:点选卡牌出牌** - `SpatialTapGesture().targetedToAnyEntity()`: @@ -169,7 +176,7 @@ Verify: ### P2.5:从地图房间接入战斗(用户路径) -### Task 5: 房间面板对 battle 节点的行为对齐 +### ✅Task 5: 房间面板对 battle 节点的行为对齐 **Files:** -Modify: `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` @@ -180,11 +187,29 @@ Verify: **Step 2: run over 的 UI** - `route == .runOver` 时:HUD/面板提供 `New Run` 与 `Close`(回控制面板)即可 + - Note:当前实现中 `Close` 会退出 Immersive 并打开控制面板窗口 Verify: Simulator 手动走通 “地图 → 战斗 → 回地图/RunOver” --- +### P2.6:战斗胜利最小奖励(Gold only) + +### ✅Task 6: 战斗胜利发放可复现金币奖励(不做卡牌选择) + +**Files:** +-Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` + +**Step 1: 在战斗胜利分支生成 `RewardContext` 并累加金币** +- 使用 `GoldRewardStrategy.generateGoldReward(context:)` +- `currentRow` 使用 `RunState.currentRow`(在 `completeCurrentNode()` 之前读取,保持与该节点一致) + +Verify: +- `xcodebuild ... build` +- Simulator:赢一场战斗后 `run.gold` 增加(MapHUD/ControlPanel 可见) + +--- + ## Backlog(已记录,后续增强) ### B1:甩牌 / 投掷命中敌人(真机增强 + Simulator 退化) @@ -205,4 +230,3 @@ Verify: Simulator 手动走通 “地图 → 战斗 → 回地图/RunOver” ### B3:真实 3D 模型资产(RealityKitContent) - 将敌人与卡牌外观替换为 `.usdz/.reality` 资产 - 需要:资源加载失败降级为占位几何体,避免沉浸空间白屏 - From c636c2926d00e8466194aecc484df0a5ed6576fb 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: Fri, 6 Feb 2026 22:30:50 +0800 Subject: [PATCH 09/13] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E6=A0=B7=E5=BC=8F=E8=AE=BE=E7=BD=AE=EF=BC=8C=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E5=9C=BA=E6=99=AF=E8=A7=92=E8=89=B2=E4=B8=BA=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E7=A8=8B=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift | 2 +- SaluNative/SaluAVP/Info.plist | 2 +- SaluNative/SaluAVP/SaluAVPApp.swift | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift b/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift index c70c1e9..b3bf04a 100644 --- a/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift +++ b/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift @@ -68,7 +68,7 @@ struct ControlPanelView: View { } } -#Preview(windowStyle: .volumetric) { +#Preview { ControlPanelView() .environment(AppModel()) .environment(RunSession()) diff --git a/SaluNative/SaluAVP/Info.plist b/SaluNative/SaluAVP/Info.plist index 852f456..9dc578a 100644 --- a/SaluNative/SaluAVP/Info.plist +++ b/SaluNative/SaluAVP/Info.plist @@ -5,7 +5,7 @@ UIApplicationSceneManifest UIApplicationPreferredDefaultSceneSessionRole - UIWindowSceneSessionRoleVolumetricApplication + UIWindowSceneSessionRoleApplication UIApplicationSupportsMultipleScenes UISceneConfigurations diff --git a/SaluNative/SaluAVP/SaluAVPApp.swift b/SaluNative/SaluAVP/SaluAVPApp.swift index 5a649be..e1a64e4 100644 --- a/SaluNative/SaluAVP/SaluAVPApp.swift +++ b/SaluNative/SaluAVP/SaluAVPApp.swift @@ -12,7 +12,6 @@ struct SaluAVPApp: App { .environment(appModel) .environment(runSession) } - .windowStyle(.volumetric) ImmersiveSpace(id: appModel.immersiveSpaceID) { ImmersiveRootView() From 8b96c3993ae0fac2d4f30a2039bbba19cd664743 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: Fri, 6 Feb 2026 22:42:30 +0800 Subject: [PATCH 10/13] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=8D=A1?= =?UTF-8?q?=E7=89=8C=E5=A5=96=E5=8A=B1=E9=9D=A2=E6=9D=BF=EF=BC=8C=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=88=98=E6=96=97=E8=83=9C=E5=88=A9=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E4=BB=A5=E6=94=AF=E6=8C=81=E5=A5=96=E5=8A=B1=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...P-P2-3D-battle-loop-implementation-plan.md | 31 +++++ .../ControlPanel/ControlPanelView.swift | 2 + .../SaluAVP/Immersive/CardRewardPanel.swift | 59 ++++++++++ .../SaluAVP/Immersive/ImmersiveRootView.swift | 109 +++++++++++++++--- .../SaluAVP/ViewModels/RunSession.swift | 61 +++++++--- 5 files changed, 235 insertions(+), 27 deletions(-) create mode 100644 SaluNative/SaluAVP/Immersive/CardRewardPanel.swift diff --git a/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md b/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md index 7918c25..a1c81b3 100644 --- a/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md +++ b/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md @@ -210,6 +210,37 @@ Verify: --- +### P2.7:卡牌奖励(3 选 1,可跳过) + +### ✅Task 7: 战斗胜利弹出卡牌奖励面板(Attachment) + +**Files:** +-Create: `SaluNative/SaluAVP/Immersive/CardRewardPanel.swift` +-Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` +-Modify: `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` + +**Step 1: 在 `RunSession.Route` 增加 `cardReward(...)`** +- 进入胜利奖励态:保存 `CardRewardOffer` + `goldEarned`,先不 `completeCurrentNode()`(等选择后再推进) + +**Step 2: 在胜利分支生成 `CardRewardOffer`** +- 使用 `RewardGenerator.generateCardReward(context:)` + +**Step 3: 实现选择接口** +- `chooseCardReward(_ cardId: CardID?)` + - 选中:`runState.addCardToDeck(cardId:)` + - 跳过:若 `offer.canSkip == true` 则允许 + - 完成后:`runState.completeCurrentNode()` → 回到 `.map`(或 `runOver`) + +**Step 4: 在 `ImmersiveRootView` 增加奖励 attachment** +- `route == .cardReward` 时显示奖励面板(HUD anchor) +- 同时隐藏战斗 HUD 与手牌(保留敌人占位体可选) + +Verify: +- `xcodebuild ... build` +- Simulator:赢一场战斗后出现 3 张卡牌按钮 + Skip;选择后牌组增加并回到地图 + +--- + ## Backlog(已记录,后续增强) ### B1:甩牌 / 投掷命中敌人(真机增强 + Simulator 退化) diff --git a/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift b/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift index b3bf04a..258fcdc 100644 --- a/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift +++ b/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift @@ -62,6 +62,8 @@ struct ControlPanelView: View { return "room(\(roomType.rawValue))" case .battle(_, let roomType): return "battle(\(roomType.rawValue))" + case .cardReward(_, let roomType, _, _): + return "cardReward(\(roomType.rawValue))" case .runOver(_, let won, let floor): return "runOver(won:\(won), floor:\(floor))" } diff --git a/SaluNative/SaluAVP/Immersive/CardRewardPanel.swift b/SaluNative/SaluAVP/Immersive/CardRewardPanel.swift new file mode 100644 index 0000000..67bf1cf --- /dev/null +++ b/SaluNative/SaluAVP/Immersive/CardRewardPanel.swift @@ -0,0 +1,59 @@ +import SwiftUI +import GameCore + +struct CardRewardPanel: View { + @Environment(RunSession.self) private var runSession + + let nodeId: String + let roomType: RoomType + let offer: CardRewardOffer + let goldEarned: Int + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Rewards") + .font(.headline) + + Text("Gold +\(goldEarned)") + .font(.caption) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 8) { + ForEach(offer.choices, id: \.rawValue) { cardId in + let def = CardRegistry.require(cardId) + Button { + runSession.chooseCardReward(cardId) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(def.name.resolved(for: .zhHans)) + .font(.body) + Text(verbatim: "\(def.type) cost \(def.cost)") + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.borderedProminent) + } + } + + HStack(spacing: 10) { + Button("Skip") { + runSession.chooseCardReward(nil) + } + .buttonStyle(.bordered) + .disabled(!offer.canSkip) + + Spacer(minLength: 0) + + Text("\(roomType.icon) \(nodeId)") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .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 a6fe944..4b1279d 100644 --- a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift +++ b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift @@ -13,6 +13,7 @@ struct ImmersiveRootView: View { private let roomPanelAttachmentId = "roomPanel" private let battleHudAttachmentId = "battleHUD" private let mapHudAttachmentId = "mapHUD" + private let cardRewardAttachmentId = "cardReward" private let mapLayerPrefix = "mapLayer_floor_" private let uiLayerName = "uiLayer" private let battleLayerName = "battleLayer" @@ -129,19 +130,34 @@ struct ImmersiveRootView: View { return battleLayer }() - let isInBattle: Bool - if case .battle = runSession.route { - isInBattle = true - } else { - isInBattle = false - } + let isInBattle: Bool = { + switch runSession.route { + case .battle, .cardReward: + return true + case .map, .room, .runOver: + return false + } + }() mapLayer.isEnabled = !isInBattle battleLayer.isEnabled = isInBattle - if isInBattle, let engine = runSession.battleEngine { - renderBattle(engine: engine, in: battleLayer) - } else { + switch runSession.route { + case .battle: + if let engine = runSession.battleEngine { + renderBattle(engine: engine, in: battleLayer) + } else { + clearBattle(in: battleLayer) + } + + case .cardReward: + if let state = runSession.battleState { + renderBattleReward(state: state, in: battleLayer) + } else { + clearBattle(in: battleLayer) + } + + case .map, .room, .runOver: clearBattle(in: battleLayer) } @@ -159,11 +175,15 @@ struct ImmersiveRootView: View { hud.name = battleHudAttachmentId hud.components.set(BillboardComponent()) hud.components.set(InputTargetComponent()) - hud.isEnabled = isInBattle + if case .battle = runSession.route { + hud.isEnabled = true + } else { + hud.isEnabled = false + } hudAnchor.children.first(where: { $0.name == battleHudAttachmentId })?.removeFromParent() - // Place HUD near the top-right in the user's view (avoid clipping on Simulator). - hud.position = [0.18, 0.15, -0.50] + // Place HUD near the top-right in the user's view (avoid clipping on Simulator). + hud.position = [0.18, 0.15, -0.50] hudAnchor.addChild(hud) } @@ -174,9 +194,24 @@ struct ImmersiveRootView: View { hud.isEnabled = !isInBattle hudAnchor.children.first(where: { $0.name == mapHudAttachmentId })?.removeFromParent() - hud.position = [0.18, 0.17, -0.50] + hud.position = [0.18, 0.17, -0.50] hudAnchor.addChild(hud) } + + if let panel = attachments.entity(for: cardRewardAttachmentId) { + panel.name = cardRewardAttachmentId + panel.components.set(BillboardComponent()) + panel.components.set(InputTargetComponent()) + if case .cardReward = runSession.route { + panel.isEnabled = true + } else { + panel.isEnabled = false + } + + hudAnchor.children.first(where: { $0.name == cardRewardAttachmentId })?.removeFromParent() + panel.position = [0, 0.02, -0.62] + hudAnchor.addChild(panel) + } } attachments: { Attachment(id: roomPanelAttachmentId) { RoomPanel( @@ -200,6 +235,10 @@ struct ImmersiveRootView: View { Attachment(id: mapHudAttachmentId) { MapHUDPanel() } + + Attachment(id: cardRewardAttachmentId) { + CardRewardAttachment() + } } .gesture( SpatialTapGesture() @@ -217,7 +256,7 @@ struct ImmersiveRootView: View { guard let handIndex = Int(suffix) else { return } runSession.playCard(handIndex: handIndex) - case .room, .runOver: + case .cardReward, .room, .runOver: break } } @@ -242,6 +281,9 @@ struct ImmersiveRootView: View { 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 @@ -336,6 +378,28 @@ struct ImmersiveRootView: View { } } + 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) @@ -525,6 +589,9 @@ private struct RoomPanel: View { case .battle: EmptyView() + case .cardReward: + EmptyView() + case .runOver(_, let won, let floor): VStack(alignment: .leading, spacing: 10) { Text(won ? "🎉 Victory" : "💀 Defeat") @@ -554,6 +621,20 @@ private struct RoomPanel: View { } } +private struct CardRewardAttachment: View { + @Environment(RunSession.self) private var runSession + + 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) + + default: + EmptyView() + } + } +} + private extension RunSession.Route { var isRoom: Bool { if case .room = self { return true } diff --git a/SaluNative/SaluAVP/ViewModels/RunSession.swift b/SaluNative/SaluAVP/ViewModels/RunSession.swift index d7a44d4..2b37262 100644 --- a/SaluNative/SaluAVP/ViewModels/RunSession.swift +++ b/SaluNative/SaluAVP/ViewModels/RunSession.swift @@ -9,6 +9,7 @@ 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 runOver(lastNodeId: String, won: Bool, floor: Int) } @@ -21,6 +22,8 @@ final class RunSession { private(set) var battleState: BattleState? private var battleNodeId: String? private var battleRoomType: RoomType? + private var pendingGoldEarned: Int? + private var pendingCardOffer: CardRewardOffer? func startNewRun() { let seed: UInt64 @@ -41,6 +44,8 @@ final class RunSession { battleState = nil battleNodeId = nil battleRoomType = nil + pendingGoldEarned = nil + pendingCardOffer = nil route = .map } @@ -103,6 +108,35 @@ final class RunSession { return false } + func chooseCardReward(_ cardId: CardID?) { + guard case .cardReward(let nodeId, let roomType, let offer, let goldEarned) = route else { return } + guard var runState else { return } + + if let cardId { + guard offer.choices.contains(cardId) else { return } + runState.addCardToDeck(cardId: cardId) + } else { + guard offer.canSkip else { return } + } + + runState.completeCurrentNode() + self.runState = runState + + pendingGoldEarned = nil + pendingCardOffer = nil + battleState = nil + battleNodeId = nil + battleRoomType = nil + + if runState.isOver { + route = .runOver(lastNodeId: nodeId, won: runState.won, floor: runState.floor) + } else { + _ = roomType + _ = goldEarned + route = .map + } + } + private func finishBattleIfNeeded() { guard let battleEngine, battleEngine.state.isOver else { return } guard var runState else { return } @@ -112,12 +146,8 @@ final class RunSession { runState.updateFromBattle(playerHP: battleEngine.state.player.currentHP) self.runState = runState - defer { - self.battleEngine = nil - self.battleState = nil - self.battleNodeId = nil - self.battleRoomType = nil - } + // Freeze the final battle state for UI (reward panel), but release the engine. + self.battleEngine = nil if battleEngine.state.playerWon == true { let rewardContext = RewardContext( @@ -129,15 +159,18 @@ final class RunSession { ) let goldEarned = GoldRewardStrategy.generateGoldReward(context: rewardContext) runState.gold += goldEarned - - runState.completeCurrentNode() self.runState = runState - if runState.isOver { - route = .runOver(lastNodeId: nodeId, won: runState.won, floor: runState.floor) - } else { - route = .map - } + + let offer = RewardGenerator.generateCardReward(context: rewardContext) + pendingGoldEarned = goldEarned + pendingCardOffer = offer + route = .cardReward(nodeId: nodeId, roomType: roomTypeForRewards, offer: offer, goldEarned: goldEarned) } else { + self.battleState = nil + self.battleNodeId = nil + self.battleRoomType = nil + pendingGoldEarned = nil + pendingCardOffer = nil route = .runOver(lastNodeId: nodeId, won: false, floor: runState.floor) } } @@ -216,5 +249,7 @@ final class RunSession { battleState = nil battleNodeId = nil battleRoomType = nil + pendingGoldEarned = nil + pendingCardOffer = nil } } From a62a3c9816e149663933313c261042ea21aad7ad 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: Fri, 6 Feb 2026 22:50:28 +0800 Subject: [PATCH 11/13] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=88=98?= =?UTF-8?q?=E6=96=97HUD=E9=9D=A2=E6=9D=BF=E7=9A=84=E9=A2=84=E7=9F=A5?= =?UTF-8?q?=E9=80=89=E7=89=8C=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=88=98=E6=96=97=E6=B5=81=E7=A8=8B=EF=BC=9B=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...P-P2-3D-battle-loop-implementation-plan.md | 1 + .../plans/2026-02-06-SaluAVP-P2-code-audit.md | 62 +++++++++++++++++++ .../SaluAVP/Immersive/BattleHUDPanel.swift | 21 +++++++ .../SaluAVP/ViewModels/RunSession.swift | 22 +++---- 4 files changed, 94 insertions(+), 12 deletions(-) create mode 100644 .github/plans/2026-02-06-SaluAVP-P2-code-audit.md diff --git a/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md b/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md index a1c81b3..0073ea8 100644 --- a/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md +++ b/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md @@ -125,6 +125,7 @@ Verify: `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -de - UX 小改(来自截图反馈): - HUD 缩小,默认收起日志(`Log` 按钮展开) - 提供 `Exit`(退出 Immersive 并打开控制面板窗口) + - 支持最小 `BattlePendingInput.foresight` 选牌(避免战斗卡死) **Step 2: 在 `ImmersiveRootView` 中新增 attachment** - 仅当 `route == .battle` 时显示 diff --git a/.github/plans/2026-02-06-SaluAVP-P2-code-audit.md b/.github/plans/2026-02-06-SaluAVP-P2-code-audit.md new file mode 100644 index 0000000..4bd95c6 --- /dev/null +++ b/.github/plans/2026-02-06-SaluAVP-P2-code-audit.md @@ -0,0 +1,62 @@ +# SaluAVP P2 代码审查与遗漏清单(2026-02-06) + +目标:按 `.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md` 的 Task 0–7 逐项对照代码,记录问题并补齐遗漏。 + +## 对照清单(Tasks → Code) + +- ✅ Task 1:`Sources/GameCore/Kernel/StableHash.swift`、`Sources/GameCore/Kernel/SeedDerivation.swift`、`Tests/GameCoreTests/SeedDerivationTests.swift` +- ✅ Task 2:`SaluNative/SaluAVP/ViewModels/RunSession.swift` +- ✅ Task 3:`SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift`、`SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` +- ✅ Task 4:`SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` +- ✅ Task 5:`SaluNative/SaluAVP/ControlPanel/ImmersiveSpaceToggleButton.swift`、`SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` +- ✅ Task 6:`SaluNative/SaluAVP/ViewModels/RunSession.swift` +- ✅ Task 7:`SaluNative/SaluAVP/Immersive/CardRewardPanel.swift`、`SaluNative/SaluAVP/ViewModels/RunSession.swift`、`SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` + +## 发现的问题(需处理) + +### P2-BUG-001:`BattlePendingInput` 未提供 UI 处理路径 + +现状: +- `BattleEngine` 可能进入 `pendingInput`(目前只有 `.foresight`),此时引擎会拒绝出牌/结束回合并发出 `invalidAction`。 +- `BattleHUDPanel` 仅展示 `Pending: ...`,但没有提供 “选择哪张牌” 的 UI,也没有调用 `submitForesightChoice(index:)`。 + +影响: +- 一旦玩家拿到/打出会触发 `foresight` 的卡牌,战斗流程可能卡死(无法继续推进)。 + +建议修复: +- 在 `BattleHUDPanel` 增加一个最小“预知选牌”面板(列出 `options`,按钮选择后调用 `battleEngine.submitForesightChoice(index:)`)。 +- 同时在 `RunSession.playCard/endTurn` 中在 `pendingInput != nil` 时直接忽略输入(避免刷屏 invalidAction)。 + +状态:已修复(见“修复记录”) + +### P2-CLEAN-001:`RunSession` 中有未使用的 pending 字段 + +现状: +- `pendingGoldEarned`、`pendingCardOffer` 只赋值、清空,但不参与任何逻辑(route 已携带同信息)。 + +影响: +- 不影响功能,但会增加状态复杂度与未来维护成本。 + +建议: +- 若短期不计划扩展,可移除;若计划扩展(例如显示上一次奖励摘要),则应真正使用并加注释说明用途。 + +状态:已处理(见“修复记录”) + +### P2-UX-001:奖励面板缺少“退出/返回控制面板”的兜底入口 + +现状: +- `CardRewardPanel` 仅提供选卡与 `Skip`,没有 `Exit`。 +- 如果未来奖励面板出现异常(例如 offer 为空、或逻辑出错无法关闭),用户只能重启应用。 + +建议: +- 给 `CardRewardPanel` 增加一个非侵入的 `Exit`(退出 Immersive 并打开控制面板窗口),或复用已有 HUD anchor 的 `Exit` 入口。 + +状态:未处理(非阻塞) + +## 已补齐的遗漏(修复记录) + +- ✅ 2026-02-06:修复 `P2-BUG-001` + - 增加最小 `foresight` 选牌 UI(BattleHUD)并调用 `RunSession.submitForesightChoice(index:)` + - 在 `RunSession.playCard/endTurn` 遇到 `pendingInput != nil` 时直接忽略输入,避免刷屏 `invalidAction` +- ✅ 2026-02-06:处理 `P2-CLEAN-001` + - 移除 `RunSession.pendingGoldEarned/pendingCardOffer`(route 已携带,不再冗余) diff --git a/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift b/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift index 12ec029..088e36c 100644 --- a/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift +++ b/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift @@ -51,6 +51,27 @@ struct BattleHUDPanel: View { .foregroundStyle(.secondary) } + if let engine, case .foresight(let options, let fromCount) = engine.pendingInput { + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Foresight \(fromCount) → pick 1") + .font(.caption) + .foregroundStyle(.secondary) + + ForEach(Array(options.prefix(6).enumerated()), id: \.offset) { index, card in + let def = CardRegistry.require(card.cardId) + Button { + runSession.submitForesightChoice(index: index) + } label: { + Text(def.name.resolved(for: .zhHans)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.bordered) + } + } + } + HStack(spacing: 10) { Button("End Turn") { runSession.endTurn() diff --git a/SaluNative/SaluAVP/ViewModels/RunSession.swift b/SaluNative/SaluAVP/ViewModels/RunSession.swift index 2b37262..a530b64 100644 --- a/SaluNative/SaluAVP/ViewModels/RunSession.swift +++ b/SaluNative/SaluAVP/ViewModels/RunSession.swift @@ -22,8 +22,6 @@ final class RunSession { private(set) var battleState: BattleState? private var battleNodeId: String? private var battleRoomType: RoomType? - private var pendingGoldEarned: Int? - private var pendingCardOffer: CardRewardOffer? func startNewRun() { let seed: UInt64 @@ -44,8 +42,6 @@ final class RunSession { battleState = nil battleNodeId = nil battleRoomType = nil - pendingGoldEarned = nil - pendingCardOffer = nil route = .map } @@ -90,6 +86,7 @@ final class RunSession { func playCard(handIndex: Int) { guard routeIsBattle else { return } guard let battleEngine else { return } + guard battleEngine.pendingInput == nil else { return } _ = battleEngine.handleAction(.playCard(handIndex: handIndex, targetEnemyIndex: nil)) battleState = battleEngine.state finishBattleIfNeeded() @@ -98,6 +95,7 @@ final class RunSession { func endTurn() { guard routeIsBattle else { return } guard let battleEngine else { return } + guard battleEngine.pendingInput == nil else { return } _ = battleEngine.handleAction(.endTurn) battleState = battleEngine.state finishBattleIfNeeded() @@ -108,6 +106,14 @@ final class RunSession { return false } + func submitForesightChoice(index: Int) { + guard routeIsBattle else { return } + guard let battleEngine else { return } + _ = battleEngine.submitForesightChoice(index: index) + battleState = battleEngine.state + finishBattleIfNeeded() + } + func chooseCardReward(_ cardId: CardID?) { guard case .cardReward(let nodeId, let roomType, let offer, let goldEarned) = route else { return } guard var runState else { return } @@ -122,8 +128,6 @@ final class RunSession { runState.completeCurrentNode() self.runState = runState - pendingGoldEarned = nil - pendingCardOffer = nil battleState = nil battleNodeId = nil battleRoomType = nil @@ -162,15 +166,11 @@ final class RunSession { self.runState = runState let offer = RewardGenerator.generateCardReward(context: rewardContext) - pendingGoldEarned = goldEarned - pendingCardOffer = offer route = .cardReward(nodeId: nodeId, roomType: roomTypeForRewards, offer: offer, goldEarned: goldEarned) } else { self.battleState = nil self.battleNodeId = nil self.battleRoomType = nil - pendingGoldEarned = nil - pendingCardOffer = nil route = .runOver(lastNodeId: nodeId, won: false, floor: runState.floor) } } @@ -249,7 +249,5 @@ final class RunSession { battleState = nil battleNodeId = nil battleRoomType = nil - pendingGoldEarned = nil - pendingCardOffer = nil } } From 347fca08f6e757d37f45c00f46428e24403b9b57 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: Fri, 6 Feb 2026 22:52:04 +0800 Subject: [PATCH 12/13] =?UTF-8?q?delete:=20=E7=A7=BB=E9=99=A4=E8=BF=87?= =?UTF-8?q?=E6=97=B6=E7=9A=84=E4=BB=A3=E7=A0=81=E5=AE=A1=E6=9F=A5=E4=B8=8E?= =?UTF-8?q?=E9=81=97=E6=BC=8F=E6=B8=85=E5=8D=95=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-02-06-SaluAVP-P2-code-audit.md | 62 ------------------- 1 file changed, 62 deletions(-) delete mode 100644 .github/plans/2026-02-06-SaluAVP-P2-code-audit.md diff --git a/.github/plans/2026-02-06-SaluAVP-P2-code-audit.md b/.github/plans/2026-02-06-SaluAVP-P2-code-audit.md deleted file mode 100644 index 4bd95c6..0000000 --- a/.github/plans/2026-02-06-SaluAVP-P2-code-audit.md +++ /dev/null @@ -1,62 +0,0 @@ -# SaluAVP P2 代码审查与遗漏清单(2026-02-06) - -目标:按 `.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md` 的 Task 0–7 逐项对照代码,记录问题并补齐遗漏。 - -## 对照清单(Tasks → Code) - -- ✅ Task 1:`Sources/GameCore/Kernel/StableHash.swift`、`Sources/GameCore/Kernel/SeedDerivation.swift`、`Tests/GameCoreTests/SeedDerivationTests.swift` -- ✅ Task 2:`SaluNative/SaluAVP/ViewModels/RunSession.swift` -- ✅ Task 3:`SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift`、`SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` -- ✅ Task 4:`SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` -- ✅ Task 5:`SaluNative/SaluAVP/ControlPanel/ImmersiveSpaceToggleButton.swift`、`SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` -- ✅ Task 6:`SaluNative/SaluAVP/ViewModels/RunSession.swift` -- ✅ Task 7:`SaluNative/SaluAVP/Immersive/CardRewardPanel.swift`、`SaluNative/SaluAVP/ViewModels/RunSession.swift`、`SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` - -## 发现的问题(需处理) - -### P2-BUG-001:`BattlePendingInput` 未提供 UI 处理路径 - -现状: -- `BattleEngine` 可能进入 `pendingInput`(目前只有 `.foresight`),此时引擎会拒绝出牌/结束回合并发出 `invalidAction`。 -- `BattleHUDPanel` 仅展示 `Pending: ...`,但没有提供 “选择哪张牌” 的 UI,也没有调用 `submitForesightChoice(index:)`。 - -影响: -- 一旦玩家拿到/打出会触发 `foresight` 的卡牌,战斗流程可能卡死(无法继续推进)。 - -建议修复: -- 在 `BattleHUDPanel` 增加一个最小“预知选牌”面板(列出 `options`,按钮选择后调用 `battleEngine.submitForesightChoice(index:)`)。 -- 同时在 `RunSession.playCard/endTurn` 中在 `pendingInput != nil` 时直接忽略输入(避免刷屏 invalidAction)。 - -状态:已修复(见“修复记录”) - -### P2-CLEAN-001:`RunSession` 中有未使用的 pending 字段 - -现状: -- `pendingGoldEarned`、`pendingCardOffer` 只赋值、清空,但不参与任何逻辑(route 已携带同信息)。 - -影响: -- 不影响功能,但会增加状态复杂度与未来维护成本。 - -建议: -- 若短期不计划扩展,可移除;若计划扩展(例如显示上一次奖励摘要),则应真正使用并加注释说明用途。 - -状态:已处理(见“修复记录”) - -### P2-UX-001:奖励面板缺少“退出/返回控制面板”的兜底入口 - -现状: -- `CardRewardPanel` 仅提供选卡与 `Skip`,没有 `Exit`。 -- 如果未来奖励面板出现异常(例如 offer 为空、或逻辑出错无法关闭),用户只能重启应用。 - -建议: -- 给 `CardRewardPanel` 增加一个非侵入的 `Exit`(退出 Immersive 并打开控制面板窗口),或复用已有 HUD anchor 的 `Exit` 入口。 - -状态:未处理(非阻塞) - -## 已补齐的遗漏(修复记录) - -- ✅ 2026-02-06:修复 `P2-BUG-001` - - 增加最小 `foresight` 选牌 UI(BattleHUD)并调用 `RunSession.submitForesightChoice(index:)` - - 在 `RunSession.playCard/endTurn` 遇到 `pendingInput != nil` 时直接忽略输入,避免刷屏 `invalidAction` -- ✅ 2026-02-06:处理 `P2-CLEAN-001` - - 移除 `RunSession.pendingGoldEarned/pendingCardOffer`(route 已携带,不再冗余) From cbc4a80a5392e757a909c3c82f3fac6b8042969d 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: Fri, 6 Feb 2026 23:05:16 +0800 Subject: [PATCH 13/13] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20Apple=20Visi?= =?UTF-8?q?on=20Pro=203D=20=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92=EF=BC=8C?= =?UTF-8?q?=E7=BB=86=E5=8C=96=20P2=20=E5=AE=9E=E6=96=BD=E4=B8=8E=E5=90=8E?= =?UTF-8?q?=E7=BB=AD=20Backlog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...P-P2-3D-battle-loop-implementation-plan.md | 264 ------------------ ...216\260\357\274\210SaluAVP\357\274\211.md" | 39 ++- 2 files changed, 38 insertions(+), 265 deletions(-) delete mode 100644 .github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md diff --git a/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md b/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md deleted file mode 100644 index 0073ea8..0000000 --- a/.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md +++ /dev/null @@ -1,264 +0,0 @@ -# SaluAVP P2:3D 战斗闭环(同一个 ImmersiveSpace)实施计划 - -> 执行方式:建议使用 `executing-plans` 分批实现与验收(每批次都跑一次 `xcodebuild` 回归)。 - -**Goal(目标):** 在 `SaluAVP` 的同一个 `ImmersiveSpace` 内打通战斗闭环:从地图进入战斗 → 3D 敌人 + 3D 手牌交互出牌 → 敌人回合 → 胜负结算 → 回到地图继续推进(或 run over)。 - -**Non-goals(非目标):** -- 不做“甩牌/投掷命中敌人”的手势(先记录为后续增强)。 -- 不做多敌人(先记录为后续扩展);本阶段只支持单敌人(`BattleEngine` 会自动解析 `targetEnemyIndex == nil` 的目标)。 -- 不做完整 VFX/动画/资产管线;敌人与卡牌先用占位几何体,后续替换为真实 3D 资源。 -- 不做存档持久化(P3 再做)。 - -**Approach(方案):** -- `RunSession` 扩展战斗路由与 `BattleEngine` 生命周期:进入战斗节点时创建引擎并 `startBattle()`;玩家交互转换为 `PlayerAction`;战斗结束后用 `RunState.updateFromBattle(playerHP:)` + `completeCurrentNode()` 推进冒险并回到地图。 -- `ImmersiveRootView` 在同一个 `RealityView` 中按 `route` 切换渲染层:`mapLayer` ↔ `battleLayer`(地图隐藏/移除)。 -- 手牌使用 RealityKit `ModelEntity`(3D 卡牌)呈现并可点选出牌;目标先按“单敌人自动目标”走通流程(后续再扩展到“拖拽/甩牌命中 + 多敌人目标”)。 -- 可复现性:战斗 seed 由(run seed + floor + nodeId)稳定派生,禁止使用 `hashValue`(非稳定)。 - -**Acceptance(验收):** -- `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build` 通过。 -- Simulator:从地图进入战斗(单敌人)后能通过点选 3D 卡牌进行出牌、结束回合,直至胜/负;胜利后回地图继续推进;失败后进入 run over。 -- 同 seed + 同节点选择路径:每次进入同一节点战斗的敌人类型/初始 HP 可复现(由派生 seed 决定)。 -- Immersive 期间控制面板窗口默认隐藏(避免“初始页面挡视线”),但在 Immersive 内提供 `Panel/Exit` 入口可随时返回。 - - Update:按反馈移除 `Panel`,仅保留 `Exit`(退出 Immersive 并打开控制面板窗口)。 - ---- - -## Plan A(主方案) - -### P2.0:记录 deferred(避免遗忘/范围漂移) - -### ✅Task 0: 把“甩牌命中”和“多敌人”记录为后续里程碑 - -**Files:** --Create: `.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md` - -**Step 1: 记录后续增强点(本文件末尾的 Backlog)** - -**Step 2:(可选)在主计划 `.github/plans/Apple Vision Pro 原生 3D 实现(SaluAVP).md` 的 P2 段落补一行“已选择:3D 卡牌(RealityKit)/ 单敌人 MVP / 甩牌与多敌人 deferred”。** - -Verify: 无(文档变更)。 - ---- - -### P2.1:稳定派生 seed(可测试,避免 UI 层随意拼) - -### ✅Task 1: 在 GameCore 增加稳定字符串哈希 + seed 派生工具 - -**Files:** --Create: `Sources/GameCore/Kernel/StableHash.swift` --Create: `Sources/GameCore/Kernel/SeedDerivation.swift` --Test: `Tests/GameCoreTests/SeedDerivationTests.swift` - -**Step 1: 写失败测试** -- 覆盖:同输入多次结果一致;不同 nodeId 结果不同;不使用 `hashValue`。 - -Run: `swift test --filter GameCoreTests.SeedDerivationTests` -Expected: FAIL(类型/函数不存在) - -**Step 2: 写最小实现让测试通过** -- `StableHash.fnv1a64(_:)`:对 `String.utf8` 做 FNV-1a 64-bit。 -- `SeedDerivation.battleSeed(runSeed:floor:nodeId:)`:`runSeed ^ fnv1a64(nodeId) &+ UInt64(floor) &* 1_000_000_000`(或其它简单可复现组合;保持 64-bit 溢出语义)。 - -Run: `swift test --filter GameCoreTests.SeedDerivationTests` -Expected: PASS - -**Step 3: 回归 GameCore** -Run: `swift test --filter GameCoreTests` -Expected: PASS - ---- - -### P2.2:RunSession 增加战斗路由与引擎生命周期 - -### ✅Task 2: 扩展 RunSession 支持 `.battle` route + BattleEngine - -**Files:** --Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` - -**Step 1: 增加 route 与状态** -- `RunSession.Route` 新增:`case battle(nodeId: String, roomType: RoomType)` -- 新增可观察状态: - - `var battleEngine: BattleEngine?` - - `var battleNodeId: String?`(可选,用于断言与回溯) - - `var battleError: String?`(可选) - -**Step 2: 进入节点时分流** -- `selectAccessibleNode(_:)`: - - 若 `node.roomType` 是 `.battle/.elite/.boss`:创建战斗并 `route = .battle(...)` - - 否则维持现有 `route = .room(...)` - -**Step 3: 创建单敌人 BattleEngine(可复现)** -- 从 `runState` 取:`player`、`deck`、`relicManager` -- 用 `SeedDerivation.battleSeed(runSeed:floor:nodeId:)` 得到 battle seed -- 用 `SeededRNG(seed:)` 从对应 Act 的 `ActXEncounterPool.randomWeak(rng:)` 选一次遭遇 -- **MVP:只取 `encounter.enemyIds.first!` 生成 1 个敌人** - - 敌人用 `createEnemy(enemyId:instanceIndex:rng:)`,`instanceIndex = 0` -- 初始化 `BattleEngine(player:enemies:deck:relicManager:seed:)` 并 `startBattle()` - -**Step 4: 暴露最小交互 API** -- `playCard(handIndex:)`:包装 `battleEngine?.handleAction(.playCard(handIndex: handIndex, targetEnemyIndex: nil))` -- `endTurn()`:包装 `battleEngine?.handleAction(.endTurn)` -- `isBattleOver/won`:从 `engine.state.isOver` + `engine.state.playerWon` 读 - -**Step 5: 结束战斗并回到地图** -- 胜利:`runState.updateFromBattle(playerHP: engine.state.player.currentHP)` → `runState.completeCurrentNode()` → `battleEngine = nil` → `route = .map` -- 失败:`runState.updateFromBattle(...)`(会把 run 置为 over)→ `battleEngine = nil` → `route = .runOver(...)` - -Verify: `xcodebuild -project SaluNative/SaluNative.xcodeproj -scheme SaluAVP -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build` - ---- - -### P2.3:Battle HUD(先可用/可调试,后再美化) - -### ✅Task 3: 添加 Battle HUD Attachment(End Turn + 状态展示) - -**Files:** --Create: `SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift` --Modify: `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` - -**Step 1: 新建 `BattleHUDPanel`(SwiftUI)** -- 展示:玩家 HP、能量、回合数、手牌数量 -- 按钮:`End Turn` -- (可选)展示:最近若干条 `BattleEvent` 文本(用于快速定位流程) -- UX 小改(来自截图反馈): - - HUD 缩小,默认收起日志(`Log` 按钮展开) - - 提供 `Exit`(退出 Immersive 并打开控制面板窗口) - - 支持最小 `BattlePendingInput.foresight` 选牌(避免战斗卡死) - -**Step 2: 在 `ImmersiveRootView` 中新增 attachment** -- 仅当 `route == .battle` 时显示 -- 放置:用 `BillboardComponent()` 让面板面向用户;位置固定在视野右上角附近(head anchor 偏移) - -Verify: `xcodebuild ... build` - ---- - -### P2.4:3D 战斗场景渲染(单敌人 + 3D 手牌) - -### ✅Task 4: 在 ImmersiveRootView 增加 `battleLayer` 并按 route 切换 - -**Files:** --Modify: `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` - -**Step 1: battle layer 基础结构** -- `mapRoot` 下新增 `battleLayer`(命名例如 `battleLayer`) -- `route == .battle`:`mapLayer.isEnabled = false`、`battleLayer.isEnabled = true` -- `route == .map/.room/.runOver`:反向切换 - -**Step 2: 渲染单敌人(占位体)** -- 从 `runSession.battleEngine?.state.enemies.first` 读数据 -- 用 `ModelEntity`(球/胶囊/盒子)占位,并在实体 `name` 中写入稳定前缀(例如 `enemy:0`) -- 材质/颜色按敌人类型简单区分(后续替换为真实模型) - - Note:当前实现使用 `Sphere`(`MeshResource.generateCapsule` 在目标 SDK 不可用) - -**Step 3: 渲染 3D 手牌(占位卡牌)** -- 将“手牌 anchor”绑定到头部(建议:`AnchorEntity(.head)` + 固定 offset),实现“像拿在眼前的一叠牌” -- 为每张 `engine.state.hand` 创建 `ModelEntity`(薄盒子),弧形/扇形排布 -- `entity.name = "card:"`,并加 `CollisionComponent` + `InputTargetComponent` -- 外观:可打出(能量足够)高亮;不可打出变暗 - - UX 小改(来自截图反馈):修正扇形朝向(圆心朝向用户),并让外侧卡牌略靠近用户 - -**Step 4: 交互:点选卡牌出牌** -- `SpatialTapGesture().targetedToAnyEntity()`: - - 命中 `card:` 且 `route == .battle` → `runSession.playCard(handIndex: idx)` -- 出牌后 UI 更新应由 `RealityView update` 重新渲染手牌(以 `engine.state.hand` 为源) - -**Step 5: 战斗结束检测与自动回收** -- 每次出牌/结束回合后: - - 若 `engine.state.isOver == true`:调用 `RunSession` 的“结算并回地图/RunOver”逻辑 - -Verify: -- `xcodebuild ... build` -- Simulator 手动验收:进入战斗后点几张牌 → `End Turn` → 直到战斗结束 - ---- - -### P2.5:从地图房间接入战斗(用户路径) - -### ✅Task 5: 房间面板对 battle 节点的行为对齐 - -**Files:** --Modify: `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` - -**Step 1: battle 节点进入方式** -- 建议:点选 battle 节点后 **直接进入 `.battle`**(不先进入 `.room`),减少多一步点击 -- 若保留 `.room`:则 `RoomPanel` 对 battle 类型显示 `Enter Battle`(而不是 `Complete`) - -**Step 2: run over 的 UI** -- `route == .runOver` 时:HUD/面板提供 `New Run` 与 `Close`(回控制面板)即可 - - Note:当前实现中 `Close` 会退出 Immersive 并打开控制面板窗口 - -Verify: Simulator 手动走通 “地图 → 战斗 → 回地图/RunOver” - ---- - -### P2.6:战斗胜利最小奖励(Gold only) - -### ✅Task 6: 战斗胜利发放可复现金币奖励(不做卡牌选择) - -**Files:** --Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` - -**Step 1: 在战斗胜利分支生成 `RewardContext` 并累加金币** -- 使用 `GoldRewardStrategy.generateGoldReward(context:)` -- `currentRow` 使用 `RunState.currentRow`(在 `completeCurrentNode()` 之前读取,保持与该节点一致) - -Verify: -- `xcodebuild ... build` -- Simulator:赢一场战斗后 `run.gold` 增加(MapHUD/ControlPanel 可见) - ---- - -### P2.7:卡牌奖励(3 选 1,可跳过) - -### ✅Task 7: 战斗胜利弹出卡牌奖励面板(Attachment) - -**Files:** --Create: `SaluNative/SaluAVP/Immersive/CardRewardPanel.swift` --Modify: `SaluNative/SaluAVP/ViewModels/RunSession.swift` --Modify: `SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` - -**Step 1: 在 `RunSession.Route` 增加 `cardReward(...)`** -- 进入胜利奖励态:保存 `CardRewardOffer` + `goldEarned`,先不 `completeCurrentNode()`(等选择后再推进) - -**Step 2: 在胜利分支生成 `CardRewardOffer`** -- 使用 `RewardGenerator.generateCardReward(context:)` - -**Step 3: 实现选择接口** -- `chooseCardReward(_ cardId: CardID?)` - - 选中:`runState.addCardToDeck(cardId:)` - - 跳过:若 `offer.canSkip == true` 则允许 - - 完成后:`runState.completeCurrentNode()` → 回到 `.map`(或 `runOver`) - -**Step 4: 在 `ImmersiveRootView` 增加奖励 attachment** -- `route == .cardReward` 时显示奖励面板(HUD anchor) -- 同时隐藏战斗 HUD 与手牌(保留敌人占位体可选) - -Verify: -- `xcodebuild ... build` -- Simulator:赢一场战斗后出现 3 张卡牌按钮 + Skip;选择后牌组增加并回到地图 - ---- - -## Backlog(已记录,后续增强) - -### B1:甩牌 / 投掷命中敌人(真机增强 + Simulator 退化) -- 真机:抓起 3D 卡牌 → 释放时根据速度/方向投射 → 命中敌人触发出牌动画与结算 -- Simulator:拖拽卡牌 → 放到敌人身上判定命中(命中后播放飞行动画) -- 需要引入: - - “抓取/拖拽”手势(targeted drag) - - 命中判定(碰撞体 + 接触回调或射线检测) - - 卡牌回收与失败落点处理 - -### B2:多敌人(2–3 敌人)与目标选择 -- 敌人布局:战斗场景内左右排布 -- 目标选择: - - 点选敌人锁定目标;或拖拽/投掷命中指定敌人 - - `PlayerAction.playCard(targetEnemyIndex: selectedIndex)` -- 注意:`BattleEngine` 在多敌人时对 `.singleEnemy` 卡牌要求显式目标(否则会报 “该牌需要选择目标”) - -### B3:真实 3D 模型资产(RealityKitContent) -- 将敌人与卡牌外观替换为 `.usdz/.reality` 资产 -- 需要:资源加载失败降级为占位几何体,避免沉浸空间白屏 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 77d81f1..a7520c6 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" @@ -183,10 +183,47 @@ SaluNative/ - 同一个 `ImmersiveSpace` 内切换 `map` ↔ `battle`(地图隐藏/移除,战斗结束再回地图)。 - 手牌使用 RealityKit 3D 实体(`ModelEntity`)呈现与交互(先点选出牌;后续再做“甩牌命中”)。 - 先支持单敌人(多敌人 + 目标选择 deferred)。 - - 详细任务拆分见:`.github/plans/2026-02-06-SaluAVP-P2-3D-battle-loop-implementation-plan.md` - DoD: - 战斗可完整结束(胜/负),并能回到地图继续推进。 +P2 实施细化(✅ 已完成,2026-02-06): + +- ✅ 可复现 battle seed:`Sources/GameCore/Kernel/StableHash.swift`、`Sources/GameCore/Kernel/SeedDerivation.swift`、`Tests/GameCoreTests/SeedDerivationTests.swift` +- ✅ `RunSession` 打通 battle → reward → map:`SaluNative/SaluAVP/ViewModels/RunSession.swift` + - 路由:`.battle(...)`、`.cardReward(...)`、`.runOver(...)` + - 胜利奖励:金币(可复现)+ 卡牌奖励 3 选 1(可跳过) +- ✅ Immersive 内战斗渲染与交互:`SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift` + - `mapLayer` ↔ `battleLayer` 切换 + - 单敌人占位体 + 3D 手牌扇形排布(点选卡牌出牌) + - HUD / Reward 以 Attachment 形式呈现,并有独立的 head anchor(避免被 battleLayer 开关误伤) +- ✅ Battle HUD(含最小 pendingInput 支持):`SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift` + - 支持 `BattlePendingInput.foresight` 选牌(避免战斗卡死) +- ✅ Map HUD:`SaluNative/SaluAVP/Immersive/MapHUDPanel.swift` +- ✅ 卡牌奖励面板:`SaluNative/SaluAVP/Immersive/CardRewardPanel.swift` +- ✅ Window/role 对齐(控制面板为 2D window,Immersive 期间默认隐藏):`SaluNative/SaluAVP/SaluAVPApp.swift`、`SaluNative/SaluAVP/ControlPanel/ImmersiveSpaceToggleButton.swift`、`SaluNative/SaluAVP/Info.plist` + +### P2 后续 Backlog(未做): + +#### B1:甩牌 / 投掷命中敌人(真机增强 + Simulator 退化) +- 真机:抓起 3D 卡牌 → 释放时根据速度/方向投射 → 命中敌人触发出牌动画与结算 +- Simulator:拖拽卡牌 → 放到敌人身上判定命中(命中后播放飞行动画) +- 需要引入: + - “抓取/拖拽”手势(targeted drag) + - 命中判定(碰撞体 + 接触回调或射线检测) + - 卡牌回收与失败落点处理 + +#### B2:多敌人(2–3 敌人)与目标选择 +- 敌人布局:战斗场景内左右排布 +- 目标选择: + - 点选敌人锁定目标;或拖拽/投掷命中指定敌人 + - `PlayerAction.playCard(targetEnemyIndex: selectedIndex)` +- 注意:`BattleEngine` 在多敌人时对 `.singleEnemy` 卡牌要求显式目标(否则会报 “该牌需要选择目标”) + +#### B3:真实 3D 模型资产(RealityKitContent) +- 将敌人与卡牌外观替换为 `.usdz/.reality` 资产 +- 需要:资源加载失败降级为占位几何体,避免沉浸空间白屏 + + ### P3(后续):持久化(SwiftData / JSON Blob) - 原则:继续沿用“索引字段 + JSON Blob”的策略,降低模型演进成本。