Initial commit for first version of project
This commit is contained in:
252
GasTankAR/Views/TankAR/ARFlowStateMachine.swift
Normal file
252
GasTankAR/Views/TankAR/ARFlowStateMachine.swift
Normal file
@ -0,0 +1,252 @@
|
||||
//
|
||||
// ARFlowStateMachine.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 26.08.26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Zustände des AR-Modus gemäß State-Machine (ohne den entfernten „Choose Tank"-State –
|
||||
/// stattdessen führt eine gefundene Location/ein erkannter Anker direkt in `immersive`).
|
||||
enum ARFlowState: Equatable {
|
||||
case worldNotInitialized
|
||||
case initializingWorld
|
||||
case initializingNotPossible
|
||||
case userMovesAround
|
||||
case showOptions
|
||||
case anchorDetection
|
||||
case askToCancel
|
||||
case selectLocation
|
||||
case immersive
|
||||
case relocating
|
||||
case relocationFailed
|
||||
}
|
||||
|
||||
/// Transparente ARKit-Statuslage (wird von der View in lokalisierten Text übersetzt).
|
||||
enum ARStatus: Equatable {
|
||||
case relocalizing
|
||||
case excessiveMotion
|
||||
case insufficientFeatures
|
||||
case unavailable
|
||||
}
|
||||
|
||||
/// Quelle der Transform beim Platzieren des Tanks.
|
||||
enum PlacementSource: Equatable {
|
||||
case storedTransform
|
||||
case screenCenter
|
||||
case imageAnchor
|
||||
}
|
||||
|
||||
/// Eingaben in die State-Machine: ARKit-Events, Timer und Nutzeraktionen.
|
||||
enum ARFlowEvent: Equatable {
|
||||
case started
|
||||
|
||||
// ARKit-Tracking
|
||||
case trackingNormal
|
||||
case trackingInitializing
|
||||
case trackingRelocalizing
|
||||
case trackingExcessiveMotion
|
||||
case trackingInsufficientFeatures
|
||||
case trackingUnavailable
|
||||
|
||||
// Coaching / Session
|
||||
case coachingWillActivate
|
||||
case coachingDidDeactivate
|
||||
case sessionInterruptionEnded
|
||||
case imageAnchorDetected
|
||||
|
||||
// Timer
|
||||
case moveAroundTimerFired
|
||||
case showOptionsTimerFired
|
||||
case anchorDetectionTimerFired
|
||||
case relocationTimerFired
|
||||
|
||||
// Nutzeraktionen
|
||||
case captureConfirmed // Raycast war erfolgreich (von der View vorgeprüft)
|
||||
case keepMovingTapped
|
||||
case placeAnchorMarkerTapped
|
||||
case tryAgainAnchorTapped
|
||||
case repositionTapped
|
||||
case restartTapped
|
||||
case retryRelocationTapped
|
||||
}
|
||||
|
||||
/// Seiteneffekte, die der Controller (unreine Schale) ausführt.
|
||||
enum ARFlowCommand: Equatable {
|
||||
case setStatus(ARStatus?)
|
||||
case runConfiguration(useImages: Bool, useInitialMap: Bool, resetTracking: Bool)
|
||||
case setCoachingAutomatic(Bool)
|
||||
case startMoveAroundTimer
|
||||
case startShowOptionsTimer
|
||||
case cancelEscalationTimers
|
||||
case startRelocationTimer
|
||||
case cancelRelocationTimer
|
||||
case startAnchorDetectionTimer
|
||||
case cancelAnchorDetectionTimer
|
||||
case placeTank(PlacementSource)
|
||||
case removeTank
|
||||
case persistWorldMap
|
||||
case persistTankTransform
|
||||
case clearStoredTransform
|
||||
}
|
||||
|
||||
/// Reine, testbare State-Machine des AR-Flows. Enthält keinerlei ARKit-/UI-Abhängigkeiten:
|
||||
/// `reduce` verändert den Zustand und liefert die auszuführenden Kommandos zurück.
|
||||
struct ARFlowStateMachine {
|
||||
private(set) var state: ARFlowState = .worldNotInitialized
|
||||
private(set) var hasPlacedTank = false
|
||||
private(set) var hasStoredTransform: Bool
|
||||
|
||||
init(hasStoredTransform: Bool) {
|
||||
self.hasStoredTransform = hasStoredTransform
|
||||
}
|
||||
|
||||
mutating func reduce(_ event: ARFlowEvent) -> [ARFlowCommand] {
|
||||
switch event {
|
||||
case .started:
|
||||
state = .worldNotInitialized
|
||||
return [.runConfiguration(useImages: false, useInitialMap: true, resetTracking: false)]
|
||||
|
||||
case .trackingNormal:
|
||||
var commands: [ARFlowCommand] = [
|
||||
.setStatus(nil), .cancelEscalationTimers, .cancelRelocationTimer, .setCoachingAutomatic(true),
|
||||
]
|
||||
commands += resolvePlacedOrRestore()
|
||||
return commands
|
||||
|
||||
case .trackingUnavailable:
|
||||
return [.setStatus(.unavailable)]
|
||||
|
||||
case .trackingInitializing:
|
||||
if !hasPlacedTank, state == .worldNotInitialized || state == .initializingWorld {
|
||||
state = .initializingWorld
|
||||
}
|
||||
return [.setStatus(nil)]
|
||||
|
||||
case .trackingRelocalizing:
|
||||
state = .relocating
|
||||
return [.setStatus(.relocalizing), .cancelRelocationTimer, .startRelocationTimer]
|
||||
|
||||
case .trackingExcessiveMotion:
|
||||
return [.setStatus(.excessiveMotion)]
|
||||
|
||||
case .trackingInsufficientFeatures:
|
||||
var commands: [ARFlowCommand] = [.setStatus(.insufficientFeatures)]
|
||||
let blockedStates: [ARFlowState] = [.anchorDetection, .showOptions, .askToCancel]
|
||||
if !hasPlacedTank, !blockedStates.contains(state), state != .userMovesAround {
|
||||
state = .initializingNotPossible
|
||||
commands += [.cancelEscalationTimers, .startMoveAroundTimer]
|
||||
}
|
||||
return commands
|
||||
|
||||
case .moveAroundTimerFired:
|
||||
guard state == .initializingNotPossible else { return [] }
|
||||
state = .userMovesAround
|
||||
return [.startShowOptionsTimer]
|
||||
|
||||
case .showOptionsTimerFired:
|
||||
guard state == .userMovesAround else { return [] }
|
||||
state = .showOptions
|
||||
return [.setCoachingAutomatic(false)]
|
||||
|
||||
case .keepMovingTapped:
|
||||
state = .userMovesAround
|
||||
return [.setCoachingAutomatic(true), .cancelEscalationTimers, .startShowOptionsTimer]
|
||||
|
||||
case .placeAnchorMarkerTapped:
|
||||
state = .anchorDetection
|
||||
return [
|
||||
.cancelEscalationTimers,
|
||||
.setCoachingAutomatic(false),
|
||||
.runConfiguration(useImages: true, useInitialMap: false, resetTracking: false),
|
||||
.cancelAnchorDetectionTimer,
|
||||
.startAnchorDetectionTimer,
|
||||
]
|
||||
|
||||
case .anchorDetectionTimerFired:
|
||||
guard state == .anchorDetection else { return [] }
|
||||
state = .askToCancel
|
||||
return []
|
||||
|
||||
case .tryAgainAnchorTapped:
|
||||
state = .showOptions
|
||||
return [
|
||||
.cancelAnchorDetectionTimer,
|
||||
.runConfiguration(useImages: false, useInitialMap: false, resetTracking: false),
|
||||
.setCoachingAutomatic(false),
|
||||
]
|
||||
|
||||
case .imageAnchorDetected:
|
||||
guard state == .anchorDetection else { return [] }
|
||||
state = .immersive
|
||||
hasPlacedTank = true
|
||||
return [.cancelAnchorDetectionTimer, .placeTank(.imageAnchor), .persistWorldMap, .persistTankTransform]
|
||||
|
||||
case .captureConfirmed:
|
||||
guard state == .selectLocation else { return [] }
|
||||
state = .immersive
|
||||
hasPlacedTank = true
|
||||
return [.placeTank(.screenCenter), .persistWorldMap, .persistTankTransform]
|
||||
|
||||
case .repositionTapped:
|
||||
state = .selectLocation
|
||||
hasPlacedTank = false
|
||||
hasStoredTransform = false
|
||||
return [.removeTank, .clearStoredTransform]
|
||||
|
||||
case .restartTapped:
|
||||
state = .worldNotInitialized
|
||||
hasPlacedTank = false
|
||||
hasStoredTransform = false
|
||||
return [
|
||||
.cancelRelocationTimer,
|
||||
.removeTank,
|
||||
.clearStoredTransform,
|
||||
.setStatus(nil),
|
||||
.runConfiguration(useImages: false, useInitialMap: false, resetTracking: true),
|
||||
]
|
||||
|
||||
case .retryRelocationTapped:
|
||||
state = .relocating
|
||||
return [.setStatus(.relocalizing), .cancelRelocationTimer, .startRelocationTimer]
|
||||
|
||||
case .relocationTimerFired:
|
||||
guard state == .relocating else { return [] }
|
||||
state = .relocationFailed
|
||||
return [.setStatus(nil)]
|
||||
|
||||
case .coachingWillActivate:
|
||||
if !hasPlacedTank { state = .initializingWorld }
|
||||
return []
|
||||
|
||||
case .coachingDidDeactivate:
|
||||
return [.cancelEscalationTimers] + resolvePlacedOrRestore()
|
||||
|
||||
case .sessionInterruptionEnded:
|
||||
var commands: [ARFlowCommand] = [.setStatus(nil)]
|
||||
if hasPlacedTank {
|
||||
state = .relocating
|
||||
commands += [.cancelRelocationTimer, .startRelocationTimer]
|
||||
}
|
||||
return commands
|
||||
}
|
||||
}
|
||||
|
||||
/// Gemeinsame Logik für „Tracking normal / Coaching fertig": Tank wiederherstellen,
|
||||
/// sonst in die Platzieren-UI wechseln (sofern nicht gerade Anker-Erkennung läuft).
|
||||
private mutating func resolvePlacedOrRestore() -> [ARFlowCommand] {
|
||||
if hasPlacedTank {
|
||||
state = .immersive
|
||||
return []
|
||||
}
|
||||
guard state != .anchorDetection else { return [] }
|
||||
if hasStoredTransform {
|
||||
state = .immersive
|
||||
hasPlacedTank = true
|
||||
return [.placeTank(.storedTransform)]
|
||||
}
|
||||
state = .selectLocation
|
||||
return []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user