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