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..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"
@@ -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,9 +179,51 @@ SaluNative/
- ImmersiveSpace 中渲染:玩家/敌人/手牌/能量/日志(形式不限,先可读可用)。
- Session 桥接 `GameCore` 的战斗推进(当前阶段优先放在 `SaluNative/SaluAVP/ViewModels/`;需要跨 Target 复用时再引入 `SaluNative/Shared/`)。
- 战斗结束后:更新 `RunState`(例如 `updateFromBattle(playerHP:)`)→ 应用奖励/推进地图(奖励可先最小化)。
+- 已选实现方向(MVP):
+ - 同一个 `ImmersiveSpace` 内切换 `map` ↔ `battle`(地图隐藏/移除,战斗结束再回地图)。
+ - 手牌使用 RealityKit 3D 实体(`ModelEntity`)呈现与交互(先点选出牌;后续再做“甩牌命中”)。
+ - 先支持单敌人(多敌人 + 目标选择 deferred)。
- 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”的策略,降低模型演进成本。
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/ControlPanelView.swift b/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift
index f6445d9..258fcdc 100644
--- a/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift
+++ b/SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift
@@ -60,13 +60,17 @@ struct ControlPanelView: View {
return "map"
case .room(_, let roomType):
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))"
}
}
}
-#Preview(windowStyle: .volumetric) {
+#Preview {
ControlPanelView()
.environment(AppModel())
.environment(RunSession())
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/Immersive/BattleHUDPanel.swift b/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift
new file mode 100644
index 0000000..088e36c
--- /dev/null
+++ b/SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift
@@ -0,0 +1,118 @@
+import SwiftUI
+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
+
+ let battleState = runSession.battleState
+ let engine = runSession.battleEngine
+
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(spacing: 8) {
+ Text("Battle")
+ .font(.headline)
+
+ Spacer(minLength: 0)
+
+ Button(isLogExpanded ? "Hide" : "Log") {
+ isLogExpanded.toggle()
+ }
+ .font(.caption2)
+ .buttonStyle(.bordered)
+ }
+
+ if let state = battleState {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Player HP \(state.player.currentHP)/\(state.player.maxHP) Block \(state.player.block)")
+ .font(.caption)
+ let enemy = state.enemies.first
+ Text("Enemy: \(enemy.map { $0.name.resolved(for: .zhHans) } ?? "-") HP \(enemy?.currentHP ?? 0)/\(enemy?.maxHP ?? 0) Block \(enemy?.block ?? 0)")
+ .font(.caption)
+ Text("Turn \(state.turn) Energy \(state.energy)/\(state.maxEnergy) \(state.isPlayerTurn ? "Player" : "Enemy")")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+
+ if let pending = engine?.pendingInput {
+ Text("Pending: \(pendingLabel(pending))")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+ } else {
+ Text("No battle state.")
+ .font(.caption)
+ .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()
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(!(battleState?.isPlayerTurn ?? false) || (engine?.pendingInput != nil))
+
+ Button("Exit") {
+ Task { @MainActor in
+ await dismissImmersiveSpace()
+ openWindow(id: AppModel.controlPanelWindowID)
+ }
+ }
+ .buttonStyle(.bordered)
+ }
+
+ if isLogExpanded, 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: 120)
+ }
+ }
+ .padding(12)
+ .background(.regularMaterial)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ .frame(width: 260)
+ }
+
+ private func pendingLabel(_ pending: BattlePendingInput) -> String {
+ switch pending {
+ case .foresight(_, let fromCount):
+ return "Foresight(\(fromCount))"
+ }
+ }
+}
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 0d58e60..4b1279d 100644
--- a/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift
+++ b/SaluNative/SaluAVP/Immersive/ImmersiveRootView.swift
@@ -5,11 +5,22 @@ 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 cardRewardAttachmentId = "cardReward"
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"
var body: some View {
RealityView { content, attachments in
@@ -24,6 +35,33 @@ 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
+
+ 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 }
@@ -37,8 +75,16 @@ 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
return
}
@@ -60,6 +106,61 @@ 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 = {
+ switch runSession.route {
+ case .battle, .cardReward:
+ return true
+ case .map, .room, .runOver:
+ return false
+ }
+ }()
+
+ mapLayer.isEnabled = !isInBattle
+ battleLayer.isEnabled = isInBattle
+
+ 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)
+ }
+
if let panel = attachments.entity(for: roomPanelAttachmentId) {
panel.name = roomPanelAttachmentId
panel.components.set(BillboardComponent())
@@ -69,24 +170,95 @@ 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())
+ 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]
+ 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)
+ }
+
+ 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(
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()
+ }
+
+ Attachment(id: cardRewardAttachmentId) {
+ CardRewardAttachment()
+ }
}
.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 .cardReward, .room, .runOver:
+ break
+ }
}
)
}
@@ -106,6 +278,12 @@ struct ImmersiveRootView: View {
// Fallback: place in front of the map origin.
return (true, [0, 0.25, -0.55])
+ case .battle:
+ return (false, .zero)
+
+ case .cardReward:
+ return (false, .zero)
+
case .runOver(let lastNodeId, _, _):
// End-of-run panel should be easy to find: keep it near the map origin instead of far away at the Boss node.
_ = lastNodeId
@@ -121,6 +299,139 @@ 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
+ // Outer cards should come slightly closer to the user.
+ let z = abs(dx) * 0.02
+ entity.position = [x, 0, z]
+
+ // Fan the cards toward the user (arc center facing the player).
+ let yaw = -dx * 0.22
+ let pitch: Float = 0.18
+ entity.orientation = simd_quatf(angle: yaw, axis: [0, 1, 0]) * simd_quatf(angle: pitch, axis: [1, 0, 0])
+
+ handRoot.addChild(entity)
+ }
+ }
+
+ private func renderBattleReward(state: BattleState, in battleLayer: RealityKit.Entity) {
+ let enemyRoot = battleLayer.findEntity(named: battleEnemyRootName) ?? {
+ let root = RealityKit.Entity()
+ root.name = battleEnemyRootName
+ root.position = [0, 0.14, -1.0]
+ battleLayer.addChild(root)
+ return root
+ }()
+
+ enemyRoot.children.forEach { $0.removeFromParent() }
+ if let enemy = state.enemies.first {
+ let enemyEntity = makeEnemyEntity(enemy: enemy)
+ enemyEntity.position = .zero
+ enemyRoot.addChild(enemyEntity)
+ }
+
+ battleLayer.findEntity(named: battleHeadAnchorName)?
+ .findEntity(named: battleHandRootName)?
+ .children
+ .forEach { $0.removeFromParent() }
+ }
+
+ private func makeEnemyEntity(enemy: GameCore.Entity) -> ModelEntity {
+ let material = SimpleMaterial(color: UIColor.systemRed.withAlphaComponent(0.85), isMetallic: true)
+ let mesh = MeshResource.generateSphere(radius: 0.14)
+ let entity = ModelEntity(mesh: mesh, materials: [material])
+ entity.name = "enemy:0"
+ entity.components.set(CollisionComponent(shapes: [.generateSphere(radius: 0.14)]))
+ entity.components.set(InputTargetComponent())
+ return entity
+ }
+
+ private func makeCardEntity(card: Card, isPlayable: Bool) -> 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 {
@@ -275,6 +586,12 @@ private struct RoomPanel: View {
.buttonStyle(.borderedProminent)
}
+ case .battle:
+ EmptyView()
+
+ case .cardReward:
+ EmptyView()
+
case .runOver(_, let won, let floor):
VStack(alignment: .leading, spacing: 10) {
Text(won ? "🎉 Victory" : "💀 Defeat")
@@ -304,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/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/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 e95eded..e1a64e4 100644
--- a/SaluNative/SaluAVP/SaluAVPApp.swift
+++ b/SaluNative/SaluAVP/SaluAVPApp.swift
@@ -7,12 +7,11 @@ struct SaluAVPApp: App {
@State private var runSession = RunSession()
var body: some Scene {
- WindowGroup {
+ WindowGroup(id: AppModel.controlPanelWindowID) {
ControlPanelView()
.environment(appModel)
.environment(runSession)
}
- .windowStyle(.volumetric)
ImmersiveSpace(id: appModel.immersiveSpaceID) {
ImmersiveRootView()
diff --git a/SaluNative/SaluAVP/ViewModels/RunSession.swift b/SaluNative/SaluAVP/ViewModels/RunSession.swift
index 8987f4a..a530b64 100644
--- a/SaluNative/SaluAVP/ViewModels/RunSession.swift
+++ b/SaluNative/SaluAVP/ViewModels/RunSession.swift
@@ -8,6 +8,8 @@ final class RunSession {
enum Route: Equatable, Sendable {
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)
}
@@ -16,6 +18,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 +38,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 +56,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 +83,160 @@ 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()
+ }
+
+ func endTurn() {
+ guard routeIsBattle else { return }
+ guard let battleEngine else { return }
+ guard battleEngine.pendingInput == nil else { return }
+ _ = battleEngine.handleAction(.endTurn)
+ battleState = battleEngine.state
+ finishBattleIfNeeded()
+ }
+
+ private var routeIsBattle: Bool {
+ if case .battle = route { return true }
+ 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 }
+
+ 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
+
+ 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 }
+
+ let nodeId = battleNodeId ?? runState.currentNodeId ?? "unknown"
+ let roomTypeForRewards = battleRoomType ?? .battle
+ runState.updateFromBattle(playerHP: battleEngine.state.player.currentHP)
+ self.runState = runState
+
+ // Freeze the final battle state for UI (reward panel), but release the engine.
+ self.battleEngine = nil
+
+ if battleEngine.state.playerWon == true {
+ 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
+ self.runState = runState
+
+ let offer = RewardGenerator.generateCardReward(context: rewardContext)
+ route = .cardReward(nodeId: nodeId, roomType: roomTypeForRewards, offer: offer, goldEarned: goldEarned)
+ } else {
+ self.battleState = nil
+ self.battleNodeId = nil
+ self.battleRoomType = nil
+ 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 +245,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)
+ }
+}
+