Files
Gas-Tank-AR-App/GasTankAR/Views/TankAR/ARFlowStateMachine.swift
2026-09-02 20:54:36 +02:00

253 lines
8.3 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 []
}
}