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) + } +} +