Initial commit for first version of project

This commit is contained in:
Dennis Nemec
2026-09-02 20:54:36 +02:00
parent 3d9f1ab6bc
commit 992bb32025
56 changed files with 5139 additions and 31 deletions

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