Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion .github/plans/Apple Vision Pro 原生 3D 实现(SaluAVP).md
Original file line number Diff line number Diff line change
@@ -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
---
Expand Down Expand Up @@ -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”的策略,降低模型演进成本。
Expand Down
1 change: 1 addition & 0 deletions SaluNative/SaluAVP/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import SwiftUI
@MainActor
@Observable
class AppModel {
static let controlPanelWindowID = "controlPanel"
let immersiveSpaceID = "ImmersiveSpace"
enum ImmersiveSpaceState {
case closed
Expand Down
6 changes: 5 additions & 1 deletion SaluNative/SaluAVP/ControlPanel/ControlPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -37,4 +41,3 @@ struct ImmersiveSpaceToggleButton: View {
.animation(.none, value: 0)
}
}

118 changes: 118 additions & 0 deletions SaluNative/SaluAVP/Immersive/BattleHUDPanel.swift
Original file line number Diff line number Diff line change
@@ -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))"
}
}
}
59 changes: 59 additions & 0 deletions SaluNative/SaluAVP/Immersive/CardRewardPanel.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading