253 lines
8.3 KiB
Swift
253 lines
8.3 KiB
Swift
//
|
||
// 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 []
|
||
}
|
||
}
|