MyApp/GameFlowStore.swift
9.6 KB · 214 lines · swift · Download from the repository page
import SwiftUIimport AVFoundation enum AppFlowStage: String { case consent, setup, game }struct RecordingAssent: Codable, Equatable { var version = 1 let date: Date let scopeID: UUID}struct SetupDraft: Codable { var id = UUID() var players = [Player(name: "Alex"), Player(name: "Jordan")] var activePlayerID: UUID? var hostID: UUID? var acknowledgement: RecordingAssent? var assents: [UUID: RecordingAssent] = [:] var recording = true var challenges = 5 var response = 3 var speed: SpeechSpeed = .normal var placement: CuePlacement = .beginning var cueMode: CueMode = .spoken var cueDuration: CueDuration = .standard var seated = false var deleteAtEnd = false var enabledCategories: [ActionCategory]? = nil var categorySet: Set<ActionCategory> { get { guard let enabledCategories, !enabledCategories.isEmpty else { return Set(ActionCategory.allCases) } return Set(enabledCategories) } set { enabledCategories = Array(newValue).sorted { $0.rawValue < $1.rawValue } } }}@MainActor @Observablefinal class GameFlowStore { let game: GameSessionStore var stage: AppFlowStage = .consent var draft: SetupDraft { didSet { persistDraft() } } var acknowledged = false var permissionsGranted = false var busy = false var error = "" var permissionDenied = false var libraryPresented = false private var resuming = false private let requestPermissions: () async -> Bool private let draftURL: URL? init(game: GameSessionStore, persist: Bool = true, requestPermissions: @escaping () async -> Bool = GameFlowStore.authorize) { self.game = game self.requestPermissions = requestPermissions draftURL = persist ? game.mediaURL("setup-draft.json") : nil if let draftURL, let data = try? Data(contentsOf: draftURL), let saved = try? JSONDecoder().decode(SetupDraft.self, from: data) { draft = saved } else { draft = SetupDraft() draft.players = game.players draft.speed = game.speed; draft.placement = game.placement draft.cueMode = game.cueMode; draft.cueDuration = game.cueDuration draft.challenges = game.challengesPerTurn; draft.response = game.responseSeconds draft.deleteAtEnd = game.deleteAtEnd draft.categorySet = game.enabledCategories } // Cold launch always requires a fresh acknowledgement, never capture or speech. } static func authorize() async -> Bool { for media in [AVMediaType.video, .audio] { switch AVCaptureDevice.authorizationStatus(for: media) { case .authorized: continue case .notDetermined: guard await AVCaptureDevice.requestAccess(for: media) else { return false } default: return false } } return true } var canResume: Bool { game.sessionStarted && game.phase != .finished } func continueConsent(recording: Bool) async { guard !busy else { return } guard !recording || acknowledged else { error = "Acknowledge recording consent, or choose nonrecording play."; return } busy = true defer { busy = false } error = ""; permissionDenied = false if recording { permissionsGranted = await requestPermissions() guard permissionsGranted else { permissionDenied = true error = "Camera and microphone access are unavailable. Open Settings or choose nonrecording play." return } draft.acknowledgement = RecordingAssent(date: Date(), scopeID: draft.id) } draft.recording = recording if !recording { draft.cueMode = .spoken } stage = .setup } func back() { guard stage == .setup, !busy else { return } stage = .consent } func addPlayer() { guard draft.players.count < 12 else { return } draft.players.append(Player(name: "Player \(draft.players.count + 1)")) } func removePlayer(_ id: UUID) { guard draft.players.count > 2 else { return } draft.players.removeAll { $0.id == id }; draft.assents[id] = nil if draft.activePlayerID == id { draft.activePlayerID = nil } if draft.hostID == id { draft.hostID = nil } } func assent(_ id: UUID, agrees: Bool) { draft.assents[id] = agrees ? RecordingAssent(date: Date(), scopeID: draft.id) : nil } func toggleCategory(_ category: ActionCategory, enabled: Bool) { var current = draft.categorySet if enabled { current.insert(category) } else if current.count > 1 { current.remove(category) } draft.categorySet = current } var validation: String? { if !(2...12).contains(draft.players.count) { return "Add between 2 and 12 players." } if draft.players.contains(where: { $0.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || $0.name.count > 40 }) { return "Give every player a name of 1–40 characters." } if !(1...10).contains(draft.challenges) || !(3...10).contains(draft.response) { return "Check turn and response limits." } if !game.prompts.contains(where: { draft.categorySet.contains($0.actionCategory) && (!draft.seated || $0.seated) }) { return "Enable a group with available actions or turn off Seated-friendly mode." } if draft.recording { if draft.acknowledgement == nil || !permissionsGranted { return "Complete recording consent and permissions first." } if draft.players.contains(where: { draft.assents[$0.id]?.scopeID != draft.id }) { return "Confirm each participant’s agreement before recording them." } } else if draft.cueMode == .visual { return "Visual S/Z requires recording and the outer camera display. Choose spoken mode." } return nil } func startGame() { guard stage == .setup, !busy else { return } guard validation == nil else { error = validation ?? ""; return } busy = true defer { busy = false } game.stopVoiceTest() if !resuming { game.players = draft.players game.challengesPerTurn = draft.challenges; game.responseSeconds = draft.response game.recordingMode = draft.recording; game.deleteAtEnd = draft.deleteAtEnd game.speed = draft.speed; game.placement = draft.placement game.cueMode = draft.cueMode; game.cueDuration = draft.cueDuration game.enabledCategories = draft.categorySet game.seatedOnly = draft.seated game.beginSession() game.playerIndex = game.players.firstIndex { $0.id == draft.activePlayerID } ?? 0 game.hostID = draft.hostID ?? game.players.last?.id } if resuming { game.recordingMode = draft.recording; game.cueMode = draft.cueMode } game.recordingAcknowledgement = draft.acknowledgement game.participantAssents = draft.assents game.consent = !game.recordingMode || draft.assents[game.player.id] != nil stage = .game; error = ""; resuming = false game.saveSession() // Preview can start only after this explicit stage transition. Recording still requires Send. if game.phase == .framing && (!game.recordingMode || !game.cameraID.isEmpty) { Task { await game.prepareCamera() } } } func resume() { guard canResume, !busy else { return } resuming = true draft = SetupDraft() draft.players = game.players; draft.activePlayerID = game.player.id; draft.hostID = game.host.id draft.recording = game.recordingMode draft.challenges = game.challengesPerTurn; draft.response = game.responseSeconds draft.speed = game.speed; draft.placement = game.placement draft.cueMode = game.cueMode; draft.cueDuration = game.cueDuration draft.seated = game.seatedOnly draft.categorySet = game.enabledCategories draft.deleteAtEnd = game.deleteAtEnd acknowledged = false error = "Confirm this group’s consent to resume. Scores and the pending result stay saved." } var isResuming: Bool { resuming } func newGame() { guard game.canChangeTab else { return } game.stopVoiceTest() if game.cameraReady || game.isPreparingCamera { game.interrupt("Camera stopped for setup. No recording in setup.") } if stage == .game { draft.players = game.players; draft.speed = game.speed; draft.placement = game.placement draft.cueMode = game.cueMode; draft.cueDuration = game.cueDuration draft.challenges = game.challengesPerTurn; draft.response = game.responseSeconds draft.seated = game.seatedOnly; draft.deleteAtEnd = game.deleteAtEnd draft.categorySet = game.enabledCategories } resuming = false; draft.id = UUID(); draft.acknowledgement = nil; draft.assents = [:] acknowledged = false; permissionsGranted = false stage = .consent; error = "" } func testVoice() { // Existing speech service, without changing the active game's persisted delivery defaults. game.testVoice(rate: draft.speed.rate) } private func persistDraft() { guard let draftURL else { return } do { try JSONEncoder().encode(draft).write(to: draftURL, options: [.atomic, .completeFileProtection]) } catch { self.error = "Could not save setup draft: " + error.localizedDescription } }}