From c57ebccf81ccf3c74ac98cd4597e0821a310be9f Mon Sep 17 00:00:00 2001 From: Eddie Date: Mon, 24 Aug 2026 10:28:44 -0400 Subject: [PATCH 01/10] feat(ios): scaffold Trophy Toss AR mini-game with gated entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the "Tiro al Trofeo" AR podium mini-game: the game is reachable from the leaderboard on devices that support ARKit world tracking, and is disabled with a Spanish explanation everywhere else (Simulator, unsupported hardware). The AR scene itself lands in a later phase. - ARSupport: world-tracking capability gate plus a read-only camera authorization helper. requestCameraAccess() is the only call that can raise the system prompt. - TrophyTossView: Spanish, themed entry screen owning the permission flow — asks for the camera when the game opens (never at app launch), renders a denied and a restricted state with a shortcut to Ajustes, re-reads the answer when the app returns to the foreground, and holds the placeholder the AR view will replace. - LeaderboardView: "Jugar" row (trophy icon) opening the game as a fullScreenCover, disabled with an explanatory footer when unsupported. - Info.plist: Spanish NSCameraUsageDescription scoped to the mini-game. The game makes no network request and does not touch leaderboard data or points. No new dependencies; XcodeGen picks up ios/IPP/Game/ with no project.yml change. --- ios/IPP/Game/ARSupport.swift | 71 +++++++++ ios/IPP/Game/TrophyTossView.swift | 238 ++++++++++++++++++++++++++++ ios/IPP/Resources/Info.plist | 2 + ios/IPP/Views/LeaderboardView.swift | 51 ++++++ 4 files changed, 362 insertions(+) create mode 100644 ios/IPP/Game/ARSupport.swift create mode 100644 ios/IPP/Game/TrophyTossView.swift diff --git a/ios/IPP/Game/ARSupport.swift b/ios/IPP/Game/ARSupport.swift new file mode 100644 index 0000000..4699691 --- /dev/null +++ b/ios/IPP/Game/ARSupport.swift @@ -0,0 +1,71 @@ +import ARKit +import AVFoundation +import Foundation + +/// Capability and camera-permission gate for the "Tiro al Trofeo" AR mini-game. +/// +/// Nothing here starts a camera on its own: +/// - `isWorldTrackingSupported` and `cameraPermission` only *read* state iOS +/// already knows, so they are safe to call from the leaderboard while +/// deciding whether to show the entry point. +/// - `requestCameraAccess()` is the only call that can raise the system prompt, +/// and it is invoked exclusively from `TrophyTossView` — i.e. when the player +/// opens the game, never at app launch (FR-010). +/// +/// This type is offline by construction: it performs no networking (FR-008). +enum ARSupport { + + // MARK: - Device capability + + /// `true` when the device can run ARKit world tracking. + /// + /// Returns `false` in the iOS Simulator and on hardware without + /// world-tracking support, which is what hides/disables the game entry + /// point (FR-001). + static var isWorldTrackingSupported: Bool { + ARWorldTrackingConfiguration.isSupported + } + + /// Short Spanish explanation shown next to the disabled entry point. + static var unsupportedMessage: String { + #if targetEnvironment(simulator) + return "El mini-juego usa la cámara: solo funciona en un iPhone real." + #else + return "Este iPhone no admite el seguimiento de realidad aumentada." + #endif + } + + // MARK: - Camera permission + + /// The camera answer the player has already given, if any. + enum CameraPermission: Equatable { + /// The player has not been asked yet — asking is up to the game view. + case notDetermined + case granted + case denied + /// Blocked by the device itself (parental controls, MDM profile). + case restricted + } + + /// Current camera authorization, read without touching the camera. + static var cameraPermission: CameraPermission { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: return .granted + case .denied: return .denied + case .restricted: return .restricted + case .notDetermined: return .notDetermined + @unknown default: return .denied + } + } + + /// Raises the system camera prompt when the answer is still + /// `.notDetermined`, and reports the resulting permission. + /// + /// Call this **only** when the game view appears (FR-010). + @discardableResult + static func requestCameraAccess() async -> CameraPermission { + guard cameraPermission == .notDetermined else { return cameraPermission } + _ = await AVCaptureDevice.requestAccess(for: .video) + return cameraPermission + } +} diff --git a/ios/IPP/Game/TrophyTossView.swift b/ios/IPP/Game/TrophyTossView.swift new file mode 100644 index 0000000..90ac71a --- /dev/null +++ b/ios/IPP/Game/TrophyTossView.swift @@ -0,0 +1,238 @@ +import SwiftUI +import UIKit + +/// Entry screen of the "Tiro al Trofeo" AR mini-game, launched from the +/// leaderboard. +/// +/// This screen owns the camera-permission story: it asks for the camera when it +/// appears — the only moment the app ever asks (FR-010) — and renders a Spanish +/// explanation with a shortcut to Ajustes when the answer is no. The AR scene +/// itself arrives in a later phase; for now `readyState` is its placeholder. +/// +/// The game is fully offline: this file makes no network request and never +/// touches `AppEnvironment` or the leaderboard data (FR-008). +struct TrophyTossView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.scenePhase) private var scenePhase + + @State private var permission: ARSupport.CameraPermission = .notDetermined + @State private var isAsking = false + /// Guards against asking twice if the view's task runs again. + @State private var didAsk = false + + var body: some View { + NavigationStack { + ZStack { + Color.ippScreen.ignoresSafeArea() + + ScrollView { + VStack(spacing: 16) { + heroCard + content + } + .padding(.horizontal, 20) + .padding(.vertical, 18) + } + } + .navigationTitle("Tiro al Trofeo") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cerrar") { dismiss() } + } + } + } + .task { await askForCameraIfNeeded() } + .onChange(of: scenePhase) { _, phase in + // Returning from Ajustes: the player may have changed the answer. + if phase == .active { permission = ARSupport.cameraPermission } + } + } + + // MARK: - Sections + + private var heroCard: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Image(systemName: "trophy.fill") + .font(.title2) + .foregroundStyle(.white) + Text("Tiro al Trofeo") + .font(.title3.weight(.bold)) + .foregroundStyle(.white) + } + Text("Apunta a una mesa o al suelo, coloca el podio y encesta la pelota en la copa.") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.85)) + Text("Juego sin conexión · no cambia tus puntos del ranking.") + .font(.caption) + .foregroundStyle(.white.opacity(0.75)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + .background(LinearGradient.ippBrand) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + + @ViewBuilder + private var content: some View { + if !ARSupport.isWorldTrackingSupported { + unsupportedState + } else { + switch permission { + case .granted: readyState + case .denied: deniedState + case .restricted: restrictedState + case .notDetermined: askingState + } + } + } + + /// Reachable only if support disappears between the leaderboard check and + /// this screen — the entry point is already gated on the same flag. + private var unsupportedState: some View { + card(icon: "iphone.slash", tint: .ippMuted, title: "No disponible aquí") { + Text(ARSupport.unsupportedMessage) + .font(.callout) + .foregroundStyle(Color.ippBody) + } + } + + private var askingState: some View { + card(icon: "camera.fill", tint: .ippTeal, title: "Permiso de cámara") { + VStack(alignment: .leading, spacing: 12) { + Text("El juego necesita la cámara para ver la superficie donde se apoya el podio. Las imágenes no se graban ni se envían.") + .font(.callout) + .foregroundStyle(Color.ippBody) + if isAsking { + HStack(spacing: 8) { + ProgressView() + Text("Esperando tu respuesta…") + .font(.caption) + .foregroundStyle(Color.ippMuted) + } + } else { + Button("Permitir cámara") { + Task { await askForCamera() } + } + .buttonStyle(.borderedProminent) + .tint(.ippTeal) + } + } + } + } + + private var deniedState: some View { + card(icon: "camera.badge.ellipsis", tint: .ippGold, title: "Sin acceso a la cámara") { + VStack(alignment: .leading, spacing: 12) { + Text("No podemos mostrar el podio sin la cámara. Puedes activarla en Ajustes › IPP › Cámara y volver a intentarlo.") + .font(.callout) + .foregroundStyle(Color.ippBody) + Button("Abrir Ajustes") { openSettings() } + .buttonStyle(.borderedProminent) + .tint(.ippTeal) + } + } + } + + private var restrictedState: some View { + card(icon: "lock.fill", tint: .ippGold, title: "Cámara restringida") { + VStack(alignment: .leading, spacing: 12) { + Text("El acceso a la cámara está bloqueado en este dispositivo (control parental o perfil de gestión), así que el mini-juego no puede abrirse.") + .font(.callout) + .foregroundStyle(Color.ippBody) + Button("Abrir Ajustes") { openSettings() } + .buttonStyle(.bordered) + .tint(.ippTeal) + } + } + } + + /// Placeholder for the AR content that a later phase installs here. + private var readyState: some View { + card(icon: "checkmark.circle.fill", tint: .ippTeal, title: "Cámara lista") { + VStack(alignment: .leading, spacing: 12) { + Text("Ya podemos usar la cámara. La vista de realidad aumentada con el podio se añade en la siguiente entrega.") + .font(.callout) + .foregroundStyle(Color.ippBody) + RoundedRectangle(cornerRadius: 14) + .strokeBorder( + Color.ippFaint, + style: StrokeStyle(lineWidth: 1.5, dash: [6, 5]) + ) + .frame(height: 180) + .overlay( + VStack(spacing: 6) { + Image(systemName: "arkit") + .font(.largeTitle) + .foregroundStyle(Color.ippFaint) + Text("Vista AR · próximamente") + .font(.caption) + .foregroundStyle(Color.ippMuted) + } + ) + } + } + } + + // MARK: - Building blocks + + private func card( + icon: String, + tint: Color, + title: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(tint.opacity(0.14)) + .frame(width: 44, height: 44) + Image(systemName: icon) + .font(.title3) + .foregroundStyle(tint) + } + Text(title) + .font(.title3.weight(.semibold)) + .foregroundStyle(Color.ippInk) + Spacer(minLength: 0) + } + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.ippBorder, lineWidth: 1) + ) + } + + // MARK: - Permission flow + + /// Runs when the game screen appears — this is the one place the app is + /// allowed to raise the camera prompt (FR-010). + private func askForCameraIfNeeded() async { + permission = ARSupport.cameraPermission + guard ARSupport.isWorldTrackingSupported, + permission == .notDetermined, + !didAsk + else { return } + await askForCamera() + } + + private func askForCamera() async { + guard !isAsking else { return } + didAsk = true + isAsking = true + permission = await ARSupport.requestCameraAccess() + isAsking = false + } + + private func openSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } +} diff --git a/ios/IPP/Resources/Info.plist b/ios/IPP/Resources/Info.plist index cc86768..139c0d5 100644 --- a/ios/IPP/Resources/Info.plist +++ b/ios/IPP/Resources/Info.plist @@ -22,6 +22,8 @@ 1 LSRequiresIPhoneOS + NSCameraUsageDescription + IPP usa la cámara únicamente en el mini-juego de realidad aumentada "Tiro al Trofeo", para detectar una superficie y colocar el podio sobre ella. No se graban ni se envían imágenes. UILaunchScreen UISupportedInterfaceOrientations diff --git a/ios/IPP/Views/LeaderboardView.swift b/ios/IPP/Views/LeaderboardView.swift index 9460b6b..8305945 100644 --- a/ios/IPP/Views/LeaderboardView.swift +++ b/ios/IPP/Views/LeaderboardView.swift @@ -8,6 +8,12 @@ struct LeaderboardView: View { @State private var entries: [LeaderboardEntry] = [] @State private var loading = true @State private var error: String? + @State private var showingGame = false + + /// The AR mini-game only runs where ARKit world tracking does — elsewhere + /// (Simulator, unsupported hardware) the entry point stays disabled with an + /// explanation (FR-001). Reading this never touches the camera. + private var gameAvailable: Bool { ARSupport.isWorldTrackingSupported } var body: some View { NavigationStack { @@ -26,6 +32,14 @@ struct LeaderboardView: View { } } + Section { + gameRow + } footer: { + Text(gameAvailable + ? "Mini-juego de realidad aumentada. Es solo por diversión: no cambia tus puntos." + : ARSupport.unsupportedMessage) + } + Section { if loading { HStack { ProgressView(); Text("Cargando…") } @@ -63,7 +77,44 @@ struct LeaderboardView: View { } } .task { await load() } + .fullScreenCover(isPresented: $showingGame) { + TrophyTossView() + } + } + } + + private var gameRow: some View { + Button { + showingGame = true + } label: { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(gameAvailable ? Color.ippGoldSoft : Color(.tertiarySystemFill)) + .frame(width: 36, height: 36) + Image(systemName: "trophy.fill") + .font(.title3) + .foregroundStyle(gameAvailable ? Color.ippGold : Color.ippMuted) + } + VStack(alignment: .leading, spacing: 2) { + Text("Jugar") + .font(.callout.weight(.semibold)) + .foregroundStyle(gameAvailable ? Color.ippInk : Color.ippMuted) + Text("Tiro al Trofeo · encesta en el podio") + .font(.caption) + .foregroundStyle(Color.ippMuted) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.ippFaint) + } + .padding(.vertical, 2) + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .disabled(!gameAvailable) + .opacity(gameAvailable ? 1 : 0.55) } private func heroCard(username: String) -> some View { From f410257a9c5c0e79857e6e0ecfca5ada616db6da Mon Sep 17 00:00:00 2001 From: Eddie Date: Mon, 24 Aug 2026 11:19:58 -0400 Subject: [PATCH 02/10] feat(ios): AR session, procedural podium and tap-to-place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the Trophy Toss AR mini-game (spec FR-002/FR-003/FR-011, SC-003). - PodiumBuilder: pure, ARView-free entity assembly. Three box steps at 12/9/6 cm in the leaderboard's exact gold/silver/bronze medal colors, a trophy (base + stem + open cup built from a 12-segment wall ring over a floor disc) on the #1 step, an invisible trigger volume in the cup mouth for Phase 3 scoring, and an invisible static floor collision plane at anchor height. Everything static-collidable so Phase 3 balls bounce. MeshResource.generateCylinder is iOS 18+, so the cylinders are generated from a MeshDescriptor here — still procedural, still no asset files. - PodiumARViewContainer: UIViewRepresentable over a RealityKit ARView with ARWorldTrackingConfiguration + horizontal plane detection and an ARCoachingOverlayView for the scan hint. Tap raycasts to a horizontal plane, anchors the podium with AnchorEntity(world:) and turns it to face the player; placement locks until Reubicar. Tracking-state, interruption and background/foreground handling never reset tracking or drop anchors, so the podium keeps its original spot. Full teardown on dismiss. - TrophyTossView: the readyState placeholder is replaced by the AR view plus a minimal Spanish overlay (hint, Reubicar, close). Verified: xcodebuild simulator build succeeds with zero warnings; the PodiumBuilder structure was exercised by 7 XCTest cases on an iPhone 17 Pro simulator (temporary target, not committed), all passing, including PodiumBuilder.selfCheck() reporting no problems. --- ios/IPP/Game/PodiumARViewContainer.swift | 412 ++++++++++++++++ ios/IPP/Game/PodiumBuilder.swift | 588 +++++++++++++++++++++++ ios/IPP/Game/TrophyTossView.swift | 156 ++++-- 3 files changed, 1123 insertions(+), 33 deletions(-) create mode 100644 ios/IPP/Game/PodiumARViewContainer.swift create mode 100644 ios/IPP/Game/PodiumBuilder.swift diff --git a/ios/IPP/Game/PodiumARViewContainer.swift b/ios/IPP/Game/PodiumARViewContainer.swift new file mode 100644 index 0000000..9607a86 --- /dev/null +++ b/ios/IPP/Game/PodiumARViewContainer.swift @@ -0,0 +1,412 @@ +import ARKit +import Combine +import RealityKit +import SwiftUI +import UIKit + +/// Shared state between the RealityKit `ARView` and the SwiftUI overlay drawn +/// on top of it. +/// +/// The view reads the published values to decide what hint and which buttons to +/// show; `relocate()` is the one command that travels the other way, into the +/// coordinator that owns the AR session. +@MainActor +final class PodiumARModel: ObservableObject { + + /// What the session is doing right now, most severe first when it comes to + /// choosing a hint. + enum Phase: Equatable { + /// Looking for a horizontal plane — the coaching overlay is up. + case scanning + /// A plane exists; the player can tap to place the podium. + case readyToPlace + /// The podium is anchored in the world. + case placed + } + + @Published fileprivate(set) var phase: Phase = .scanning + /// Set while ARKit reports limited tracking or an interruption. The podium + /// keeps its anchor throughout — this only drives the hint (FR-002). + @Published fileprivate(set) var trackingIssue: String? + /// Unrecoverable session error; the player has to close and reopen. + @Published fileprivate(set) var failure: String? + /// Short-lived feedback, e.g. a tap that hit no surface. + @Published fileprivate(set) var transientHint: String? + + /// Installed by the coordinator so the overlay's "Reubicar" button can + /// reach the AR session. + fileprivate var relocateHandler: (() -> Void)? + + var isPlaced: Bool { phase == .placed } + + /// The single line of Spanish shown at the bottom of the AR view. + var hint: String { + if let failure { return failure } + if let trackingIssue { return trackingIssue } + if let transientHint { return transientHint } + switch phase { + case .scanning: + return "Mueve el teléfono para detectar una superficie." + case .readyToPlace: + return "Toca la superficie para colocar el podio." + case .placed: + return "Podio colocado. Lanza pelotas o pulsa Reubicar." + } + } + + /// Drops the placed podium and goes back to scanning so the player can pick + /// a new spot. Phase 4 will additionally forbid this mid-round. + func relocate() { + relocateHandler?() + } + + fileprivate func flash(_ message: String) { + transientHint = message + Task { [weak self] in + try? await Task.sleep(nanoseconds: 2_500_000_000) + guard let self, self.transientHint == message else { return } + self.transientHint = nil + } + } +} + +/// The AR half of "Tiro al Trofeo": a RealityKit `ARView` running world +/// tracking with horizontal plane detection, an `ARCoachingOverlayView` for the +/// scan hint, and tap-to-place for the procedural podium (FR-002, FR-003). +/// +/// The session is torn down completely when SwiftUI removes the view — paused, +/// un-delegated, anchors and subscriptions dropped — so closing the game leaves +/// nothing running behind the leaderboard (FR-011). +/// +/// Offline by construction: nothing here performs any networking (FR-008). +struct PodiumARViewContainer: UIViewRepresentable { + + @ObservedObject var model: PodiumARModel + + func makeCoordinator() -> Coordinator { + Coordinator(model: model) + } + + func makeUIView(context: Context) -> ARView { + #if DEBUG + let problems = PodiumBuilder.selfCheck() + assert(problems.isEmpty, "PodiumBuilder produced a broken scene: \(problems)") + #endif + + let arView = ARView(frame: .zero) + // Our configuration, not ARView's guess. + arView.automaticallyConfigureSession = false + arView.session.delegate = context.coordinator + // Delegate callbacks land on the main queue, which is where the + // published state and the RealityKit scene both live. + arView.session.delegateQueue = .main + + context.coordinator.attach(to: arView) + return arView + } + + func updateUIView(_ uiView: ARView, context: Context) { + // All state flows out of the coordinator; nothing to push back in. + } + + /// Full teardown when the game screen goes away (FR-011). + static func dismantleUIView(_ uiView: ARView, coordinator: Coordinator) { + coordinator.tearDown() + } + + // MARK: - Coordinator + + @MainActor + final class Coordinator: NSObject, ARSessionDelegate, ARCoachingOverlayViewDelegate { + + private let model: PodiumARModel + private weak var arView: ARView? + private let coachingOverlay = ARCoachingOverlayView() + private var tapRecognizer: UITapGestureRecognizer? + + /// The one anchor the podium lives on. Kept so relocation can remove + /// exactly it, and so tracking recovery can be checked against it. + private var podiumAnchor: AnchorEntity? + /// Phase 3 will put its collision subscriptions here; the array exists + /// now so teardown is already correct. + private var subscriptions: [any Cancellable] = [] + private var lifecycleObservers: [NSObjectProtocol] = [] + + private var hasSeenPlane = false + private var isPausedForBackground = false + private var isTornDown = false + + init(model: PodiumARModel) { + self.model = model + super.init() + model.relocateHandler = { [weak self] in self?.relocate() } + } + + // MARK: Session configuration + + /// World tracking with horizontal plane detection — the minimum the + /// game needs, and nothing more (no people occlusion, no scene mesh), + /// which keeps the frame rate healthy on older iPhones. + private func makeConfiguration() -> ARWorldTrackingConfiguration { + let configuration = ARWorldTrackingConfiguration() + configuration.planeDetection = [.horizontal] + configuration.environmentTexturing = .automatic + configuration.isLightEstimationEnabled = true + return configuration + } + + func attach(to arView: ARView) { + self.arView = arView + + arView.session.run(makeConfiguration(), options: [.resetTracking, .removeExistingAnchors]) + + installCoachingOverlay(on: arView) + + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) + arView.addGestureRecognizer(tap) + tapRecognizer = tap + + observeAppLifecycle() + } + + private func installCoachingOverlay(on arView: ARView) { + coachingOverlay.session = arView.session + coachingOverlay.goal = .horizontalPlane + coachingOverlay.activatesAutomatically = true + coachingOverlay.delegate = self + coachingOverlay.translatesAutoresizingMaskIntoConstraints = false + arView.addSubview(coachingOverlay) + NSLayoutConstraint.activate([ + coachingOverlay.leadingAnchor.constraint(equalTo: arView.leadingAnchor), + coachingOverlay.trailingAnchor.constraint(equalTo: arView.trailingAnchor), + coachingOverlay.topAnchor.constraint(equalTo: arView.topAnchor), + coachingOverlay.bottomAnchor.constraint(equalTo: arView.bottomAnchor) + ]) + } + + // MARK: App lifecycle + // + // Backgrounding stops the camera. We pause explicitly on the way out + // and re-run the *same* configuration with no options on the way back + // in — no `.resetTracking`, no `.removeExistingAnchors`, so the podium + // keeps the anchor it was placed on and ARKit relocalises to it + // instead of the podium jumping somewhere new. + + private func observeAppLifecycle() { + let center = NotificationCenter.default + lifecycleObservers.append( + center.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.pauseForBackground() } + } + ) + lifecycleObservers.append( + center.addObserver( + forName: UIApplication.willEnterForegroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.resumeFromBackground() } + } + ) + } + + private func pauseForBackground() { + guard !isTornDown, !isPausedForBackground else { return } + isPausedForBackground = true + arView?.session.pause() + } + + private func resumeFromBackground() { + guard !isTornDown, isPausedForBackground else { return } + isPausedForBackground = false + // No options: existing anchors survive, tracking relocalises. + arView?.session.run(makeConfiguration()) + model.trackingIssue = nil + } + + // MARK: Placement + + @objc + private func handleTap(_ gesture: UITapGestureRecognizer) { + guard let arView, podiumAnchor == nil else { return } + + let point = gesture.location(in: arView) + // Prefer a real detected plane; fall back to ARKit's estimate so a + // confident player is not blocked by a slow plane extension. + let hit = arView.raycast(from: point, allowing: .existingPlaneGeometry, alignment: .horizontal).first + ?? arView.raycast(from: point, allowing: .estimatedPlane, alignment: .horizontal).first + + guard let hit else { + model.flash("Ahí no vemos superficie. Prueba en otro punto de la mesa o el suelo.") + return + } + + place(at: hit.worldTransform, in: arView) + } + + private func place(at worldTransform: simd_float4x4, in arView: ARView) { + let position = SIMD3( + worldTransform.columns.3.x, + worldTransform.columns.3.y, + worldTransform.columns.3.z + ) + + // Anchor on the world position only — the raycast's own rotation + // follows the plane's arbitrary axes, which would spin the podium. + let anchor = AnchorEntity(world: position) + let scene = PodiumBuilder.makeScene() + scene.orientation = simd_quatf(angle: yaw(towardCameraFrom: position, in: arView), axis: [0, 1, 0]) + anchor.addChild(scene) + arView.scene.addAnchor(anchor) + + podiumAnchor = anchor + model.phase = .placed + model.transientHint = nil + + // From here the player is looking at the podium, so stop the + // full-screen coaching overlay from covering it; our own hint takes + // over if tracking degrades. + coachingOverlay.activatesAutomatically = false + coachingOverlay.setActive(false, animated: true) + } + + /// Rotation about +Y that turns the podium's front (+Z) toward the + /// camera, so the steps face the player however they were standing. + private func yaw(towardCameraFrom position: SIMD3, in arView: ARView) -> Float { + guard let frame = arView.session.currentFrame else { return 0 } + let camera = frame.camera.transform.columns.3 + let dx = camera.x - position.x + let dz = camera.z - position.z + guard dx * dx + dz * dz > 1e-6 else { return 0 } + return atan2(dx, dz) + } + + private func relocate() { + guard !isTornDown, let arView, let anchor = podiumAnchor else { return } + arView.scene.removeAnchor(anchor) + podiumAnchor = nil + model.phase = hasSeenPlane ? .readyToPlace : .scanning + model.transientHint = nil + coachingOverlay.activatesAutomatically = true + } + + // MARK: ARSessionDelegate / ARCoachingOverlayViewDelegate + // + // The delegate methods themselves are `nonisolated` — ARKit's protocols + // make no isolation promise, and claiming otherwise is a data race in + // the Swift 6 language mode. Each one boils its payload down to a plain + // value and hops to the main actor, where the model and the RealityKit + // scene live. + + nonisolated func session(_ session: ARSession, didAdd anchors: [ARAnchor]) { + guard anchors.contains(where: { $0 is ARPlaneAnchor }) else { return } + Task { @MainActor in self.planeBecameAvailable() } + } + + nonisolated func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) { + let issue = Self.message(for: camera.trackingState) + Task { @MainActor in self.model.trackingIssue = issue } + } + + nonisolated func sessionWasInterrupted(_ session: ARSession) { + Task { @MainActor in + self.model.trackingIssue = "Sesión en pausa. Vuelve a apuntar a la superficie." + } + } + + nonisolated func sessionInterruptionEnded(_ session: ARSession) { + // Deliberately no `run(_:options:)` here: ARKit resumes on its own + // and relocalises to the existing anchor. Resetting would move the + // podium, which the spec forbids. + Task { @MainActor in self.model.trackingIssue = nil } + } + + nonisolated func session(_ session: ARSession, didFailWithError error: Error) { + let failure = Self.message(for: error) + Task { @MainActor in self.model.failure = failure } + } + + nonisolated func coachingOverlayViewDidDeactivate(_ coachingOverlayView: ARCoachingOverlayView) { + Task { @MainActor in self.planeBecameAvailable() } + } + + /// A horizontal plane exists, so the player can tap to place. + private func planeBecameAvailable() { + hasSeenPlane = true + if model.phase == .scanning { + model.phase = .readyToPlace + } + } + + // MARK: Hint copy + + nonisolated private static func message(for state: ARCamera.TrackingState) -> String? { + switch state { + case .normal: + return nil + case .notAvailable: + return "Recupera la superficie: mueve el teléfono despacio." + case .limited(.initializing): + return "Preparando la cámara…" + case .limited(.relocalizing): + return "Recuperando la superficie: apunta al mismo sitio de antes." + case .limited(.excessiveMotion): + return "Mueve el teléfono más despacio." + case .limited(.insufficientFeatures): + return "Poca luz o superficie lisa: apunta a una zona con más detalle." + case .limited: + return "Recupera la superficie: mueve el teléfono despacio." + } + } + + nonisolated private static func message(for error: Error) -> String { + guard let arError = error as? ARError else { + return "La cámara falló. Cierra el juego y vuelve a abrirlo." + } + switch arError.code { + case .cameraUnauthorized: + return "Sin permiso de cámara. Actívalo en Ajustes › IPP › Cámara." + case .sensorUnavailable, .sensorFailed: + return "La cámara no está disponible ahora mismo." + default: + return "La sesión de realidad aumentada falló. Cierra el juego y vuelve a abrirlo." + } + } + + // MARK: Teardown (FR-011) + + func tearDown() { + guard !isTornDown else { return } + isTornDown = true + + model.relocateHandler = nil + + for observer in lifecycleObservers { + NotificationCenter.default.removeObserver(observer) + } + lifecycleObservers.removeAll() + + subscriptions.removeAll() + + coachingOverlay.delegate = nil + coachingOverlay.session = nil + coachingOverlay.removeFromSuperview() + + if let arView { + if let tapRecognizer { + arView.removeGestureRecognizer(tapRecognizer) + } + arView.scene.anchors.removeAll() + arView.session.pause() + arView.session.delegate = nil + } + tapRecognizer = nil + podiumAnchor = nil + arView = nil + } + } +} diff --git a/ios/IPP/Game/PodiumBuilder.swift b/ios/IPP/Game/PodiumBuilder.swift new file mode 100644 index 0000000..255203d --- /dev/null +++ b/ios/IPP/Game/PodiumBuilder.swift @@ -0,0 +1,588 @@ +import Foundation +import RealityKit +import UIKit +import simd + +/// Procedural assembly of the "Tiro al Trofeo" podium scene (FR-003). +/// +/// Everything here is a pure function of constants: it builds and returns +/// `Entity` trees and never touches an `ARView`, an `ARSession` or any app +/// state. That is deliberate — RealityKit's entity/mesh layer works in the +/// Simulator even though ARKit does not, so the geometry can be built and +/// asserted on off-device. `selfCheck()` at the bottom is exactly that +/// assertion set; Phase 6a's test target will host it as real unit tests. +/// +/// Units are metres, RealityKit's convention: +X right, +Y up, −Z away from the +/// player. The scene's origin sits **on the surface the player tapped**, so the +/// podium's feet and the invisible floor plane both live at y = 0. +/// +/// No bundled 3D assets and no networking are involved (FR-003, FR-008). +@MainActor +enum PodiumBuilder { + + // MARK: - Entity names + // + // Stable names are the contract the game logic (and the tests) look things + // up by — `findEntity(named:)` rather than index arithmetic. + + enum Name { + static let root = "podium_scene" + static let steps = "podium_steps" + static let goldStep = "step_gold" + static let silverStep = "step_silver" + static let bronzeStep = "step_bronze" + static let trophy = "trophy" + static let cup = "cup" + static let cupWall = "cup_wall" + static let cupFloor = "cup_floor" + static let cupTrigger = "cup_trigger" + static let floor = "floor" + } + + // MARK: - Dimensions + + enum Metrics { + /// Footprint of a single step (square, in metres). + static let stepWidth: Float = 0.10 + static let stepDepth: Float = 0.10 + + /// Step heights — 12 / 9 / 6 cm, per the plan. + static let goldHeight: Float = 0.12 + static let silverHeight: Float = 0.09 + static let bronzeHeight: Float = 0.06 + + /// Silver sits left of gold, bronze right of gold, flush against it. + static let goldX: Float = 0 + static let silverX: Float = -stepWidth + static let bronzeX: Float = stepWidth + + /// Trophy: a small base and stem carrying an open cup. + static let trophyBaseRadius: Float = 0.030 + static let trophyBaseHeight: Float = 0.010 + static let trophyStemRadius: Float = 0.008 + static let trophyStemHeight: Float = 0.030 + + /// Inner radius of the cup. A Phase 3 ball is ~3.5 cm across, so a + /// 10 cm mouth is a fair but not trivial target. + static let cupInnerRadius: Float = 0.050 + static let cupWallThickness: Float = 0.006 + static let cupWallHeight: Float = 0.060 + static let cupFloorThickness: Float = 0.006 + /// Number of box segments approximating the cup's cylindrical wall. + static let cupWallSegments = 12 + + /// Invisible collision plane standing in for the real table or floor, + /// so missed balls bounce on the surface instead of falling forever. + static let floorExtent: Float = 3.0 + static let floorThickness: Float = 0.02 + + /// Height of the whole trophy above the step it stands on. + static var trophyHeight: Float { + trophyBaseHeight + trophyStemHeight + cupFloorThickness + cupWallHeight + } + } + + // MARK: - Colors + // + // The exact `LeaderboardRow.medalColor` values, so the podium reads as the + // leaderboard's top three (FR-003, FR-009). + + enum Medal { + static let gold = UIColor(red: 0.95, green: 0.78, blue: 0.18, alpha: 1) + static let silver = UIColor(red: 0.75, green: 0.78, blue: 0.82, alpha: 1) + static let bronze = UIColor(red: 0.80, green: 0.50, blue: 0.20, alpha: 1) + } + + // MARK: - Public assembly + + /// The whole placeable scene: the three-step podium with its trophy, plus + /// the invisible floor collision plane. The root's origin is the point the + /// player tapped on the detected surface. + static func makeScene() -> Entity { + let root = Entity() + root.name = Name.root + root.addChild(makePodium()) + root.addChild(makeFloor()) + return root + } + + /// The visible podium: three steps plus the trophy on the tallest one. + static func makePodium() -> Entity { + let podium = Entity() + podium.name = Name.steps + + let gold = makeStep( + name: Name.goldStep, + color: Medal.gold, + height: Metrics.goldHeight, + x: Metrics.goldX + ) + podium.addChild(gold) + podium.addChild( + makeStep( + name: Name.silverStep, + color: Medal.silver, + height: Metrics.silverHeight, + x: Metrics.silverX + ) + ) + podium.addChild( + makeStep( + name: Name.bronzeStep, + color: Medal.bronze, + height: Metrics.bronzeHeight, + x: Metrics.bronzeX + ) + ) + + // The trophy rides on the gold step, so Phase 5's cup relocation is a + // re-parent plus a move rather than a rebuild. The step's origin is its + // centre, so half its height puts the trophy on the top face. + let trophy = makeTrophy() + trophy.position = [0, Metrics.goldHeight / 2, 0] + gold.addChild(trophy) + + return podium + } + + /// One podium step, resting on y = 0 with its centre at `x`. + static func makeStep(name: String, color: UIColor, height: Float, x: Float) -> ModelEntity { + let mesh = MeshResource.generateBox( + width: Metrics.stepWidth, + height: height, + depth: Metrics.stepDepth, + cornerRadius: 0.004 + ) + let step = ModelEntity(mesh: mesh, materials: [material(color)]) + step.name = name + step.position = [x, height / 2, 0] + addStaticPhysics( + to: step, + shape: .generateBox( + width: Metrics.stepWidth, + height: height, + depth: Metrics.stepDepth + ) + ) + return step + } + + /// The trophy: base + stem + an **open** cup with an invisible trigger + /// volume filling its mouth. + /// + /// The cup is a ring of wall segments over a floor disc rather than a solid + /// cylinder on purpose — a solid mesh would make the ball bounce off the + /// target instead of settling into it, which is what Phase 3 has to detect. + static func makeTrophy() -> Entity { + let trophy = Entity() + trophy.name = Name.trophy + + let gold = material(Medal.gold, roughness: 0.25, metallic: true) + + // Base. + let base = ModelEntity( + mesh: cylinderMesh( + height: Metrics.trophyBaseHeight, + radius: Metrics.trophyBaseRadius + ), + materials: [gold] + ) + base.name = "trophy_base" + base.position = [0, Metrics.trophyBaseHeight / 2, 0] + addStaticPhysics( + to: base, + shape: .generateBox( + width: Metrics.trophyBaseRadius * 2, + height: Metrics.trophyBaseHeight, + depth: Metrics.trophyBaseRadius * 2 + ) + ) + trophy.addChild(base) + + // Stem. + let stem = ModelEntity( + mesh: cylinderMesh( + height: Metrics.trophyStemHeight, + radius: Metrics.trophyStemRadius + ), + materials: [gold] + ) + stem.name = "trophy_stem" + stem.position = [0, Metrics.trophyBaseHeight + Metrics.trophyStemHeight / 2, 0] + addStaticPhysics( + to: stem, + shape: .generateBox( + width: Metrics.trophyStemRadius * 2, + height: Metrics.trophyStemHeight, + depth: Metrics.trophyStemRadius * 2 + ) + ) + trophy.addChild(stem) + + // Cup, sitting on top of the stem. + let cup = makeCup() + cup.position = [0, Metrics.trophyBaseHeight + Metrics.trophyStemHeight, 0] + trophy.addChild(cup) + + return trophy + } + + /// The open cup. Its origin is the underside of its floor disc. + static func makeCup() -> Entity { + let cup = Entity() + cup.name = Name.cup + + let gold = material(Medal.gold, roughness: 0.2, metallic: true) + + // Floor disc. + let floorDisc = ModelEntity( + mesh: cylinderMesh( + height: Metrics.cupFloorThickness, + radius: Metrics.cupInnerRadius + ), + materials: [gold] + ) + floorDisc.name = Name.cupFloor + floorDisc.position = [0, Metrics.cupFloorThickness / 2, 0] + addStaticPhysics( + to: floorDisc, + shape: .generateBox( + width: Metrics.cupInnerRadius * 2, + height: Metrics.cupFloorThickness, + depth: Metrics.cupInnerRadius * 2 + ), + // A soft, grippy floor so balls settle instead of bouncing back out. + friction: 0.9, + restitution: 0.05 + ) + cup.addChild(floorDisc) + + // Wall: `cupWallSegments` thin boxes on a circle, forming a polygonal + // ring that reads as a cylinder and collides like a container. + let segments = Metrics.cupWallSegments + let ringRadius = Metrics.cupInnerRadius + Metrics.cupWallThickness / 2 + // Chord length of one segment, plus a hair of overlap so the ring has + // no gaps between neighbours. + let segmentWidth = 2 * ringRadius * sin(.pi / Float(segments)) * 1.08 + let wallY = Metrics.cupFloorThickness + Metrics.cupWallHeight / 2 + + for index in 0.. Entity { + // Shorter than the wall so a ball perched on the rim does not count, + // and narrower so the sensor stays clear of the wall segments. + let height = Metrics.cupWallHeight * 0.75 + let side = (Metrics.cupInnerRadius - Metrics.cupWallThickness) * 1.4 + let trigger = Entity() + trigger.name = Name.cupTrigger + trigger.position = [0, Metrics.cupFloorThickness + height / 2, 0] + trigger.components.set( + CollisionComponent( + shapes: [.generateBox(width: side, height: height, depth: side)], + mode: .trigger, + filter: .sensor + ) + ) + return trigger + } + + /// Invisible static plane at anchor height (y = 0) standing in for the real + /// table or floor, so missed balls bounce on the surface the player placed + /// the podium on instead of falling through the world. + static func makeFloor() -> Entity { + let floor = Entity() + floor.name = Name.floor + // Top face flush with y = 0. + floor.position = [0, -Metrics.floorThickness / 2, 0] + addStaticPhysics( + to: floor, + shape: .generateBox( + width: Metrics.floorExtent, + height: Metrics.floorThickness, + depth: Metrics.floorExtent + ), + friction: 0.8, + restitution: 0.25 + ) + return floor + } + + // MARK: - Helpers + + static func material( + _ color: UIColor, + roughness: Float = 0.45, + metallic: Bool = false + ) -> SimpleMaterial { + SimpleMaterial(color: color, roughness: .float(roughness), isMetallic: metallic) + } + + /// Gives an entity a collision shape and an immovable physics body, so the + /// Phase 3 balls bounce off it rather than through it. + static func addStaticPhysics( + to entity: Entity, + shape: ShapeResource, + friction: Float = 0.7, + restitution: Float = 0.2 + ) { + entity.components.set(CollisionComponent(shapes: [shape])) + entity.components.set( + PhysicsBodyComponent( + massProperties: .default, + material: .generate(friction: friction, restitution: restitution), + mode: .static + ) + ) + } + + /// A Y-axis cylinder centred on its own origin, built from scratch. + /// + /// `MeshResource.generateCylinder` is iOS 18+, and the app deploys to + /// iOS 17, so the mesh is generated here instead — still procedural, still + /// no asset files (FR-003). + /// + /// Winding is counter-clockwise seen from outside, RealityKit's front-face + /// convention. With `p(θ) = (r·sin θ, y, r·cos θ)`, increasing θ runs + /// counter-clockwise when viewed from +Y, which fixes the order of every + /// triangle below. + static func cylinderMesh(height: Float, radius: Float, segments: Int = 24) -> MeshResource { + let n = max(3, segments) + let halfHeight = height / 2 + + var positions: [SIMD3] = [] + var normals: [SIMD3] = [] + positions.reserveCapacity(4 * n + 2) + normals.reserveCapacity(4 * n + 2) + + let ring: [SIMD2] = (0.. [String] { + var problems: [String] = [] + let scene = makeScene() + + func requireEntity(_ name: String) -> Entity? { + guard let found = scene.findEntity(named: name) else { + problems.append("missing entity '\(name)'") + return nil + } + return found + } + + func requireCollision(_ entity: Entity, _ label: String) { + guard let collision = entity.components[CollisionComponent.self] else { + problems.append("'\(label)' has no CollisionComponent") + return + } + if collision.shapes.isEmpty { + problems.append("'\(label)' has an empty collision shape list") + } + } + + func requireStaticBody(_ entity: Entity, _ label: String) { + guard let body = entity.components[PhysicsBodyComponent.self] else { + problems.append("'\(label)' has no PhysicsBodyComponent") + return + } + if body.mode != .static { + problems.append("'\(label)' physics body is not .static") + } + } + + // 1. Three steps: medal-coloured, resting on the surface, collidable. + let expectedSteps: [(String, Float, UIColor)] = [ + (Name.goldStep, Metrics.goldHeight, Medal.gold), + (Name.silverStep, Metrics.silverHeight, Medal.silver), + (Name.bronzeStep, Metrics.bronzeHeight, Medal.bronze) + ] + for (name, height, color) in expectedSteps { + guard let step = requireEntity(name) else { continue } + requireCollision(step, name) + requireStaticBody(step, name) + if abs(step.position.y - height / 2) > 0.0001 { + problems.append("'\(name)' does not rest on the anchor plane") + } + guard let model = step.components[ModelComponent.self] else { + problems.append("'\(name)' has no ModelComponent") + continue + } + guard let simple = model.materials.first as? SimpleMaterial else { + problems.append("'\(name)' is not using a SimpleMaterial") + continue + } + if !sameColor(simple.color.tint, color) { + problems.append("'\(name)' is not using its medal color") + } + } + + // 2. Trophy on the tallest (gold) step, not loose in the scene. + if let trophy = requireEntity(Name.trophy), trophy.parent?.name != Name.goldStep { + problems.append("trophy is not parented to the gold step") + } + + // 3. Cup: a closed ring of wall segments over a floor disc. + if let cupFloor = requireEntity(Name.cupFloor) { + requireCollision(cupFloor, Name.cupFloor) + requireStaticBody(cupFloor, Name.cupFloor) + } + let wallSegments = (0.. 0.0001 { + problems.append("floor plane's top face is not at the anchor height") + } + } + + return problems + } + + /// Compares two colours by sRGB components — `UIColor ==` also compares + /// colour spaces, which a round-trip through the material does not promise + /// to preserve. + private static func sameColor(_ lhs: UIColor, _ rhs: UIColor) -> Bool { + var lr: CGFloat = 0, lg: CGFloat = 0, lb: CGFloat = 0, la: CGFloat = 0 + var rr: CGFloat = 0, rg: CGFloat = 0, rb: CGFloat = 0, ra: CGFloat = 0 + guard lhs.getRed(&lr, green: &lg, blue: &lb, alpha: &la), + rhs.getRed(&rr, green: &rg, blue: &rb, alpha: &ra) + else { return false } + let tolerance: CGFloat = 0.01 + return abs(lr - rr) < tolerance + && abs(lg - rg) < tolerance + && abs(lb - rb) < tolerance + && abs(la - ra) < tolerance + } +} +#endif diff --git a/ios/IPP/Game/TrophyTossView.swift b/ios/IPP/Game/TrophyTossView.swift index 90ac71a..04d1d45 100644 --- a/ios/IPP/Game/TrophyTossView.swift +++ b/ios/IPP/Game/TrophyTossView.swift @@ -4,13 +4,16 @@ import UIKit /// Entry screen of the "Tiro al Trofeo" AR mini-game, launched from the /// leaderboard. /// -/// This screen owns the camera-permission story: it asks for the camera when it -/// appears — the only moment the app ever asks (FR-010) — and renders a Spanish -/// explanation with a shortcut to Ajustes when the answer is no. The AR scene -/// itself arrives in a later phase; for now `readyState` is its placeholder. +/// Two faces, chosen by whether the game can actually run: +/// - the **AR screen** (`gameScreen`) — a full-bleed `PodiumARViewContainer` +/// with a thin Spanish overlay: hint, Reubicar, close (FR-002, FR-009); +/// - the **explainer screen** (`infoScreen`) — the camera-permission story. +/// It asks for the camera when this view appears, the only moment the app +/// ever asks (FR-010), and offers a shortcut to Ajustes when the answer is no. /// -/// The game is fully offline: this file makes no network request and never -/// touches `AppEnvironment` or the leaderboard data (FR-008). +/// Closing the screen removes the AR container, which tears the session down +/// (FR-011). The game is fully offline: this file makes no network request and +/// never touches `AppEnvironment` or the leaderboard data (FR-008). struct TrophyTossView: View { @Environment(\.dismiss) private var dismiss @Environment(\.scenePhase) private var scenePhase @@ -20,7 +23,113 @@ struct TrophyTossView: View { /// Guards against asking twice if the view's task runs again. @State private var didAsk = false + /// State shared with the `ARView`. Lives here so it survives the AR view's + /// own updates, and dies with this screen. + @StateObject private var arModel = PodiumARModel() + + private var canPlay: Bool { + ARSupport.isWorldTrackingSupported && permission == .granted + } + var body: some View { + Group { + if canPlay { + gameScreen + } else { + infoScreen + } + } + .task { await askForCameraIfNeeded() } + .onChange(of: scenePhase) { _, phase in + // Returning from Ajustes: the player may have changed the answer. + if phase == .active { permission = ARSupport.cameraPermission } + } + } + + // MARK: - AR screen + + private var gameScreen: some View { + ZStack { + Color.black.ignoresSafeArea() + + PodiumARViewContainer(model: arModel) + .ignoresSafeArea() + + VStack(spacing: 0) { + topBar + Spacer(minLength: 0) + hintBar + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 20) + } + } + + private var topBar: some View { + HStack(spacing: 10) { + Button { + dismiss() + } label: { + Label("Cerrar", systemImage: "xmark") + .labelStyle(.iconOnly) + .font(.headline) + .frame(width: 40, height: 40) + } + .buttonStyle(.plain) + .foregroundStyle(.white) + .background(Color.ippInk.opacity(0.55), in: Circle()) + .accessibilityLabel("Cerrar el juego") + + Spacer(minLength: 0) + + if arModel.isPlaced { + Button { + arModel.relocate() + } label: { + Label("Reubicar", systemImage: "arrow.triangle.2.circlepath") + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 14) + .frame(height: 40) + } + .buttonStyle(.plain) + .foregroundStyle(.white) + .background(Color.ippTeal.opacity(0.92), in: Capsule()) + } + } + } + + private var hintBar: some View { + HStack(spacing: 10) { + Image(systemName: hintIcon) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(hintTint) + Text(arModel.hint) + .font(.subheadline) + .foregroundStyle(.white) + .multilineTextAlignment(.leading) + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(Color.ippInk.opacity(0.72), in: RoundedRectangle(cornerRadius: 14)) + .animation(.easeInOut(duration: 0.2), value: arModel.hint) + } + + private var hintIcon: String { + if arModel.failure != nil { return "exclamationmark.triangle.fill" } + if arModel.trackingIssue != nil { return "viewfinder.trianglebadge.exclamationmark" } + return arModel.isPlaced ? "trophy.fill" : "hand.tap.fill" + } + + private var hintTint: Color { + if arModel.failure != nil || arModel.trackingIssue != nil { return .ippGold } + return .white.opacity(0.85) + } + + // MARK: - Explainer screen + + private var infoScreen: some View { NavigationStack { ZStack { Color.ippScreen.ignoresSafeArea() @@ -42,11 +151,6 @@ struct TrophyTossView: View { } } } - .task { await askForCameraIfNeeded() } - .onChange(of: scenePhase) { _, phase in - // Returning from Ajustes: the player may have changed the answer. - if phase == .active { permission = ARSupport.cameraPermission } - } } // MARK: - Sections @@ -80,7 +184,7 @@ struct TrophyTossView: View { unsupportedState } else { switch permission { - case .granted: readyState + case .granted: openingState case .denied: deniedState case .restricted: restrictedState case .notDetermined: askingState @@ -148,29 +252,15 @@ struct TrophyTossView: View { } } - /// Placeholder for the AR content that a later phase installs here. - private var readyState: some View { - card(icon: "checkmark.circle.fill", tint: .ippTeal, title: "Cámara lista") { - VStack(alignment: .leading, spacing: 12) { - Text("Ya podemos usar la cámara. La vista de realidad aumentada con el podio se añade en la siguiente entrega.") + /// Only ever on screen for the frame between the player granting the camera + /// and `canPlay` swapping this whole screen for `gameScreen`. + private var openingState: some View { + card(icon: "camera.fill", tint: .ippTeal, title: "Abriendo la cámara") { + HStack(spacing: 8) { + ProgressView() + Text("Preparando la vista de realidad aumentada…") .font(.callout) .foregroundStyle(Color.ippBody) - RoundedRectangle(cornerRadius: 14) - .strokeBorder( - Color.ippFaint, - style: StrokeStyle(lineWidth: 1.5, dash: [6, 5]) - ) - .frame(height: 180) - .overlay( - VStack(spacing: 6) { - Image(systemName: "arkit") - .font(.largeTitle) - .foregroundStyle(Color.ippFaint) - Text("Vista AR · próximamente") - .font(.caption) - .foregroundStyle(Color.ippMuted) - } - ) } } } From fb67dd6f51a1f4b25e1d1f9621039bfb9911dc77 Mon Sep 17 00:00:00 2001 From: Eddie Date: Mon, 24 Aug 2026 12:04:32 -0400 Subject: [PATCH 03/10] feat(ios): swipe-to-toss physics, cup scoring and ball culling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Trophy Toss AR mini-game (spec FR-004/FR-005/FR-006, SC-002, SC-006), which completes spec User Story 1. - TossController: the rules of the throw as a pure, ARKit-free type — swipe + duration -> clamped launch impulse (camera aim + world-up arc + sideways deflection), 0.3 s rate limit, 8-ball cap, scored-once state and the culling predicates. Launch speeds (1.6-4.5 m/s) are derived from the podium's real geometry; every feel constant is a Tuning property so Gate 3 feedback is a one-line edit. - PodiumBuilder.makeBall: 3.5 cm dynamic sphere in the brand teal, with continuous collision detection so a hard throw cannot tunnel through the cup wall. - PodiumARViewContainer: pan-to-throw, balls parented to the podium's own anchor (RealityKit simulates physics per anchor), CollisionEvents.Began on the cup trigger for scoring, a success haptic plus a trophy pulse, and a per-frame culler for balls at rest, out of bounds or over 5 s. - Person occlusion (.personSegmentationWithDepth where supported), from the Gate 2 finding that the podium drew over the player's hand. - TrophyTossView: session score pill, and a hint that teaches the swipe. Verified: 39 unit tests green on the iPhone 17 Pro simulator via a temporary, uncommitted test target; simulator build clean; installed on a physical iPhone for HUMAN DEVICE GATE 3. --- ios/IPP/Game/PodiumARViewContainer.swift | 263 ++++++++++++++- ios/IPP/Game/PodiumBuilder.swift | 44 +++ ios/IPP/Game/TossController.swift | 391 +++++++++++++++++++++++ ios/IPP/Game/TrophyTossView.swift | 26 +- 4 files changed, 716 insertions(+), 8 deletions(-) create mode 100644 ios/IPP/Game/TossController.swift diff --git a/ios/IPP/Game/PodiumARViewContainer.swift b/ios/IPP/Game/PodiumARViewContainer.swift index 9607a86..a68bd7e 100644 --- a/ios/IPP/Game/PodiumARViewContainer.swift +++ b/ios/IPP/Game/PodiumARViewContainer.swift @@ -32,6 +32,10 @@ final class PodiumARModel: ObservableObject { @Published fileprivate(set) var failure: String? /// Short-lived feedback, e.g. a tap that hit no surface. @Published fileprivate(set) var transientHint: String? + /// Balls landed in the cup since the game screen opened. Phase 4 replaces + /// this free-play counter with a per-round score (FR-007); for now it is the + /// whole HUD. + @Published fileprivate(set) var score: Int = 0 /// Installed by the coordinator so the overlay's "Reubicar" button can /// reach the AR session. @@ -50,7 +54,7 @@ final class PodiumARModel: ObservableObject { case .readyToPlace: return "Toca la superficie para colocar el podio." case .placed: - return "Podio colocado. Lanza pelotas o pulsa Reubicar." + return "Desliza hacia arriba para lanzar la pelota a la copa." } } @@ -60,6 +64,10 @@ final class PodiumARModel: ObservableObject { relocateHandler?() } + fileprivate func registerScore() { + score += 1 + } + fileprivate func flash(_ message: String) { transientHint = message Task { [weak self] in @@ -72,7 +80,12 @@ final class PodiumARModel: ObservableObject { /// The AR half of "Tiro al Trofeo": a RealityKit `ARView` running world /// tracking with horizontal plane detection, an `ARCoachingOverlayView` for the -/// scan hint, and tap-to-place for the procedural podium (FR-002, FR-003). +/// scan hint, tap-to-place for the procedural podium (FR-002, FR-003) and +/// swipe-to-throw for the balls, including cup scoring and ball culling +/// (FR-004, FR-005, FR-006). +/// +/// The rules of the throw live in `TossController`, which knows nothing about +/// ARKit; this file supplies the camera pose, the entities and the frame clock. /// /// The session is torn down completely when SwiftUI removes the view — paused, /// un-delegated, anchors and subscriptions dropped — so closing the game leaves @@ -123,19 +136,44 @@ struct PodiumARViewContainer: UIViewRepresentable { private weak var arView: ARView? private let coachingOverlay = ARCoachingOverlayView() private var tapRecognizer: UITapGestureRecognizer? + private var panRecognizer: UIPanGestureRecognizer? /// The one anchor the podium lives on. Kept so relocation can remove /// exactly it, and so tracking recovery can be checked against it. private var podiumAnchor: AnchorEntity? - /// Phase 3 will put its collision subscriptions here; the array exists - /// now so teardown is already correct. private var subscriptions: [any Cancellable] = [] + /// Cup-trigger subscription, held apart from the rest because it is made + /// and dropped with the podium rather than with the view. + private var cupSubscription: (any Cancellable)? private var lifecycleObservers: [NSObjectProtocol] = [] private var hasSeenPlane = false private var isPausedForBackground = false private var isTornDown = false + // MARK: Toss state (Phase 3) + + /// Rules and tuning for the toss — pure, and unit-tested off-device. + private var toss = TossController() + /// Balls currently in the scene, with the bookkeeping the culler needs. + private var balls: [LiveBall] = [] + /// When the current swipe started, in `CACurrentMediaTime()` seconds. + private var swipeStart: TimeInterval? + /// The trophy's resting transform, captured at placement so the score + /// pulse always animates back to a known pose rather than to whatever + /// mid-animation value it happens to read. + private var trophyRestTransform: Transform? + private let successHaptics = UINotificationFeedbackGenerator() + + /// One ball in flight: the entity plus the two timers the culling rules + /// in `TossController` are written against (FR-006). + private struct LiveBall { + let id: TossController.BallID + let entity: ModelEntity + var age: TimeInterval = 0 + var restingFor: TimeInterval = 0 + } + init(model: PodiumARModel) { self.model = model super.init() @@ -144,14 +182,25 @@ struct PodiumARViewContainer: UIViewRepresentable { // MARK: Session configuration - /// World tracking with horizontal plane detection — the minimum the - /// game needs, and nothing more (no people occlusion, no scene mesh), - /// which keeps the frame rate healthy on older iPhones. + /// World tracking with horizontal plane detection, plus person + /// occlusion where the hardware offers it. No scene mesh — the podium + /// only ever sits on a detected plane, so reconstruction would cost + /// frame rate for nothing. + /// + /// Person occlusion comes from Gate 2's one finding (row 2.4): with it + /// off, a hand passing in front of the phone is painted *behind* the + /// podium, which reads as broken. `.personSegmentationWithDepth` makes + /// people and hands occlude virtual content at the right depth. It needs + /// an A12 or newer device, so the capability is checked and the game + /// simply runs without it on older hardware. private func makeConfiguration() -> ARWorldTrackingConfiguration { let configuration = ARWorldTrackingConfiguration() configuration.planeDetection = [.horizontal] configuration.environmentTexturing = .automatic configuration.isLightEstimationEnabled = true + if ARWorldTrackingConfiguration.supportsFrameSemantics(.personSegmentationWithDepth) { + configuration.frameSemantics.insert(.personSegmentationWithDepth) + } return configuration } @@ -166,6 +215,21 @@ struct PodiumARViewContainer: UIViewRepresentable { arView.addGestureRecognizer(tap) tapRecognizer = tap + // Tap places the podium, swipe throws a ball. The two never fight: + // a pan only begins once the finger has moved, which a tap never + // does, and the swipe handler ignores everything until the podium + // is down. + let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) + pan.maximumNumberOfTouches = 1 + arView.addGestureRecognizer(pan) + panRecognizer = pan + + subscriptions.append( + arView.scene.subscribe(to: SceneEvents.Update.self) { [weak self] event in + MainActor.assumeIsolated { self?.stepBalls(deltaTime: event.deltaTime) } + } + ) + observeAppLifecycle() } @@ -267,6 +331,11 @@ struct PodiumARViewContainer: UIViewRepresentable { model.phase = .placed model.transientHint = nil + trophyRestTransform = scene.findEntity(named: PodiumBuilder.Name.trophy)?.transform + subscribeToCup(in: arView, anchor: anchor) + // Warms the Taptic Engine so the first score's haptic is immediate. + successHaptics.prepare() + // From here the player is looking at the podium, so stop the // full-screen coaching overlay from covering it; our own hint takes // over if tracking degrades. @@ -274,6 +343,19 @@ struct PodiumARViewContainer: UIViewRepresentable { coachingOverlay.setActive(false, animated: true) } + /// Listens to the cup's invisible trigger volume, which is the only + /// thing that can turn a ball into a point (FR-005). + private func subscribeToCup(in arView: ARView, anchor: AnchorEntity) { + cupSubscription = nil + guard let trigger = anchor.findEntity(named: PodiumBuilder.Name.cupTrigger) else { return } + cupSubscription = arView.scene.subscribe( + to: CollisionEvents.Began.self, + on: trigger + ) { [weak self] event in + MainActor.assumeIsolated { self?.handleCupEntry(event) } + } + } + /// Rotation about +Y that turns the podium's front (+Z) toward the /// camera, so the steps face the player however they were standing. private func yaw(towardCameraFrom position: SIMD3, in arView: ARView) -> Float { @@ -287,6 +369,13 @@ struct PodiumARViewContainer: UIViewRepresentable { private func relocate() { guard !isTornDown, let arView, let anchor = podiumAnchor else { return } + // The balls are children of this anchor, so removing it takes them + // with it; the controller has to be told so its live-ball cap does + // not stay pinned at the balls that no longer exist. + balls.removeAll() + toss.retireAll() + cupSubscription = nil + trophyRestTransform = nil arView.scene.removeAnchor(anchor) podiumAnchor = nil model.phase = hasSeenPlane ? .readyToPlace : .scanning @@ -294,6 +383,156 @@ struct PodiumARViewContainer: UIViewRepresentable { coachingOverlay.activatesAutomatically = true } + // MARK: - Tossing (FR-004) + + /// A swipe anywhere on the AR view throws a ball. Power comes from how + /// fast the finger travelled *upward*, aim from where the phone points, + /// and a nudge left or right from the swipe's horizontal component — + /// all of it decided by `TossController`, which this method only feeds + /// and obeys. + @objc + private func handlePan(_ gesture: UIPanGestureRecognizer) { + guard !isTornDown, let arView, podiumAnchor != nil else { return } + + switch gesture.state { + case .began: + swipeStart = CACurrentMediaTime() + case .ended: + let now = CACurrentMediaTime() + let started = swipeStart ?? now + swipeStart = nil + let translation = gesture.translation(in: arView) + throwBall( + TossController.Swipe( + translation: SIMD2(Float(translation.x), Float(translation.y)), + duration: now - started + ), + at: now + ) + case .cancelled, .failed: + swipeStart = nil + default: + break + } + } + + private func throwBall(_ swipe: TossController.Swipe, at now: TimeInterval) { + guard let arView, + let anchor = podiumAnchor, + let frame = arView.session.currentFrame + else { return } + + let camera = TossController.CameraBasis(transform: frame.camera.transform) + + switch toss.flick(swipe, camera: camera, at: now) { + case .rejected(.tooManyLiveBalls): + model.flash("Demasiadas pelotas en juego. Espera un momento.") + case .rejected: + // A drag that was not a toss, or a flick inside the 0.3 s rate + // limit. Both are the player's normal behaviour, not errors, so + // they pass in silence. + break + case .launched(let launch): + spawn(launch, on: anchor) + } + } + + /// Puts one ball into the scene and pushes it. + /// + /// The ball is parented to the **podium's own anchor** rather than to a + /// new one: RealityKit simulates physics per anchor, so a ball on any + /// other anchor would fall straight through the steps, the cup and the + /// floor plane. + private func spawn(_ launch: TossController.Launch, on anchor: AnchorEntity) { + let ball = PodiumBuilder.makeBall( + id: launch.ball, + radius: toss.tuning.ballRadius, + mass: toss.tuning.ballMass, + friction: toss.tuning.ballFriction, + restitution: toss.tuning.ballRestitution + ) + anchor.addChild(ball) + // The launch is computed in world space; the ball's transform is + // relative to the anchor it now hangs from. + ball.position = anchor.convert(position: launch.origin, from: nil) + ball.applyLinearImpulse(launch.impulse, relativeTo: nil) + balls.append(LiveBall(id: launch.ball, entity: ball)) + } + + // MARK: - Scoring (FR-005, SC-002) + + private func handleCupEntry(_ event: CollisionEvents.Began) { + guard !isTornDown else { return } + // One of the two entities is the trigger volume; the other is + // whatever crossed it. Only a ball we launched counts. + guard let ball = balls.first(where: { $0.entity === event.entityA || $0.entity === event.entityB }) + else { return } + // False unless this is the ball's *first* crossing, so a ball that + // settles, rolls and re-triggers still scores exactly once. + guard toss.score(ball.id) else { return } + + model.registerScore() + celebrate() + } + + /// Success cue: the success haptic plus a quick swell of the trophy, so + /// the score reads even when the phone is at arm's length. + private func celebrate() { + successHaptics.notificationOccurred(.success) + successHaptics.prepare() + + guard let trophy = podiumAnchor?.findEntity(named: PodiumBuilder.Name.trophy), + let rest = trophyRestTransform + else { return } + + var swollen = rest + swollen.scale = rest.scale * 1.28 + _ = trophy.move(to: swollen, relativeTo: trophy.parent, duration: 0.14, timingFunction: .easeOut) + + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 150_000_000) + guard let self, !self.isTornDown, let rest = self.trophyRestTransform else { return } + _ = trophy.move(to: rest, relativeTo: trophy.parent, duration: 0.22, timingFunction: .easeInOut) + } + } + + // MARK: - Culling (FR-006, SC-006) + + /// Runs once per rendered frame: ages every live ball, tracks how long + /// it has been still, and removes it once `TossController` says it is + /// litter — at rest, off the table, or simply too old. This is what + /// keeps a minute of spam-flicking bounded (SC-006). + private func stepBalls(deltaTime: TimeInterval) { + guard !isTornDown, !balls.isEmpty, let anchor = podiumAnchor else { return } + + var survivors: [LiveBall] = [] + survivors.reserveCapacity(balls.count) + + for var ball in balls { + ball.age += deltaTime + let speed = simd_length(ball.entity.physicsMotion?.linearVelocity ?? .zero) + ball.restingFor = toss.restingDuration( + previous: ball.restingFor, + speed: speed, + delta: deltaTime + ) + let height = ball.entity.position(relativeTo: anchor).y + + if toss.cullReason( + age: ball.age, + restingFor: ball.restingFor, + heightAboveAnchor: height + ) != nil { + ball.entity.removeFromParent() + toss.retire(ball.id) + } else { + survivors.append(ball) + } + } + + balls = survivors + } + // MARK: ARSessionDelegate / ARCoachingOverlayViewDelegate // // The delegate methods themselves are `nonisolated` — ARKit's protocols @@ -391,6 +630,12 @@ struct PodiumARViewContainer: UIViewRepresentable { lifecycleObservers.removeAll() subscriptions.removeAll() + cupSubscription = nil + + balls.removeAll() + toss.retireAll() + trophyRestTransform = nil + swipeStart = nil coachingOverlay.delegate = nil coachingOverlay.session = nil @@ -400,11 +645,15 @@ struct PodiumARViewContainer: UIViewRepresentable { if let tapRecognizer { arView.removeGestureRecognizer(tapRecognizer) } + if let panRecognizer { + arView.removeGestureRecognizer(panRecognizer) + } arView.scene.anchors.removeAll() arView.session.pause() arView.session.delegate = nil } tapRecognizer = nil + panRecognizer = nil podiumAnchor = nil arView = nil } diff --git a/ios/IPP/Game/PodiumBuilder.swift b/ios/IPP/Game/PodiumBuilder.swift index 255203d..522374e 100644 --- a/ios/IPP/Game/PodiumBuilder.swift +++ b/ios/IPP/Game/PodiumBuilder.swift @@ -37,6 +37,9 @@ enum PodiumBuilder { static let cupFloor = "cup_floor" static let cupTrigger = "cup_trigger" static let floor = "floor" + /// Balls are named `ball_` so a scene dump stays readable; the game + /// itself matches them by identity, not by name. + static let ballPrefix = "ball_" } // MARK: - Dimensions @@ -91,6 +94,10 @@ enum PodiumBuilder { static let gold = UIColor(red: 0.95, green: 0.78, blue: 0.18, alpha: 1) static let silver = UIColor(red: 0.75, green: 0.78, blue: 0.82, alpha: 1) static let bronze = UIColor(red: 0.80, green: 0.50, blue: 0.20, alpha: 1) + /// The ball wears the brand teal (`LinearGradient.ippBrand`'s light + /// stop, `#13837E`) so it reads as the app's rather than as a stray + /// object, and stays legible against gold and against most desks. + static let ball = UIColor(red: 0x13 / 255, green: 0x83 / 255, blue: 0x7E / 255, alpha: 1) } // MARK: - Public assembly @@ -342,6 +349,43 @@ enum PodiumBuilder { return floor } + // MARK: - Ball (FR-004) + + /// One throwable ball: a small dynamic sphere in the brand teal. + /// + /// Dimensions and physics material come from `TossController.Tuning`, which + /// is where Gate 3's feel feedback gets applied — this function only turns + /// those numbers into an entity. + /// + /// Continuous collision detection is on: at 4.5 m/s a 3.5 cm ball moves + /// ~7.5 cm per 60 Hz step, further than the cup's 6 mm walls are thick, so + /// discrete stepping would let a hard throw tunnel straight through the cup. + static func makeBall( + id: UInt64, + radius: Float, + mass: Float, + friction: Float, + restitution: Float + ) -> ModelEntity { + let ball = ModelEntity( + mesh: .generateSphere(radius: radius), + materials: [material(Medal.ball, roughness: 0.35)] + ) + ball.name = "\(Name.ballPrefix)\(id)" + ball.components.set(CollisionComponent(shapes: [.generateSphere(radius: radius)])) + var body = PhysicsBodyComponent( + massProperties: .init(mass: mass), + material: .generate(friction: friction, restitution: restitution), + mode: .dynamic + ) + body.isContinuousCollisionDetectionEnabled = true + ball.components.set(body) + // Present from the start so the culler can read the ball's speed on the + // very first frame instead of treating it as motionless. + ball.components.set(PhysicsMotionComponent()) + return ball + } + // MARK: - Helpers static func material( diff --git a/ios/IPP/Game/TossController.swift b/ios/IPP/Game/TossController.swift new file mode 100644 index 0000000..0af4fb4 --- /dev/null +++ b/ios/IPP/Game/TossController.swift @@ -0,0 +1,391 @@ +import Foundation +import simd + +/// The rules of "Tiro al Trofeo", with none of the machinery. +/// +/// Everything a toss needs to be decided — does this swipe count, may we launch +/// right now, how fast and in which direction does the ball leave, has this ball +/// already scored, is it time to clean it up — lives here as plain arithmetic +/// over `simd` vectors. The type imports no ARKit and no RealityKit, so it runs +/// (and is asserted) in the Simulator, where ARKit does not exist. +/// +/// `PodiumARViewContainer.Coordinator` owns one of these and does the other +/// half: it reads the ARKit camera, spawns RealityKit entities and applies the +/// impulses this type hands it. +/// +/// Conventions: +/// - **Screen space** is UIKit's: points, `+x` right, `+y` **down**. So an +/// upward flick has a *negative* `translation.y`. +/// - **World space** is RealityKit's: metres, `+y` up. +/// +/// # Tuning (Gate 3) +/// +/// Every number the game's feel depends on is a stored property of `Tuning`, so +/// the owner's Gate 3 feedback ("too weak", "too floaty", "curves too much") +/// turns into a one-line edit of ``Tuning/init()``'s defaults rather than a hunt +/// through the AR code. +/// +/// The launch-speed range is picked from the actual geometry rather than by +/// eye. The podium is ~30 cm wide and the cup mouth sits ~0.17 m above the +/// surface it stands on; a player holds the phone ~0.35 m above that surface and +/// stands 0.5–1.0 m away. Firing at ``Tuning/arc`` = 0.45 world-up per unit of +/// aim (≈ 24° above where the phone points) and solving the projectile equations +/// for those distances under RealityKit's 9.81 m/s² gravity gives: +/// +/// | Distance to the cup | Speed that lands in it | +/// |---|---| +/// | 0.5 m | ≈ 1.9 m/s | +/// | 0.7 m | ≈ 2.4 m/s | +/// | 1.0 m | ≈ 3.1 m/s | +/// +/// So the flick maps onto **1.6 … 4.5 m/s**: the band brackets that 1.9–3.1 +/// sweet spot with room on both sides, which is what makes it a game — a limp +/// flick drops short, a hard one sails over the podium. +struct TossController { + + // MARK: - Tuning + + /// Every constant the game's feel depends on, in one place. + struct Tuning: Equatable { + + // Ball body — a table-tennis-sized sphere with a little bounce. + + /// Radius of the ball, in metres. 3.5 cm against a 10 cm cup mouth. + var ballRadius: Float = 0.035 + /// Mass, in kilograms. Light enough to be lively, heavy enough that a + /// bounce off the podium does not fling it across the room. + var ballMass: Float = 0.045 + /// Bounciness. Kept well under the cup wall's own value so a ball that + /// hits the rim does not rocket away. + var ballRestitution: Float = 0.35 + var ballFriction: Float = 0.60 + + // Launch power — flick speed in points/second maps onto metres/second. + + /// Speed of the weakest launch, in m/s. Undershoots from ~0.5 m. + var minLaunchSpeed: Float = 1.6 + /// Speed of the hardest launch, in m/s. Overshoots from ~1.0 m. + var maxLaunchSpeed: Float = 4.5 + /// Upward flick speed (points/second) that still maps to + /// ``minLaunchSpeed`` — a slow drag. + var slowFlick: Float = 350 + /// Upward flick speed (points/second) that reaches ``maxLaunchSpeed``. + /// A brisk thumb flick covers ~250 pt in ~0.10 s. + var fastFlick: Float = 2400 + /// Floor on the measured swipe duration, so a gesture the system reports + /// as near-instant cannot divide its way to an absurd flick speed. + var minimumSwipeDuration: TimeInterval = 0.05 + /// How far the finger must travel **upward** for the gesture to count as + /// a toss at all. Stops a stray drag while aiming from firing a ball. + var minimumUpwardTravel: Float = 40 + + // Aim — where the ball goes. + + /// World-up added per unit of camera-forward before normalising, i.e. + /// how much loft is baked into every throw. 0.45 ≈ 24° above the aim. + var arc: Float = 0.45 + /// Points of *horizontal* swipe that produce one full unit of sideways + /// deflection (before the cap below). + var lateralReference: Float = 220 + /// Hard cap on the sideways deflection, as a fraction of the aim vector. + /// Keeps a diagonal flick a nudge rather than a right-angle turn. + var maxLateral: Float = 0.35 + + // Spawn point — just in front of the camera, not inside it. + + /// Metres in front of the camera the ball appears at, so it is outside + /// the near plane and visibly leaves the player's hand. + var spawnForwardOffset: Float = 0.16 + /// Metres below the camera the ball appears at, so it arcs up into view + /// rather than starting dead centre over the crosshair. + var spawnDownOffset: Float = 0.04 + + // Flood control (FR-006, edge case "ball spam"). + + /// Minimum seconds between two launches. + var minimumLaunchInterval: TimeInterval = 0.30 + /// Hard cap on balls simulating at once. + var maximumLiveBalls: Int = 8 + + // Culling (FR-006, edge case "stale balls"). + + /// Speed (m/s) below which a ball counts as motionless. + var restSpeed: Float = 0.06 + /// Seconds a ball may sit still before it is removed. + var restDuration: TimeInterval = 1.0 + /// Seconds any ball may exist, however it is moving. + var maximumAge: TimeInterval = 5.0 + /// Height relative to the anchor plane (metres, so negative is below the + /// table) past which a ball has clearly left the play area. + var minimumHeight: Float = -0.40 + + init() {} + } + + // MARK: - Values crossing the boundary + + /// Identifier the controller hands out per launch. Monotonic and never + /// reused, so a stale collision event can never credit a later ball. + typealias BallID = UInt64 + + /// A finished swipe, in UIKit screen space. + struct Swipe: Equatable { + /// Total finger travel in points: `+x` right, `+y` **down**. + var translation: SIMD2 + /// Seconds between touch-down and lift. + var duration: TimeInterval + + init(translation: SIMD2, duration: TimeInterval) { + self.translation = translation + self.duration = duration + } + + /// Upward travel in points (0 for a sideways or downward swipe). + var upwardTravel: Float { max(-translation.y, 0) } + } + + /// The camera's world-space pose, reduced to the three things a throw needs. + /// + /// Built from an `ARCamera`'s transform by the AR side; `simd_float4x4` is a + /// math type, so taking it here keeps this file free of ARKit. + struct CameraBasis: Equatable { + /// Where the camera is, in world space. + var position: SIMD3 + /// Unit vector the camera looks along. + var forward: SIMD3 + /// Unit vector out of the camera's right-hand side. + var right: SIMD3 + + init(position: SIMD3, forward: SIMD3, right: SIMD3) { + self.position = position + self.forward = forward + self.right = right + } + + /// ARKit's camera transform: `+x` right, `+y` up, `+z` **backward**, so + /// the viewing direction is the negated third column. + init(transform: simd_float4x4) { + self.init( + position: SIMD3(transform.columns.3.x, transform.columns.3.y, transform.columns.3.z), + forward: -SIMD3(transform.columns.2.x, transform.columns.2.y, transform.columns.2.z), + right: SIMD3(transform.columns.0.x, transform.columns.0.y, transform.columns.0.z) + ) + } + } + + /// Everything the AR side needs to put one ball into the scene. + struct Launch: Equatable { + var ball: BallID + /// World-space spawn point. + var origin: SIMD3 + /// World-space velocity the ball should leave with, in m/s. + var velocity: SIMD3 + /// The same thing as an impulse (mass × velocity), which is what + /// RealityKit's `applyLinearImpulse` wants. + var impulse: SIMD3 + } + + /// Why a flick did not become a ball. + enum Rejection: Equatable { + /// The finger did not travel far enough upward to read as a toss. + case notAToss + /// Less than ``Tuning/minimumLaunchInterval`` since the last launch. + case tooSoon + /// ``Tuning/maximumLiveBalls`` are already in flight. + case tooManyLiveBalls + } + + enum Outcome: Equatable { + case launched(Launch) + case rejected(Rejection) + } + + /// Why a ball is being taken out of the scene. + enum CullReason: Equatable { + /// It has been motionless long enough to be litter. + case atRest + /// It fell off the table or was thrown out of the play area. + case outOfBounds + /// It simply ran out of time. + case expired + } + + // MARK: - State + + var tuning: Tuning + + /// Balls currently simulating, newest last. + private(set) var liveBalls: [BallID] = [] + /// Balls that have already been credited. Cleared per ball on ``retire(_:)`` + /// — ids are never reused, so nothing can be double-credited afterwards. + private var scoredBalls: Set = [] + private var lastLaunch: TimeInterval? + private var nextBall: BallID = 1 + + init(tuning: Tuning = Tuning()) { + self.tuning = tuning + } + + var liveBallCount: Int { liveBalls.count } + + func hasScored(_ ball: BallID) -> Bool { scoredBalls.contains(ball) } + + func isLive(_ ball: BallID) -> Bool { liveBalls.contains(ball) } + + // MARK: - Swipe → impulse (pure) + + /// Does this gesture read as a toss, rather than as aiming or a stray drag? + func isToss(_ swipe: Swipe) -> Bool { + swipe.upwardTravel >= tuning.minimumUpwardTravel + } + + /// Launch speed in m/s, clamped to + /// ``Tuning/minLaunchSpeed``…``Tuning/maxLaunchSpeed``. + /// + /// Only the **upward** part of the swipe sets the power. That keeps aiming + /// and power independent: sideways travel steers (see ``lateralDeflection``) + /// and never adds force, so a hard sideways swipe is a gentle, wide throw + /// rather than a rocket. + func launchSpeed(for swipe: Swipe) -> Float { + let seconds = Float(max(swipe.duration, tuning.minimumSwipeDuration)) + let flick = swipe.upwardTravel / seconds + let span = max(tuning.fastFlick - tuning.slowFlick, 1) + let t = min(max((flick - tuning.slowFlick) / span, 0), 1) + return tuning.minLaunchSpeed + t * (tuning.maxLaunchSpeed - tuning.minLaunchSpeed) + } + + /// Sideways steering from the horizontal part of the swipe, as a fraction of + /// the aim vector, clamped to ±``Tuning/maxLateral``. Positive is to the + /// player's right, matching the swipe. + func lateralDeflection(for swipe: Swipe) -> Float { + let raw = swipe.translation.x / max(tuning.lateralReference, 1) + return min(max(raw, -tuning.maxLateral), tuning.maxLateral) + } + + /// Where the ball leaves from: just in front of and slightly below the + /// camera, so it is outside the near plane and reads as leaving the hand. + func launchOrigin(camera: CameraBasis) -> SIMD3 { + let aim = Self.unit(camera.forward, fallback: Self.defaultForward) + return camera.position + + aim * tuning.spawnForwardOffset + - Self.worldUp * tuning.spawnDownOffset + } + + /// World-space launch velocity: the camera's aim, lofted by ``Tuning/arc`` + /// and steered by the swipe, scaled to ``launchSpeed(for:)``. + /// + /// The loft is added along **world** up rather than the camera's up, so the + /// arc is the same whether the player holds the phone level or tilted. + func launchVelocity(for swipe: Swipe, camera: CameraBasis) -> SIMD3 { + let aim = Self.unit(camera.forward, fallback: Self.defaultForward) + let side = Self.unit(camera.right, fallback: Self.defaultRight) + let heading = aim + + Self.worldUp * tuning.arc + + side * lateralDeflection(for: swipe) + // |aim| == 1 and both additions are < 1, so `heading` cannot collapse to + // zero; the fallback is belt-and-braces for a degenerate camera basis. + return Self.unit(heading, fallback: aim) * launchSpeed(for: swipe) + } + + /// The velocity above expressed as the impulse RealityKit expects (N·s). + func impulse(for swipe: Swipe, camera: CameraBasis) -> SIMD3 { + launchVelocity(for: swipe, camera: camera) * tuning.ballMass + } + + // MARK: - Launching (stateful) + + /// Why a launch would be refused right now, or `nil` if it is allowed. + func launchBlocker(at now: TimeInterval) -> Rejection? { + if liveBalls.count >= tuning.maximumLiveBalls { return .tooManyLiveBalls } + if let lastLaunch, now - lastLaunch < tuning.minimumLaunchInterval { return .tooSoon } + return nil + } + + /// Turns a finished swipe into a ball, subject to the gesture test, the rate + /// limit and the live-ball cap. + /// + /// On success the new ball is recorded as live; the caller is responsible + /// for calling ``retire(_:)`` when it removes the entity again. + mutating func flick(_ swipe: Swipe, camera: CameraBasis, at now: TimeInterval) -> Outcome { + guard isToss(swipe) else { return .rejected(.notAToss) } + if let blocker = launchBlocker(at: now) { return .rejected(blocker) } + + let ball = nextBall + nextBall += 1 + liveBalls.append(ball) + lastLaunch = now + + let velocity = launchVelocity(for: swipe, camera: camera) + return .launched( + Launch( + ball: ball, + origin: launchOrigin(camera: camera), + velocity: velocity, + impulse: velocity * tuning.ballMass + ) + ) + } + + // MARK: - Scoring (SC-002) + + /// Credits a ball for landing in the cup. + /// + /// Returns `true` **exactly once** per ball: the cup's trigger volume fires + /// a collision every time the ball crosses it — settling, bouncing, rolling + /// — and only the first of those is a point. A ball that has already been + /// retired scores nothing, so a late event cannot resurrect it. + mutating func score(_ ball: BallID) -> Bool { + guard liveBalls.contains(ball) else { return false } + return scoredBalls.insert(ball).inserted + } + + // MARK: - Culling (FR-006) + + /// Runs the "how long has this ball been still" accumulator. + /// + /// Returns the updated resting time: `previous + delta` while the ball is + /// slower than ``Tuning/restSpeed``, and back to zero the moment it moves. + func restingDuration(previous: TimeInterval, speed: Float, delta: TimeInterval) -> TimeInterval { + speed <= tuning.restSpeed ? previous + delta : 0 + } + + /// Whether this ball should leave the scene, and why. + func cullReason( + age: TimeInterval, + restingFor: TimeInterval, + heightAboveAnchor: Float + ) -> CullReason? { + if heightAboveAnchor < tuning.minimumHeight { return .outOfBounds } + if restingFor >= tuning.restDuration { return .atRest } + if age >= tuning.maximumAge { return .expired } + return nil + } + + /// Forgets a ball the caller has removed from the scene. + mutating func retire(_ ball: BallID) { + liveBalls.removeAll { $0 == ball } + scoredBalls.remove(ball) + } + + /// Drops all per-ball state, e.g. when the podium is relocated and every + /// ball is cleared out with it. Ids keep counting up. + mutating func retireAll() { + liveBalls.removeAll() + scoredBalls.removeAll() + lastLaunch = nil + } + + // MARK: - Vector helpers + + static let worldUp = SIMD3(0, 1, 0) + private static let defaultForward = SIMD3(0, 0, -1) + private static let defaultRight = SIMD3(1, 0, 0) + + /// `simd_normalize` on a zero-length vector is a NaN factory; this is the + /// same operation with a defined answer for that case. + private static func unit(_ vector: SIMD3, fallback: SIMD3) -> SIMD3 { + let lengthSquared = simd_length_squared(vector) + guard lengthSquared > 1e-12, lengthSquared.isFinite else { return fallback } + return vector / lengthSquared.squareRoot() + } +} diff --git a/ios/IPP/Game/TrophyTossView.swift b/ios/IPP/Game/TrophyTossView.swift index 04d1d45..f42f879 100644 --- a/ios/IPP/Game/TrophyTossView.swift +++ b/ios/IPP/Game/TrophyTossView.swift @@ -6,7 +6,8 @@ import UIKit /// /// Two faces, chosen by whether the game can actually run: /// - the **AR screen** (`gameScreen`) — a full-bleed `PodiumARViewContainer` -/// with a thin Spanish overlay: hint, Reubicar, close (FR-002, FR-009); +/// with a thin Spanish overlay: hint, Reubicar, close and the session score +/// (FR-002, FR-005, FR-009); /// - the **explainer screen** (`infoScreen`) — the camera-permission story. /// It asks for the camera when this view appears, the only moment the app /// ever asks (FR-010), and offers a shortcut to Ajustes when the answer is no. @@ -95,10 +96,33 @@ struct TrophyTossView: View { .buttonStyle(.plain) .foregroundStyle(.white) .background(Color.ippTeal.opacity(0.92), in: Capsule()) + + scorePill } } } + /// The whole HUD for now: how many balls have gone in since the screen + /// opened. Phase 4 puts a countdown and a round score in its place (FR-007). + private var scorePill: some View { + HStack(spacing: 7) { + Image(systemName: "trophy.fill") + .font(.subheadline.weight(.semibold)) + Text("\(arModel.score)") + .font(.title3.weight(.bold)) + .monospacedDigit() + .contentTransition(.numericText(value: Double(arModel.score))) + } + .foregroundStyle(Color.ippGold) + .padding(.horizontal, 14) + .frame(height: 40) + .background(Color.ippInk.opacity(0.65), in: Capsule()) + .animation(.snappy(duration: 0.25), value: arModel.score) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Puntos") + .accessibilityValue("\(arModel.score)") + } + private var hintBar: some View { HStack(spacing: 10) { Image(systemName: hintIcon) From c4f2e9db44761f56eef38b47811f66456d566de7 Mon Sep 17 00:00:00 2001 From: Eddie Date: Mon, 24 Aug 2026 12:42:47 -0400 Subject: [PATCH 04/10] feat(ios): timed rounds, HUD, best score, and Gate 3 feel fixes Phase 4 of the Trophy Toss AR mini-game. Gate 3 findings: - 4.0a: larger power range. maxLaunchSpeed 4.5 -> 6.8 m/s, re-derived including the phone's downward tilt (the loft is added along world up, so aiming down eats the launch angle). The flick -> speed mapping is now shaped by a new powerCurve = 1.8 exponent, so mid flicks keep the 0.5-1 m sweet spot and only hard flicks reach the new ceiling. fastFlick 2400 -> 2200 pt/s so the ceiling is actually reachable. - 4.0b: balls can no longer rest on the cup rim and be silently culled. The cup wall now flares 15 degrees outward (a shallow cone) with a slippery rim, so a rim rest is physically unstable; on top of that a ball caught resting on the rim is shoved in a random direction and given its time back instead of being removed, up to 3 times. - 4.0c: placed-state hint now says 'adentro de la copa'. Rounds (FR-007, FR-008): - GameRound: pure, tick-driven state machine (idle -> running -> ended), 60 s rounds, pause reasons for limited tracking and backgrounding so paused time is never charged to the player. - BestScoreStore: UserDefaults-backed single integer, injectable suite. The game's only persistence. - HUD: Comenzar button once the podium is placed, countdown + live score during play, end-of-round summary with round score, best score, new record badge, Jugar de nuevo / Seguir practicando / Salir. Reubicar is disabled while a round runs. - Free practice before Comenzar still throws balls; those never touch a round score or its clock. --- ios/IPP/Game/BestScoreStore.swift | 52 ++++++ ios/IPP/Game/GameRound.swift | 215 ++++++++++++++++++++++ ios/IPP/Game/PodiumARViewContainer.swift | 224 ++++++++++++++++++++--- ios/IPP/Game/PodiumBuilder.swift | 96 ++++++++-- ios/IPP/Game/TossController.swift | 184 ++++++++++++++++--- ios/IPP/Game/TrophyTossView.swift | 210 ++++++++++++++++++--- 6 files changed, 896 insertions(+), 85 deletions(-) create mode 100644 ios/IPP/Game/BestScoreStore.swift create mode 100644 ios/IPP/Game/GameRound.swift diff --git a/ios/IPP/Game/BestScoreStore.swift b/ios/IPP/Game/BestScoreStore.swift new file mode 100644 index 0000000..0ea8a34 --- /dev/null +++ b/ios/IPP/Game/BestScoreStore.swift @@ -0,0 +1,52 @@ +import Foundation + +/// The one thing "Tiro al Trofeo" remembers between launches: a single integer +/// best score (FR-008, US2). +/// +/// That is the whole persistence story for the game — no round history, no +/// player profile, nothing that leaves the device, and nothing that touches the +/// app's own data or the leaderboard. The spec is explicit that a device-local +/// best score is the *only* persistence allowed, so this type is deliberately +/// small enough to audit at a glance. +/// +/// `UserDefaults` is injectable so tests can run against their own suite +/// instead of the app's, and so two instances can be pointed at the same suite +/// to prove the value really survives (which is the part that matters — the +/// store holds no cached copy, every read goes to disk). +struct BestScoreStore { + + /// Key under which the best score lives. Namespaced so it cannot collide + /// with anything the rest of the app stores. + static let defaultKey = "com.nonturing.ipp.trophyToss.bestScore" + + private let defaults: UserDefaults + private let key: String + + init(defaults: UserDefaults = .standard, key: String = BestScoreStore.defaultKey) { + self.defaults = defaults + self.key = key + } + + /// The stored best, or 0 when nothing has been stored yet — `integer(forKey:)` + /// already returns 0 for a missing key, and the `max` guards against a value + /// some other writer left negative. + var best: Int { max(defaults.integer(forKey: key), 0) } + + /// Records the score of a finished round. + /// + /// - Returns: `true` only when this round beat the stored best, which is + /// also the cue the summary uses to say "¡Nuevo récord!". Equal scores do + /// not count as a new record, and a zero-point round never writes at all. + @discardableResult + func submit(_ score: Int) -> Bool { + guard score > 0, score > best else { return false } + defaults.set(score, forKey: key) + return true + } + + /// Forgets the best score. Not reachable from the UI; it exists so tests + /// (and a future debug menu) can start from a clean slate. + func clear() { + defaults.removeObject(forKey: key) + } +} diff --git a/ios/IPP/Game/GameRound.swift b/ios/IPP/Game/GameRound.swift new file mode 100644 index 0000000..bdd7e07 --- /dev/null +++ b/ios/IPP/Game/GameRound.swift @@ -0,0 +1,215 @@ +import Foundation + +/// One timed round of "Tiro al Trofeo" (FR-007), as a value type with no view, +/// no timer object and no ARKit anywhere in sight. +/// +/// The round is **tick-driven**: something outside — in the app, the RealityKit +/// frame loop — hands it the elapsed time and it decides when the round is over. +/// Nothing here reads a clock, which is what makes every rule below testable in +/// the Simulator, where ARKit does not exist. +/// +/// ``` +/// idle ──start()──▶ running(remaining) ──tick() to 0──▶ ended(score) +/// ▲ │ │ +/// └───────reset()────────┴────────────reset()─────────────┘ +/// ``` +/// +/// # Pausing +/// +/// Two things can stop the clock, and they are tracked separately rather than +/// as one flag: AR tracking going bad, and the app leaving the foreground. A +/// player who covers the camera *and* takes a call must get both back before +/// play resumes, which a single boolean would get wrong. While any reason is +/// present, ``tick(_:)`` consumes no time and the round accepts nothing — so +/// paused seconds are never charged to the player (edge cases "tracking loss +/// mid-round" and "backgrounding mid-round"). +/// +/// # Free practice vs the round +/// +/// FR-007 says flicks are accepted only while a round runs. Taken literally +/// that also freezes the screen the player sees *before* they ever press +/// "Comenzar", which is where Phase 3's playable sandbox lived and where SC-001 +/// (first flick within 30 s of opening the game) is actually satisfied. The +/// rule implemented here keeps both: **balls may always be thrown in `idle`, +/// but only a running round counts them**. ``acceptsFlicks`` is the gesture +/// gate, ``countsScores`` is the scoring gate, and they differ exactly in +/// `idle`. Nothing thrown outside a round can touch a round's score or its +/// clock, which is the part FR-007 is protecting. +struct GameRound: Equatable { + + // MARK: - Rules + + /// The knobs, in one place, the way `TossController.Tuning` does it. + struct Rules: Equatable { + /// Length of a round in seconds. The spec allows 30–60 s (US2); 60 s is + /// long enough to recover from a bad start and matches SC-006's + /// "60-second round of continuous play". + var duration: TimeInterval = 60 + + init() {} + } + + // MARK: - State + + enum State: Equatable { + case idle + /// A round is under way; `remaining` counts down to zero. + case running(remaining: TimeInterval) + /// The clock ran out. Carries the final score so the summary cannot + /// drift from what the round actually recorded. + case ended(score: Int) + } + + /// Why the clock is stopped. A set, not a flag: reasons come from + /// independent sources and each must clear itself. + enum PauseReason: String, Hashable, CaseIterable { + /// ARKit reported limited tracking, or the session was interrupted. + case trackingLimited + /// The app is not the active scene. + case backgrounded + } + + var rules: Rules + private(set) var state: State = .idle + /// Points scored so far in the round in progress. Reset by ``start()``. + private(set) var score: Int = 0 + private(set) var pauseReasons: Set = [] + + init(rules: Rules = Rules()) { + self.rules = rules + } + + // MARK: - Queries + + var isIdle: Bool { state == .idle } + + var isRunning: Bool { + if case .running = state { return true } + return false + } + + var hasEnded: Bool { + if case .ended = state { return true } + return false + } + + /// A round is on the clock but the clock is stopped. + var isPaused: Bool { isRunning && !pauseReasons.isEmpty } + + /// A round is on the clock and the clock is moving. + var isTicking: Bool { isRunning && pauseReasons.isEmpty } + + /// Seconds left in the round in progress; zero when no round is running. + var remaining: TimeInterval { + if case .running(let remaining) = state { return remaining } + return 0 + } + + /// Final score of the round that just finished, or `nil` if none has. + var finalScore: Int? { + if case .ended(let score) = state { return score } + return nil + } + + /// May the player throw a ball right now? + /// + /// Yes in `idle` (free practice, see the type's doc comment) and while a + /// round is actually ticking. No while paused — gameplay stops with the + /// clock — and no while the summary is up, so a stray swipe over the + /// end-of-round card cannot fire a ball behind it. + var acceptsFlicks: Bool { + switch state { + case .idle: return true + case .running: return pauseReasons.isEmpty + case .ended: return false + } + } + + /// Does a ball landing in the cup add to a round's score right now? + /// Only while a round is ticking (FR-007). + var countsScores: Bool { isTicking } + + /// May the player press "Comenzar"? + var canStart: Bool { !isRunning } + + // MARK: - Transitions + + /// Begins a round, from `idle` or from a finished one ("Jugar de nuevo"). + /// + /// Existing pause reasons are deliberately **kept**: they are owned by the + /// outside world (tracking state, scene phase) and clearing them here would + /// leave the round believing it can tick while the camera is still covered. + /// A round started under a pause simply begins paused and starts counting + /// when the cause clears. + /// + /// - Returns: `true` if a round actually started. + @discardableResult + mutating func start() -> Bool { + guard canStart else { return false } + score = 0 + state = .running(remaining: rules.duration) + return true + } + + /// Adds or clears one pause reason. Idempotent, so callers can fire it on + /// every tracking-state change without bookkeeping of their own. + mutating func setPaused(_ paused: Bool, reason: PauseReason) { + if paused { + pauseReasons.insert(reason) + } else { + pauseReasons.remove(reason) + } + } + + /// Advances the clock. + /// + /// A no-op unless a round is running with no pause reason, so paused and + /// backgrounded time is never charged to the player. + /// + /// - Returns: `true` on the single tick that ends the round. + @discardableResult + mutating func tick(_ delta: TimeInterval) -> Bool { + guard case .running(let remaining) = state, + pauseReasons.isEmpty, + delta > 0 + else { return false } + + let left = remaining - delta + guard left > 0 else { + state = .ended(score: score) + return true + } + state = .running(remaining: left) + return false + } + + /// Credits a ball that landed in the cup. + /// + /// - Returns: `true` if the point went to a round. `false` means the throw + /// was free practice (or landed while paused), and the caller should tally + /// it somewhere that is not a round score. + @discardableResult + mutating func registerScore() -> Bool { + guard countsScores else { return false } + score += 1 + return true + } + + /// Back to `idle` — dismissing the summary, or relocating the podium. + mutating func reset() { + state = .idle + score = 0 + } + + // MARK: - Presentation helpers + + /// The countdown as `m:ss`, rounded **up** so the HUD shows "1:00" for a + /// round that has only just begun and only reaches "0:00" when time is + /// genuinely gone. + static func countdownText(_ remaining: TimeInterval) -> String { + let seconds = max(0, Int(remaining.rounded(.up))) + return String(format: "%d:%02d", seconds / 60, seconds % 60) + } + + var countdownText: String { Self.countdownText(remaining) } +} diff --git a/ios/IPP/Game/PodiumARViewContainer.swift b/ios/IPP/Game/PodiumARViewContainer.swift index a68bd7e..3186ec7 100644 --- a/ios/IPP/Game/PodiumARViewContainer.swift +++ b/ios/IPP/Game/PodiumARViewContainer.swift @@ -7,9 +7,13 @@ import UIKit /// Shared state between the RealityKit `ARView` and the SwiftUI overlay drawn /// on top of it. /// -/// The view reads the published values to decide what hint and which buttons to -/// show; `relocate()` is the one command that travels the other way, into the -/// coordinator that owns the AR session. +/// The view reads the published values to decide what hint, HUD and buttons to +/// show and calls the round controls; `relocate()` is the one command that +/// travels the other way, into the coordinator that owns the AR session. +/// +/// The round itself is a `GameRound` value — every rule about time, pausing and +/// what counts lives there, tested off-device; this class only decides *when* +/// to poke it and republishes the result for SwiftUI (FR-007). @MainActor final class PodiumARModel: ObservableObject { @@ -32,17 +36,49 @@ final class PodiumARModel: ObservableObject { @Published fileprivate(set) var failure: String? /// Short-lived feedback, e.g. a tap that hit no surface. @Published fileprivate(set) var transientHint: String? - /// Balls landed in the cup since the game screen opened. Phase 4 replaces - /// this free-play counter with a per-round score (FR-007); for now it is the - /// whole HUD. - @Published fileprivate(set) var score: Int = 0 + + // MARK: Round state (FR-007, FR-008) + + /// The timed round. `private(set)` because every mutation has to go through + /// one of the methods below, which keep the persisted best score in step. + @Published private(set) var round = GameRound() + /// Balls landed in the cup outside a round. Free practice, reset whenever + /// the game returns to `idle` — never mixed into a round's score. + @Published private(set) var practiceScore: Int = 0 + /// Best round ever played on this device (FR-008). Read once at init and + /// kept in step by ``finishRound()``. + @Published private(set) var bestScore: Int + /// Whether the round that just ended set a new best, so the summary can say + /// so. Meaningless unless `round.hasEnded`. + @Published private(set) var didSetRecord = false + + /// The game's only persistence. + private let bestScores: BestScoreStore /// Installed by the coordinator so the overlay's "Reubicar" button can /// reach the AR session. fileprivate var relocateHandler: (() -> Void)? + init(bestScores: BestScoreStore = BestScoreStore(), rules: GameRound.Rules = GameRound.Rules()) { + self.bestScores = bestScores + self.bestScore = bestScores.best + self.round = GameRound(rules: rules) + } + var isPlaced: Bool { phase == .placed } + /// What the score pill shows: the round's score once a round is under way + /// or finished, the free-practice tally before that. + var displayedScore: Int { round.isIdle ? practiceScore : round.score } + + /// "Comenzar" is only offered once there is something to throw at. + var canStartRound: Bool { isPlaced && round.canStart } + + /// Relocating mid-round would move the target out from under a running + /// clock, so the button is off while a round is on (anticipated by the + /// Phase 2 task list). + var canRelocate: Bool { isPlaced && !round.isRunning } + /// The single line of Spanish shown at the bottom of the AR view. var hint: String { if let failure { return failure } @@ -54,18 +90,66 @@ final class PodiumARModel: ObservableObject { case .readyToPlace: return "Toca la superficie para colocar el podio." case .placed: - return "Desliza hacia arriba para lanzar la pelota a la copa." + return "Desliza hacia arriba para lanzar la pelota adentro de la copa." } } + // MARK: Round controls + + /// "Comenzar" / "Jugar de nuevo". + func startRound() { + guard canStartRound else { return } + didSetRecord = false + practiceScore = 0 + round.start() + } + + /// Dismissing the summary: back to free practice with a clean slate. + func returnToPractice() { + round.reset() + practiceScore = 0 + didSetRecord = false + } + + /// Stops or restarts the round clock. Called from the tracking-state + /// delegate and from the view's `scenePhase` observer; both fire + /// unconditionally, and `GameRound` makes that idempotent. + func setPaused(_ paused: Bool, reason: GameRound.PauseReason) { + round.setPaused(paused, reason: reason) + } + /// Drops the placed podium and goes back to scanning so the player can pick - /// a new spot. Phase 4 will additionally forbid this mid-round. + /// a new spot. Refused while a round is running. func relocate() { + guard canRelocate else { return } + returnToPractice() relocateHandler?() } - fileprivate func registerScore() { - score += 1 + /// One frame of round time, driven by the RealityKit update loop. + fileprivate func tick(_ delta: TimeInterval) { + if round.tick(delta) { finishRound() } + } + + /// The clock hit zero: freeze the score and update the stored best. + private func finishRound() { + guard let score = round.finalScore else { return } + didSetRecord = bestScores.submit(score) + bestScore = bestScores.best + } + + /// A ball landed in the cup. It counts for the round if one is running, and + /// otherwise only for the free-practice tally (see `GameRound`). + /// + /// - Returns: `false` when the point counted for nothing — a ball already + /// in flight when the round paused — so the caller can skip the fanfare + /// for a point the player did not get. + @discardableResult + fileprivate func registerScore() -> Bool { + if round.registerScore() { return true } + guard round.isIdle else { return false } + practiceScore += 1 + return true } fileprivate func flash(_ message: String) { @@ -84,8 +168,9 @@ final class PodiumARModel: ObservableObject { /// swipe-to-throw for the balls, including cup scoring and ball culling /// (FR-004, FR-005, FR-006). /// -/// The rules of the throw live in `TossController`, which knows nothing about -/// ARKit; this file supplies the camera pose, the entities and the frame clock. +/// The rules of the throw live in `TossController` and the rules of a round in +/// `GameRound`, neither of which knows anything about ARKit; this file supplies +/// the camera pose, the entities and the frame clock they run on. /// /// The session is torn down completely when SwiftUI removes the view — paused, /// un-delegated, anchors and subscriptions dropped — so closing the game leaves @@ -165,13 +250,20 @@ struct PodiumARViewContainer: UIViewRepresentable { private var trophyRestTransform: Transform? private let successHaptics = UINotificationFeedbackGenerator() - /// One ball in flight: the entity plus the two timers the culling rules - /// in `TossController` are written against (FR-006). + /// The cup, cached at placement so the rim rule can measure a ball's + /// position in the cup's own frame without walking the hierarchy every + /// frame. + private weak var cupEntity: Entity? + + /// One ball in flight: the entity plus the timers the culling rules in + /// `TossController` are written against (FR-006), and how many times it + /// has been shoved off the cup's rim (Gate 3 row 3.2). private struct LiveBall { let id: TossController.BallID let entity: ModelEntity var age: TimeInterval = 0 var restingFor: TimeInterval = 0 + var rimNudges: Int = 0 } init(model: PodiumARModel) { @@ -226,7 +318,7 @@ struct PodiumARViewContainer: UIViewRepresentable { subscriptions.append( arView.scene.subscribe(to: SceneEvents.Update.self) { [weak self] event in - MainActor.assumeIsolated { self?.stepBalls(deltaTime: event.deltaTime) } + MainActor.assumeIsolated { self?.step(deltaTime: event.deltaTime) } } ) @@ -289,7 +381,15 @@ struct PodiumARViewContainer: UIViewRepresentable { isPausedForBackground = false // No options: existing anchors survive, tracking relocalises. arView?.session.run(makeConfiguration()) - model.trackingIssue = nil + applyTrackingIssue(nil) + } + + /// The one place tracking trouble is recorded: it drives both the hint + /// line and the round clock, and those two must never disagree + /// (FR-007, edge case "tracking loss mid-round"). + private func applyTrackingIssue(_ issue: String?) { + model.trackingIssue = issue + model.setPaused(issue != nil, reason: .trackingLimited) } // MARK: Placement @@ -332,6 +432,7 @@ struct PodiumARViewContainer: UIViewRepresentable { model.transientHint = nil trophyRestTransform = scene.findEntity(named: PodiumBuilder.Name.trophy)?.transform + cupEntity = scene.findEntity(named: PodiumBuilder.Name.cup) subscribeToCup(in: arView, anchor: anchor) // Warms the Taptic Engine so the first score's haptic is immediate. successHaptics.prepare() @@ -376,6 +477,7 @@ struct PodiumARViewContainer: UIViewRepresentable { toss.retireAll() cupSubscription = nil trophyRestTransform = nil + cupEntity = nil arView.scene.removeAnchor(anchor) podiumAnchor = nil model.phase = hasSeenPlane ? .readyToPlace : .scanning @@ -393,6 +495,13 @@ struct PodiumARViewContainer: UIViewRepresentable { @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { guard !isTornDown, let arView, podiumAnchor != nil else { return } + // FR-007: no throwing while the round is paused or the summary is + // up. Free practice before "Comenzar" is still allowed — see + // `GameRound`'s doc comment for why. + guard model.round.acceptsFlicks else { + swipeStart = nil + return + } switch gesture.state { case .began: @@ -470,8 +579,10 @@ struct PodiumARViewContainer: UIViewRepresentable { // False unless this is the ball's *first* crossing, so a ball that // settles, rolls and re-triggers still scores exactly once. guard toss.score(ball.id) else { return } + // False when the point counted for nobody — the round is paused — + // in which case there is nothing to celebrate. + guard model.registerScore() else { return } - model.registerScore() celebrate() } @@ -498,12 +609,24 @@ struct PodiumARViewContainer: UIViewRepresentable { // MARK: - Culling (FR-006, SC-006) + /// One frame of the game: the round clock, then the balls. + private func step(deltaTime: TimeInterval) { + guard !isTornDown else { return } + model.tick(deltaTime) + stepBalls(deltaTime: deltaTime) + } + /// Runs once per rendered frame: ages every live ball, tracks how long /// it has been still, and removes it once `TossController` says it is /// litter — at rest, off the table, or simply too old. This is what /// keeps a minute of spam-flicking bounded (SC-006). + /// + /// The one exception is a ball balanced on the cup's rim: culling it + /// there is what Gate 3 row 3.2 saw as balls "disappearing", so instead + /// it gets shoved (``nudgeOffRim(_:)``) and given its time back, up to + /// `Tuning.maximumRimNudges` times, until it visibly drops in or out. private func stepBalls(deltaTime: TimeInterval) { - guard !isTornDown, !balls.isEmpty, let anchor = podiumAnchor else { return } + guard !balls.isEmpty, let anchor = podiumAnchor else { return } var survivors: [LiveBall] = [] survivors.reserveCapacity(balls.count) @@ -518,21 +641,67 @@ struct PodiumARViewContainer: UIViewRepresentable { ) let height = ball.entity.position(relativeTo: anchor).y - if toss.cullReason( + let reason = toss.cullReason( age: ball.age, restingFor: ball.restingFor, heightAboveAnchor: height - ) != nil { - ball.entity.removeFromParent() - toss.retire(ball.id) - } else { + ) + + guard let reason else { + survivors.append(ball) + continue + } + + if reason != .outOfBounds, + toss.mayNudgeOffRim(nudgesSoFar: ball.rimNudges), + isPerchedOnRim(ball.entity) { + nudgeOffRim(ball.entity) + ball.rimNudges += 1 + ball.restingFor = 0 + ball.age = max(0, ball.age - toss.tuning.rimNudgeGrace) survivors.append(ball) + continue } + + ball.entity.removeFromParent() + toss.retire(ball.id) } balls = survivors } + // MARK: - Rim rescue (Gate 3 row 3.2) + + /// Is this ball balanced on the cup's rim right now? Measured in the + /// cup's own frame, where `TossController`'s rule is written. + private func isPerchedOnRim(_ ball: ModelEntity) -> Bool { + guard let cup = cupEntity else { return false } + let local = ball.position(relativeTo: cup) + let contact = TossController.RimContact( + heightAboveRim: local.y - PodiumBuilder.Metrics.cupRimHeight, + radialDistance: simd_length(SIMD2(local.x, local.z)) + ) + return toss.isPerchedOnRim( + contact, + ballRadius: toss.tuning.ballRadius, + cupOuterRadius: PodiumBuilder.Metrics.cupRimOuterRadius + ) + } + + /// Tips a perched ball off the rim in a random direction, so it falls + /// in or out instead of sitting there until the culler deletes it. + /// + /// The velocity is written straight into `PhysicsMotionComponent` + /// rather than applied as an impulse: a ball that has been still for a + /// second may have been put to sleep by the solver, and setting the + /// motion component is the reliable way to get it moving again. + private func nudgeOffRim(_ ball: ModelEntity) { + let azimuth = Float.random(in: 0..<(2 * .pi)) + var motion = ball.components[PhysicsMotionComponent.self] ?? PhysicsMotionComponent() + motion.linearVelocity += toss.rimNudgeVelocity(azimuth: azimuth) + ball.components.set(motion) + } + // MARK: ARSessionDelegate / ARCoachingOverlayViewDelegate // // The delegate methods themselves are `nonisolated` — ARKit's protocols @@ -548,12 +717,12 @@ struct PodiumARViewContainer: UIViewRepresentable { nonisolated func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) { let issue = Self.message(for: camera.trackingState) - Task { @MainActor in self.model.trackingIssue = issue } + Task { @MainActor in self.applyTrackingIssue(issue) } } nonisolated func sessionWasInterrupted(_ session: ARSession) { Task { @MainActor in - self.model.trackingIssue = "Sesión en pausa. Vuelve a apuntar a la superficie." + self.applyTrackingIssue("Sesión en pausa. Vuelve a apuntar a la superficie.") } } @@ -561,7 +730,7 @@ struct PodiumARViewContainer: UIViewRepresentable { // Deliberately no `run(_:options:)` here: ARKit resumes on its own // and relocalises to the existing anchor. Resetting would move the // podium, which the spec forbids. - Task { @MainActor in self.model.trackingIssue = nil } + Task { @MainActor in self.applyTrackingIssue(nil) } } nonisolated func session(_ session: ARSession, didFailWithError error: Error) { @@ -635,6 +804,7 @@ struct PodiumARViewContainer: UIViewRepresentable { balls.removeAll() toss.retireAll() trophyRestTransform = nil + cupEntity = nil swipeStart = nil coachingOverlay.delegate = nil diff --git a/ios/IPP/Game/PodiumBuilder.swift b/ios/IPP/Game/PodiumBuilder.swift index 522374e..226c6df 100644 --- a/ios/IPP/Game/PodiumBuilder.swift +++ b/ios/IPP/Game/PodiumBuilder.swift @@ -73,15 +73,54 @@ enum PodiumBuilder { static let cupFloorThickness: Float = 0.006 /// Number of box segments approximating the cup's cylindrical wall. static let cupWallSegments = 12 + /// How far each wall segment leans **outward**, in radians (Gate 3 row + /// 3.2). The cup is therefore a shallow cone rather than a tube: its + /// mouth is wider than its floor, the inner face funnels a ball down + /// into the cup, and — the point of the change — the rim has no level + /// surface anywhere on it for a ball to balance on. + /// + /// 15° against the rim's friction of ``cupRimFriction`` (0.18, whose + /// friction angle is ≈ 10°) means a ball landing on the rim always + /// slides off instead of settling there and being silently culled. + static let cupWallFlare: Float = 15 * .pi / 180 + /// Friction of the rim and inner wall. Deliberately slippery, so the + /// flare above can do its job. + static let cupRimFriction: Float = 0.18 + static let cupRimRestitution: Float = 0.18 /// Invisible collision plane standing in for the real table or floor, /// so missed balls bounce on the surface instead of falling forever. static let floorExtent: Float = 3.0 static let floorThickness: Float = 0.02 + // Derived cup geometry. The game's rim rule (`TossController`) is + // written against these, so the numbers exist once. + + /// Radius of the circle the wall segments' centres sit on. + static var cupRingRadius: Float { cupInnerRadius + cupWallThickness / 2 } + /// That radius at the mouth, once the segments lean out. + static var cupRimRingRadius: Float { + cupRingRadius + (cupWallHeight / 2) * sin(cupWallFlare) + } + /// Outermost horizontal reach of the rim — the "is this ball anywhere + /// near the cup" radius. + static var cupRimOuterRadius: Float { cupRimRingRadius + cupWallThickness } + /// Height of the top of the wall above the cup entity's own origin + /// (which is the underside of the cup's floor disc). + static var cupRimHeight: Float { + cupFloorThickness + cupWallHeight / 2 + (cupWallHeight / 2) * cos(cupWallFlare) + } + + /// Inner radius of the flared wall at `height` above the cup's origin — + /// smallest at the floor, widest at the mouth. + static func cupInnerRadius(atHeight height: Float) -> Float { + let wallMidHeight = cupFloorThickness + cupWallHeight / 2 + return cupInnerRadius + (height - wallMidHeight) * tan(cupWallFlare) + } + /// Height of the whole trophy above the step it stands on. static var trophyHeight: Float { - trophyBaseHeight + trophyStemHeight + cupFloorThickness + cupWallHeight + trophyBaseHeight + trophyStemHeight + cupRimHeight } } @@ -266,11 +305,18 @@ enum PodiumBuilder { // Wall: `cupWallSegments` thin boxes on a circle, forming a polygonal // ring that reads as a cylinder and collides like a container. + // + // Each segment also leans outward by `cupWallFlare`, which turns the + // tube into a shallow cone (Gate 3 row 3.2). Two consequences, both + // wanted: the inner face now funnels a ball toward the cup floor, and + // the top face is a slope rather than a ledge, so a ball can no longer + // come to rest on the rim and be culled out of existence. let segments = Metrics.cupWallSegments - let ringRadius = Metrics.cupInnerRadius + Metrics.cupWallThickness / 2 - // Chord length of one segment, plus a hair of overlap so the ring has - // no gaps between neighbours. - let segmentWidth = 2 * ringRadius * sin(.pi / Float(segments)) * 1.08 + let ringRadius = Metrics.cupRingRadius + // Chord length of one segment, measured at the **mouth**, where the + // flare has pushed the ring out furthest — sizing it at the mid radius + // would open gaps between neighbours at the top. Plus a hair of overlap. + let segmentWidth = 2 * Metrics.cupRimRingRadius * sin(.pi / Float(segments)) * 1.08 let wallY = Metrics.cupFloorThickness + Metrics.cupWallHeight / 2 for index in 0.. Entity { // Shorter than the wall so a ball perched on the rim does not count, - // and narrower so the sensor stays clear of the wall segments. + // and narrower so the sensor stays clear of the wall segments — which + // now lean *inward* at their base, so the clearance is measured there. let height = Metrics.cupWallHeight * 0.75 - let side = (Metrics.cupInnerRadius - Metrics.cupWallThickness) * 1.4 + let side = (Metrics.cupInnerRadius - Metrics.cupWallThickness) * 1.25 let trigger = Entity() trigger.name = Name.cupTrigger trigger.position = [0, Metrics.cupFloorThickness + height / 2, 0] @@ -357,9 +408,10 @@ enum PodiumBuilder { /// is where Gate 3's feel feedback gets applied — this function only turns /// those numbers into an entity. /// - /// Continuous collision detection is on: at 4.5 m/s a 3.5 cm ball moves - /// ~7.5 cm per 60 Hz step, further than the cup's 6 mm walls are thick, so - /// discrete stepping would let a hard throw tunnel straight through the cup. + /// Continuous collision detection is on: at the 6.8 m/s ceiling a 3.5 cm + /// ball moves ~11 cm per 60 Hz step, far further than the cup's 6 mm walls + /// are thick, so discrete stepping would let a hard throw tunnel straight + /// through the cup. (It mattered at Phase 3's 4.5 m/s; it matters more now.) static func makeBall( id: UInt64, radius: Float, @@ -598,6 +650,26 @@ extension PodiumBuilder { } } + // 4b. Cup geometry after the Gate 3 rim fix: the wall must flare + // outward, still admit the ball at the bottom, and keep the scoring + // trigger strictly below the rim so a perched ball cannot score. + let ballRadius = TossController.Tuning().ballRadius + let mouthRadius = Metrics.cupInnerRadius(atHeight: Metrics.cupRimHeight) + let baseRadius = Metrics.cupInnerRadius(atHeight: Metrics.cupFloorThickness) + if mouthRadius <= baseRadius { + problems.append("the cup narrows toward its mouth — the rim flare is inverted") + } + let restingHeight = Metrics.cupFloorThickness + ballRadius + if Metrics.cupInnerRadius(atHeight: restingHeight) <= ballRadius { + problems.append("the flared wall is too tight for a ball to reach the cup floor") + } + if let trigger = scene.findEntity(named: Name.cupTrigger) { + let triggerTop = trigger.position.y + Metrics.cupWallHeight * 0.75 / 2 + if triggerTop >= Metrics.cupRimHeight { + problems.append("the scoring trigger reaches the rim — a perched ball could score") + } + } + // 5. Floor plane: invisible, static, top face at the anchor height. if let floor = requireEntity(Name.floor) { requireCollision(floor, Name.floor) diff --git a/ios/IPP/Game/TossController.swift b/ios/IPP/Game/TossController.swift index 0af4fb4..9a37c5b 100644 --- a/ios/IPP/Game/TossController.swift +++ b/ios/IPP/Game/TossController.swift @@ -18,29 +18,65 @@ import simd /// upward flick has a *negative* `translation.y`. /// - **World space** is RealityKit's: metres, `+y` up. /// -/// # Tuning (Gate 3) +/// # Tuning (Gate 3 → Phase 4) /// /// Every number the game's feel depends on is a stored property of `Tuning`, so -/// the owner's Gate 3 feedback ("too weak", "too floaty", "curves too much") -/// turns into a one-line edit of ``Tuning/init()``'s defaults rather than a hunt -/// through the AR code. +/// the owner's feedback ("too weak", "too floaty", "curves too much") turns into +/// a one-line edit of ``Tuning/init()``'s defaults rather than a hunt through +/// the AR code. /// -/// The launch-speed range is picked from the actual geometry rather than by -/// eye. The podium is ~30 cm wide and the cup mouth sits ~0.17 m above the -/// surface it stands on; a player holds the phone ~0.35 m above that surface and -/// stands 0.5–1.0 m away. Firing at ``Tuning/arc`` = 0.45 world-up per unit of -/// aim (≈ 24° above where the phone points) and solving the projectile equations -/// for those distances under RealityKit's 9.81 m/s² gravity gives: +/// ## Where the launch-speed range comes from /// -/// | Distance to the cup | Speed that lands in it | -/// |---|---| -/// | 0.5 m | ≈ 1.9 m/s | -/// | 0.7 m | ≈ 2.4 m/s | -/// | 1.0 m | ≈ 3.1 m/s | +/// Not from eye-balling. The scene fixes the geometry: the cup's floor sits +/// 0.166 m and its mouth 0.226 m above the surface the podium stands on, so a +/// ball has to arrive at ≈ 0.20 m. A player holds the phone ≈ 0.35 m above that +/// surface and the ball leaves ``Tuning/spawnDownOffset`` below the camera, so +/// it starts at ≈ 0.31 m — i.e. it has to **drop** ≈ 0.11 m over the throw. /// -/// So the flick maps onto **1.6 … 4.5 m/s**: the band brackets that 1.9–3.1 -/// sweet spot with room on both sides, which is what makes it a game — a limp -/// flick drops short, a hard one sails over the podium. +/// Every throw leaves along `normalize(aim + worldUp · arc + side · lateral)`. +/// With ``Tuning/arc`` = 0.45 that is 24.2° above the aim **when the phone is +/// level** — but the player is looking down at a podium on a table, and the loft +/// is added along *world* up, so a downward tilt eats straight into the launch +/// angle. That is the part the first (4.5 m/s) ceiling missed, and it is why +/// Gate 3 row 3.1 reported having to walk the camera closer. +/// +/// Solving `x = v·cosθ·t`, `Δy = v·sinθ·t − ½gt²` at g = 9.81 m/s²: +/// +/// | Distance | Phone aimed at the cup | Phone tilted 20° down | +/// |---|---|---| +/// | 0.5 m | 2.4 m/s (θ ≈ 13.5°) | 2.7 m/s (θ ≈ 6.6°) | +/// | 0.7 m | 2.9 m/s | 3.6 m/s | +/// | 1.0 m | 3.5 m/s | 4.7 m/s | +/// | 1.5 m | 4.3 m/s | 6.3 m/s | +/// | 2.0 m | 5.0 m/s | 7.7 m/s | +/// +/// So the old 4.5 m/s ceiling topped out at ~1.7 m with a flat aim and **0.9 m** +/// with a 20° tilt — hence "move closer". ``Tuning/maxLaunchSpeed`` is now +/// **6.8 m/s**, which reaches ~2.8 m aimed flat and ~1.7 m at a steep tilt: a +/// hard flick clears the ~2 m the owner asked for without walking, and the +/// 7 m/s-ish worst case is only out of reach if the player insists on staring at +/// their own feet. +/// +/// ## Why the mapping is a curve, not a line +/// +/// Raising the ceiling with the old straight line would have dragged every mid +/// flick up with it (a 1200 pt/s flick would jump 2.8 → 3.8 m/s and sail over a +/// cup 0.7 m away). Instead the flick fraction is raised to +/// ``Tuning/powerCurve`` = 1.8 before it is mixed, which keeps the low and +/// middle of the range where Gate 3 said it already felt right and spends all +/// the new headroom on the hardest flicks: +/// +/// | Upward flick | Launch speed | Lands about | +/// |---|---|---| +/// | 600 pt/s (lazy) | 1.8 m/s | short of 0.4 m | +/// | 1000 pt/s | 2.5 m/s | ≈ 0.55 m | +/// | 1200 pt/s | 2.9 m/s | ≈ 0.75 m | +/// | 1400 pt/s | 3.5 m/s | ≈ 1.0 m | +/// | 1800 pt/s | 4.9 m/s | ≈ 1.8 m | +/// | 2200 pt/s (hard) | 6.8 m/s | ≈ 2.8 m | +/// +/// ``Tuning/fastFlick`` also came down 2400 → 2200 pt/s so the ceiling is +/// actually reachable by a thumb rather than being a number in a file. struct TossController { // MARK: - Tuning @@ -64,14 +100,24 @@ struct TossController { /// Speed of the weakest launch, in m/s. Undershoots from ~0.5 m. var minLaunchSpeed: Float = 1.6 - /// Speed of the hardest launch, in m/s. Overshoots from ~1.0 m. - var maxLaunchSpeed: Float = 4.5 + /// Speed of the hardest launch, in m/s. Reaches ~2.8 m with the phone + /// aimed flat, ~1.7 m with it tilted well down (Gate 3 row 3.1: the + /// previous 4.5 m/s made the player walk closer). + var maxLaunchSpeed: Float = 6.8 /// Upward flick speed (points/second) that still maps to /// ``minLaunchSpeed`` — a slow drag. var slowFlick: Float = 350 /// Upward flick speed (points/second) that reaches ``maxLaunchSpeed``. - /// A brisk thumb flick covers ~250 pt in ~0.10 s. - var fastFlick: Float = 2400 + /// A hard thumb flick covers ~250 pt in ~0.11 s. + var fastFlick: Float = 2200 + /// Shape of the flick → speed curve: the normalised flick fraction is + /// raised to this power before it is mixed between the two speeds. + /// + /// `1` is the straight line Phase 3 used. Above 1 the curve sags, so the + /// gentle and middling flicks keep the speeds they had before the + /// ceiling was raised and only the hardest flicks reach the new top end + /// — which is the whole point of 4.0a: more reach, same sweet spot. + var powerCurve: Float = 1.8 /// Floor on the measured swipe duration, so a gesture the system reports /// as near-instant cannot divide its way to an absurd flick speed. var minimumSwipeDuration: TimeInterval = 0.05 @@ -119,6 +165,32 @@ struct TossController { /// table) past which a ball has clearly left the play area. var minimumHeight: Float = -0.40 + // Rim rescue (Gate 3 row 3.2) — a ball must never be balanced on the + // cup's rim when the rest-culler fires, because from the player's seat + // that reads as the ball evaporating. + + /// How far *below* the rim's top face (as a fraction of the ball's + /// radius) a resting ball's centre may be and still count as perched on + /// the rim rather than sitting inside the cup. + /// + /// A ball inside the cup rests with its centre ≈ 0.7 radii **below** the + /// rim; one balanced on the rim sits a full radius above it. 0.4 splits + /// those two cases with room to spare on both sides. + var rimGraceFraction: Float = 0.40 + /// Speed (m/s) of the destabilising shove given to a ball caught resting + /// on the rim. Big enough to topple it, small enough that it drops in or + /// falls off rather than being launched. + var rimNudgeSpeed: Float = 0.30 + /// Downward part of that shove, as a fraction of its horizontal part, so + /// the ball commits to falling instead of skating along the rim. + var rimNudgeDownwardBias: Float = 0.35 + /// How many times one ball may be nudged before it is culled anyway. + /// Bounds the worst case; in practice the first shove settles it. + var maximumRimNudges: Int = 3 + /// Seconds of life handed back to a ball each time it is nudged, so the + /// shove has time to work before the same cull rule fires again. + var rimNudgeGrace: TimeInterval = 1.0 + init() {} } @@ -246,12 +318,18 @@ struct TossController { /// and power independent: sideways travel steers (see ``lateralDeflection``) /// and never adds force, so a hard sideways swipe is a gentle, wide throw /// rather than a rocket. + /// + /// The flick fraction is shaped by ``Tuning/powerCurve`` before it is mixed, + /// so raising the ceiling for hard flicks (4.0a) did not also make every + /// ordinary flick overshoot. The function stays monotonic in flick speed for + /// any positive exponent. func launchSpeed(for swipe: Swipe) -> Float { let seconds = Float(max(swipe.duration, tuning.minimumSwipeDuration)) let flick = swipe.upwardTravel / seconds let span = max(tuning.fastFlick - tuning.slowFlick, 1) let t = min(max((flick - tuning.slowFlick) / span, 0), 1) - return tuning.minLaunchSpeed + t * (tuning.maxLaunchSpeed - tuning.minLaunchSpeed) + let shaped = tuning.powerCurve == 1 ? t : pow(t, max(tuning.powerCurve, 0.01)) + return tuning.minLaunchSpeed + shaped * (tuning.maxLaunchSpeed - tuning.minLaunchSpeed) } /// Sideways steering from the horizontal part of the swipe, as a fraction of @@ -361,6 +439,66 @@ struct TossController { return nil } + // MARK: - Rim rescue (Gate 3 row 3.2) + + /// Where a ball sits relative to the cup, reduced to the two numbers the + /// rim rule needs. Both are measured in the cup's own frame. + struct RimContact: Equatable { + /// Ball centre minus the top of the cup wall, in metres. Positive means + /// the ball is above the mouth. + var heightAboveRim: Float + /// Horizontal distance from the cup's axis, in metres. + var radialDistance: Float + + init(heightAboveRim: Float, radialDistance: Float) { + self.heightAboveRim = heightAboveRim + self.radialDistance = radialDistance + } + } + + /// Is this ball balanced on the cup's rim, rather than resting inside the + /// cup or somewhere else in the scene? + /// + /// Gate 3 row 3.2: balls occasionally came to rest on the 6 mm rim and were + /// then removed by the rest-culler, which looks to the player like the ball + /// vanishing. The geometry answers the question cleanly — a ball in the cup + /// has its centre below the rim, a ball on the rim has it a radius above — + /// so the caller can shove the perched one instead of deleting it. + func isPerchedOnRim( + _ contact: RimContact, + ballRadius: Float, + cupOuterRadius: Float + ) -> Bool { + contact.heightAboveRim > -ballRadius * tuning.rimGraceFraction + && contact.radialDistance < cupOuterRadius + ballRadius + } + + /// The shove given to a perched ball: horizontal, in the direction + /// `azimuth` (radians, measured from +X toward +Z), with a downward bias so + /// it drops rather than skates. + /// + /// The direction is a parameter rather than a random draw so the rule stays + /// pure and testable; the AR side picks the angle. It is picked at random + /// there on purpose — a ball tipped off the rim should be as free to fall + /// *in* as to fall out, which is exactly what Gate 3 row 3.2 asked for. + func rimNudgeVelocity(azimuth: Float) -> SIMD3 { + SIMD3( + cos(azimuth) * tuning.rimNudgeSpeed, + -tuning.rimNudgeSpeed * tuning.rimNudgeDownwardBias, + sin(azimuth) * tuning.rimNudgeSpeed + ) + } + + /// The same shove as an impulse (N·s). + func rimNudgeImpulse(azimuth: Float) -> SIMD3 { + rimNudgeVelocity(azimuth: azimuth) * tuning.ballMass + } + + /// Whether a ball that the culler wants to remove has any nudges left. + func mayNudgeOffRim(nudgesSoFar: Int) -> Bool { + nudgesSoFar < tuning.maximumRimNudges + } + /// Forgets a ball the caller has removed from the scene. mutating func retire(_ ball: BallID) { liveBalls.removeAll { $0 == ball } diff --git a/ios/IPP/Game/TrophyTossView.swift b/ios/IPP/Game/TrophyTossView.swift index f42f879..0c170ba 100644 --- a/ios/IPP/Game/TrophyTossView.swift +++ b/ios/IPP/Game/TrophyTossView.swift @@ -6,8 +6,9 @@ import UIKit /// /// Two faces, chosen by whether the game can actually run: /// - the **AR screen** (`gameScreen`) — a full-bleed `PodiumARViewContainer` -/// with a thin Spanish overlay: hint, Reubicar, close and the session score -/// (FR-002, FR-005, FR-009); +/// with a thin Spanish overlay: hint, Reubicar, close, the round HUD +/// (countdown + score) and the end-of-round summary +/// (FR-002, FR-005, FR-007, FR-009); /// - the **explainer screen** (`infoScreen`) — the camera-permission story. /// It asks for the camera when this view appears, the only moment the app /// ever asks (FR-010), and offers a shortcut to Ajustes when the answer is no. @@ -44,6 +45,9 @@ struct TrophyTossView: View { .onChange(of: scenePhase) { _, phase in // Returning from Ajustes: the player may have changed the answer. if phase == .active { permission = ARSupport.cameraPermission } + // A round must not burn its clock while the app is away (FR-007, + // edge case "backgrounding mid-round"). + arModel.setPaused(phase != .active, reason: .backgrounded) } } @@ -56,15 +60,34 @@ struct TrophyTossView: View { PodiumARViewContainer(model: arModel) .ignoresSafeArea() - VStack(spacing: 0) { + VStack(spacing: 10) { topBar + if arModel.isPlaced { + HStack(spacing: 0) { + Spacer(minLength: 0) + relocateButton + } + } Spacer(minLength: 0) - hintBar + // Both are hidden behind the summary card rather than dimmed + // under it — the overlay owns the screen while it is up. + if !arModel.round.hasEnded { + if arModel.isPlaced && arModel.round.isIdle { + startButton + } + hintBar + } } .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 20) + + if arModel.round.hasEnded { + summaryOverlay + .transition(.opacity.combined(with: .scale(scale: 0.96))) + } } + .animation(.snappy(duration: 0.28), value: arModel.round.hasEnded) } private var topBar: some View { @@ -84,43 +107,184 @@ struct TrophyTossView: View { Spacer(minLength: 0) - if arModel.isPlaced { - Button { - arModel.relocate() - } label: { - Label("Reubicar", systemImage: "arrow.triangle.2.circlepath") - .font(.subheadline.weight(.semibold)) - .padding(.horizontal, 14) - .frame(height: 40) - } - .buttonStyle(.plain) - .foregroundStyle(.white) - .background(Color.ippTeal.opacity(0.92), in: Capsule()) + if arModel.round.isRunning { + countdownPill + } + if arModel.isPlaced { scorePill } } } - /// The whole HUD for now: how many balls have gone in since the screen - /// opened. Phase 4 puts a countdown and a round score in its place (FR-007). + /// Time left in the round (FR-007). Turns gold under ten seconds and says + /// so out loud when the clock is stopped for tracking or backgrounding. + private var countdownPill: some View { + HStack(spacing: 7) { + Image(systemName: arModel.round.isPaused ? "pause.fill" : "timer") + .font(.subheadline.weight(.semibold)) + Text(arModel.round.countdownText) + .font(.title3.weight(.bold)) + .monospacedDigit() + } + .foregroundStyle(countdownTint) + .padding(.horizontal, 14) + .frame(height: 40) + .background(Color.ippInk.opacity(0.65), in: Capsule()) + .accessibilityElement(children: .ignore) + .accessibilityLabel(arModel.round.isPaused ? "Ronda en pausa" : "Tiempo restante") + .accessibilityValue(arModel.round.countdownText) + } + + private var countdownTint: Color { + if arModel.round.isPaused { return .ippGold } + return arModel.round.remaining <= 10 ? .ippGold : .white + } + + /// Points: the round's while one is on, the free-practice tally before that. private var scorePill: some View { HStack(spacing: 7) { Image(systemName: "trophy.fill") .font(.subheadline.weight(.semibold)) - Text("\(arModel.score)") + Text("\(arModel.displayedScore)") .font(.title3.weight(.bold)) .monospacedDigit() - .contentTransition(.numericText(value: Double(arModel.score))) + .contentTransition(.numericText(value: Double(arModel.displayedScore))) } .foregroundStyle(Color.ippGold) .padding(.horizontal, 14) .frame(height: 40) .background(Color.ippInk.opacity(0.65), in: Capsule()) - .animation(.snappy(duration: 0.25), value: arModel.score) + .animation(.snappy(duration: 0.25), value: arModel.displayedScore) .accessibilityElement(children: .ignore) - .accessibilityLabel("Puntos") - .accessibilityValue("\(arModel.score)") + .accessibilityLabel(arModel.round.isIdle ? "Puntos de práctica" : "Puntos de la ronda") + .accessibilityValue("\(arModel.displayedScore)") + } + + /// Moving the podium mid-round would pull the target out from under a + /// running clock, so the button is disabled rather than hidden — the player + /// can see it will come back. + private var relocateButton: some View { + Button { + arModel.relocate() + } label: { + Label("Reubicar", systemImage: "arrow.triangle.2.circlepath") + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 14) + .frame(height: 36) + } + .buttonStyle(.plain) + .foregroundStyle(.white) + .background(Color.ippTeal.opacity(arModel.canRelocate ? 0.92 : 0.35), in: Capsule()) + .opacity(arModel.canRelocate ? 1 : 0.55) + .disabled(!arModel.canRelocate) + } + + /// Starts a timed round (FR-007). Only appears once the podium is down — + /// before that there is nothing to aim at. + private var startButton: some View { + Button { + arModel.startRound() + } label: { + HStack(spacing: 8) { + Image(systemName: "play.fill") + Text("Comenzar") + .font(.headline) + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(LinearGradient.ippBrand, in: Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel("Comenzar una ronda de \(Int(arModel.round.rules.duration)) segundos") + } + + // MARK: - End-of-round summary (FR-007, FR-008) + + private var summaryOverlay: some View { + ZStack { + Color.black.opacity(0.55) + .ignoresSafeArea() + + VStack(spacing: 16) { + summaryCard + summaryActions + } + .padding(.horizontal, 24) + } + } + + private var summaryCard: some View { + VStack(spacing: 10) { + Text("Ronda terminada") + .font(.headline) + .foregroundStyle(.white.opacity(0.85)) + + Text("\(arModel.round.finalScore ?? 0)") + .font(.system(size: 64, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + + Text(scoreWord(arModel.round.finalScore ?? 0)) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.85)) + + if arModel.didSetRecord { + Label("¡Nuevo récord!", systemImage: "sparkles") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.ippGold) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(Color.white.opacity(0.16), in: Capsule()) + } else { + Text("Tu mejor marca: \(arModel.bestScore)") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.75)) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 26) + .padding(.horizontal, 20) + .background(LinearGradient.ippBrand) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + + private var summaryActions: some View { + VStack(spacing: 10) { + Button { + arModel.startRound() + } label: { + Text("Jugar de nuevo") + .font(.headline) + .foregroundStyle(Color.ippTealDeep) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(Color.white, in: Capsule()) + } + .buttonStyle(.plain) + + Button { + arModel.returnToPractice() + } label: { + Text("Seguir practicando") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(Color.white.opacity(0.18), in: Capsule()) + } + .buttonStyle(.plain) + + Button("Salir del juego") { dismiss() } + .font(.subheadline) + .foregroundStyle(.white.opacity(0.8)) + .padding(.top, 2) + } + } + + private func scoreWord(_ score: Int) -> String { + score == 1 ? "punto" : "puntos" } private var hintBar: some View { From dc2ecc161e676451cdf3bd2c571a34ea17483c25 Mon Sep 17 00:00:00 2001 From: Eddie Date: Mon, 24 Aug 2026 13:30:22 -0400 Subject: [PATCH 05/10] fix(ios): verified-inside scoring, two tiers, touch spawn and cup ramp Gate 4 defect + the owner's two rule changes, plus the US3 difficulty ramp. 5.0a A make is no longer a sensor contact. The cup's trigger volume is gone; a ball counts as inside only when its centre is below the rim, above the cup floor and fully within the flared wall at its own height, and stays there for 0.10 s. Contact from outside the wall puts the centre ~9 cm from the axis, where the rule allows ~2, so a front-wall hit cannot be a make. 5.0b Two tiers: touching the cup anywhere pays +1 once, landing inside pays +10 once and absorbs the hit (10 total, not 11). Light impact haptic and a small trophy pulse for a hit; the full success haptic and swell for a make. 5.0c The ball spawns under the finger: the swipe's touch-down point is projected through ARView.ray(through:) onto the usual spawn plane, clamped in depth and sideways offset. Aim and power are unchanged. US3 After a make the cup slides to a randomly chosen different step over 0.4 s, on the podium's own anchor; scoring is suspended for the move and balls left in the cup leave with it. --- ios/IPP/Game/GameRound.swift | 12 +- ios/IPP/Game/PodiumARViewContainer.swift | 367 ++++++++++++++++++----- ios/IPP/Game/PodiumBuilder.swift | 192 ++++++++---- ios/IPP/Game/TossController.swift | 332 ++++++++++++++++++-- 4 files changed, 736 insertions(+), 167 deletions(-) diff --git a/ios/IPP/Game/GameRound.swift b/ios/IPP/Game/GameRound.swift index bdd7e07..9e67c97 100644 --- a/ios/IPP/Game/GameRound.swift +++ b/ios/IPP/Game/GameRound.swift @@ -183,15 +183,19 @@ struct GameRound: Equatable { return false } - /// Credits a ball that landed in the cup. + /// Credits a ball that earned points. /// - /// - Returns: `true` if the point went to a round. `false` means the throw + /// The round does not care *which* tier earned them (FR-005: +1 for + /// touching the cup, the balance of +10 for landing in it) — that rule + /// lives in `TossController`, which hands the arithmetic down as a number. + /// + /// - Returns: `true` if the points went to a round. `false` means the throw /// was free practice (or landed while paused), and the caller should tally /// it somewhere that is not a round score. @discardableResult - mutating func registerScore() -> Bool { + mutating func registerScore(_ points: Int = 1) -> Bool { guard countsScores else { return false } - score += 1 + score += points return true } diff --git a/ios/IPP/Game/PodiumARViewContainer.swift b/ios/IPP/Game/PodiumARViewContainer.swift index 3186ec7..f5286c5 100644 --- a/ios/IPP/Game/PodiumARViewContainer.swift +++ b/ios/IPP/Game/PodiumARViewContainer.swift @@ -138,17 +138,19 @@ final class PodiumARModel: ObservableObject { bestScore = bestScores.best } - /// A ball landed in the cup. It counts for the round if one is running, and - /// otherwise only for the free-practice tally (see `GameRound`). + /// A ball earned points — `TossController.Tuning.hitPoints` for touching + /// the cup, the rest of `makePoints` for landing in it (FR-005, amended at + /// Gate 4). They count for the round if one is running, and otherwise only + /// for the free-practice tally (see `GameRound`). /// - /// - Returns: `false` when the point counted for nothing — a ball already + /// - Returns: `false` when the points counted for nothing — a ball already /// in flight when the round paused — so the caller can skip the fanfare /// for a point the player did not get. @discardableResult - fileprivate func registerScore() -> Bool { - if round.registerScore() { return true } + fileprivate func registerScore(_ points: Int) -> Bool { + if round.registerScore(points) { return true } guard round.isIdle else { return false } - practiceScore += 1 + practiceScore += points return true } @@ -227,9 +229,10 @@ struct PodiumARViewContainer: UIViewRepresentable { /// exactly it, and so tracking recovery can be checked against it. private var podiumAnchor: AnchorEntity? private var subscriptions: [any Cancellable] = [] - /// Cup-trigger subscription, held apart from the rest because it is made - /// and dropped with the podium rather than with the view. - private var cupSubscription: (any Cancellable)? + /// Collision subscription for the +1 tier, held apart from the rest + /// because it is made and dropped with the podium rather than with the + /// view. + private var contactSubscription: (any Cancellable)? private var lifecycleObservers: [NSObjectProtocol] = [] private var hasSeenPlane = false @@ -244,25 +247,45 @@ struct PodiumARViewContainer: UIViewRepresentable { private var balls: [LiveBall] = [] /// When the current swipe started, in `CACurrentMediaTime()` seconds. private var swipeStart: TimeInterval? - /// The trophy's resting transform, captured at placement so the score - /// pulse always animates back to a known pose rather than to whatever - /// mid-animation value it happens to read. - private var trophyRestTransform: Transform? private let successHaptics = UINotificationFeedbackGenerator() + /// The lighter cue for the +1 tier, so a graze off the cup does not + /// feel like a made shot (FR-005). + private let hitHaptics = UIImpactFeedbackGenerator(style: .light) - /// The cup, cached at placement so the rim rule can measure a ball's + /// The cup, cached at placement so the cup rules can measure a ball's /// position in the cup's own frame without walking the hierarchy every /// frame. private weak var cupEntity: Entity? + /// The trophy, cached at placement. The difficulty ramp animates it + /// between steps inside its own parent, the steps container (US3). + private weak var trophyEntity: Entity? + /// Which step the cup is standing on right now. + private var currentStep: PodiumBuilder.Step = .gold + /// True from the moment a make is celebrated until the cup has finished + /// sliding to its new step. Nothing scores in that window: the trophy + /// is being scaled and moved, so every measurement taken in the cup's + /// frame is in motion (US3, and it keeps the Gate 4 defect from coming + /// back through the animation). + private var isCupMoving = false + /// Every collidable part of the cup, by identity. Touching any of them + /// is the +1 tier (FR-005). + private var cupContactIDs: Set = [] + + /// How long the cup takes to slide to its new step. + private static let cupMoveDuration: TimeInterval = 0.4 + /// How long the trophy holds its celebratory swell before the move. + private static let makeSwellDuration: TimeInterval = 0.14 /// One ball in flight: the entity plus the timers the culling rules in - /// `TossController` are written against (FR-006), and how many times it - /// has been shoved off the cup's rim (Gate 3 row 3.2). + /// `TossController` are written against (FR-006), how long it has been + /// verifiably inside the cup (FR-005) and how many times it has been + /// shoved off the cup's rim (Gate 3 row 3.2). private struct LiveBall { let id: TossController.BallID let entity: ModelEntity var age: TimeInterval = 0 var restingFor: TimeInterval = 0 + var insideFor: TimeInterval = 0 var rimNudges: Int = 0 } @@ -431,11 +454,14 @@ struct PodiumARViewContainer: UIViewRepresentable { model.phase = .placed model.transientHint = nil - trophyRestTransform = scene.findEntity(named: PodiumBuilder.Name.trophy)?.transform + trophyEntity = scene.findEntity(named: PodiumBuilder.Name.trophy) cupEntity = scene.findEntity(named: PodiumBuilder.Name.cup) - subscribeToCup(in: arView, anchor: anchor) + currentStep = .gold + isCupMoving = false + subscribeToCupContacts(in: arView, anchor: anchor) // Warms the Taptic Engine so the first score's haptic is immediate. successHaptics.prepare() + hitHaptics.prepare() // From here the player is looking at the podium, so stop the // full-screen coaching overlay from covering it; our own hint takes @@ -444,16 +470,38 @@ struct PodiumARViewContainer: UIViewRepresentable { coachingOverlay.setActive(false, animated: true) } - /// Listens to the cup's invisible trigger volume, which is the only - /// thing that can turn a ball into a point (FR-005). - private func subscribeToCup(in arView: ARView, anchor: AnchorEntity) { - cupSubscription = nil - guard let trigger = anchor.findEntity(named: PodiumBuilder.Name.cupTrigger) else { return } - cupSubscription = arView.scene.subscribe( - to: CollisionEvents.Began.self, - on: trigger - ) { [weak self] event in - MainActor.assumeIsolated { self?.handleCupEntry(event) } + /// Listens for balls touching the cup — the +1 tier (FR-005). + /// + /// One subscription for the whole scene rather than thirteen (twelve + /// wall segments and the floor disc): the collidable parts of the cup + /// are collected once, by identity, and every other contact in the + /// scene — the table, the steps, ball against ball — is discarded with + /// a set lookup. + /// + /// Note what this subscription is **not** used for: landing inside the + /// cup. That is decided per frame from the ball's position, because a + /// contact cannot tell which side of a 6 mm wall the ball is on — the + /// Gate 4 defect in one sentence. + private func subscribeToCupContacts(in arView: ARView, anchor: AnchorEntity) { + contactSubscription = nil + cupContactIDs = [] + guard let cup = anchor.findEntity(named: PodiumBuilder.Name.cup) else { return } + + var identifiers: Set = [] + collectColliders(of: cup, into: &identifiers) + cupContactIDs = identifiers + + contactSubscription = arView.scene.subscribe(to: CollisionEvents.Began.self) { [weak self] event in + MainActor.assumeIsolated { self?.handleContact(event) } + } + } + + private func collectColliders(of entity: Entity, into identifiers: inout Set) { + if entity.components[CollisionComponent.self] != nil { + identifiers.insert(ObjectIdentifier(entity)) + } + for child in entity.children { + collectColliders(of: child, into: &identifiers) } } @@ -475,9 +523,12 @@ struct PodiumARViewContainer: UIViewRepresentable { // not stay pinned at the balls that no longer exist. balls.removeAll() toss.retireAll() - cupSubscription = nil - trophyRestTransform = nil + contactSubscription = nil + cupContactIDs = [] + trophyEntity = nil cupEntity = nil + currentStep = .gold + isCupMoving = false arView.scene.removeAnchor(anchor) podiumAnchor = nil model.phase = hasSeenPlane ? .readyToPlace : .scanning @@ -489,9 +540,10 @@ struct PodiumARViewContainer: UIViewRepresentable { /// A swipe anywhere on the AR view throws a ball. Power comes from how /// fast the finger travelled *upward*, aim from where the phone points, - /// and a nudge left or right from the swipe's horizontal component — - /// all of it decided by `TossController`, which this method only feeds - /// and obeys. + /// a nudge left or right from the swipe's horizontal component, and — + /// since Gate 4 — the ball's starting point from where the finger went + /// down. All of it is decided by `TossController`, which this method + /// only feeds and obeys. @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { guard !isTornDown, let arView, podiumAnchor != nil else { return } @@ -511,11 +563,19 @@ struct PodiumARViewContainer: UIViewRepresentable { let started = swipeStart ?? now swipeStart = nil let translation = gesture.translation(in: arView) + // Where the finger went *down*: a pan's translation is measured + // from the touch-down point, so subtracting it from the current + // location recovers that point exactly — better than the + // location at `.began`, which UIKit only reports once the + // finger has already slid a few points (FR-004). + let current = gesture.location(in: arView) + let start = CGPoint(x: current.x - translation.x, y: current.y - translation.y) throwBall( TossController.Swipe( translation: SIMD2(Float(translation.x), Float(translation.y)), duration: now - started ), + from: start, at: now ) case .cancelled, .failed: @@ -525,15 +585,22 @@ struct PodiumARViewContainer: UIViewRepresentable { } } - private func throwBall(_ swipe: TossController.Swipe, at now: TimeInterval) { + private func throwBall(_ swipe: TossController.Swipe, from start: CGPoint, at now: TimeInterval) { guard let arView, let anchor = podiumAnchor, let frame = arView.session.currentFrame else { return } let camera = TossController.CameraBasis(transform: frame.camera.transform) + // `ARView.ray(through:)` owns the projection matrix and the + // interface orientation, so the touch point lands in the world + // correctly without this file having to know either. A nil result + // (no valid camera yet) falls back to the fixed spawn. + let touch = arView.ray(through: start).map { + TossController.TouchRay(origin: $0.origin, direction: $0.direction) + } - switch toss.flick(swipe, camera: camera, at: now) { + switch toss.flick(swipe, camera: camera, at: now, touch: touch) { case .rejected(.tooManyLiveBalls): model.flash("Demasiadas pelotas en juego. Espera un momento.") case .rejected: @@ -570,40 +637,163 @@ struct PodiumARViewContainer: UIViewRepresentable { // MARK: - Scoring (FR-005, SC-002) - private func handleCupEntry(_ event: CollisionEvents.Began) { - guard !isTornDown else { return } - // One of the two entities is the trigger volume; the other is - // whatever crossed it. Only a ball we launched counts. - guard let ball = balls.first(where: { $0.entity === event.entityA || $0.entity === event.entityB }) - else { return } - // False unless this is the ball's *first* crossing, so a ball that - // settles, rolls and re-triggers still scores exactly once. - guard toss.score(ball.id) else { return } - // False when the point counted for nobody — the round is paused — - // in which case there is nothing to celebrate. - guard model.registerScore() else { return } + /// A ball touched something. The +1 tier fires if that something was + /// part of the cup. + private func handleContact(_ event: CollisionEvents.Began) { + guard !isTornDown, !cupContactIDs.isEmpty else { return } + + let hitCupWithA = cupContactIDs.contains(ObjectIdentifier(event.entityA)) + let hitCupWithB = cupContactIDs.contains(ObjectIdentifier(event.entityB)) + guard hitCupWithA != hitCupWithB else { return } + let other = hitCupWithA ? event.entityB : event.entityA + + guard let ball = balls.first(where: { $0.entity === other }) else { return } + creditHit(ball.id) + } + + /// The +1 tier: this ball touched the cup, wherever on it. + private func creditHit(_ ball: TossController.BallID) { + guard !isCupMoving else { return } + // nil unless this is the ball's *first* touch and it has not + // already been paid the make, which is worth the full amount. + guard let award = toss.registerHit(ball) else { return } + // False when the points counted for nobody — the round is paused — + // in which case there is nothing to acknowledge. + guard model.registerScore(award.points) else { return } + + hitHaptics.impactOccurred(intensity: 0.55) + hitHaptics.prepare() + pulse(scale: 1.08, rise: 0.09, fall: 0.12) + } - celebrate() + /// The +10 tier: this ball is verifiably sitting in the cup. + private func creditMake(_ ball: TossController.BallID) { + guard !isCupMoving else { return } + guard let award = toss.registerMake(ball) else { return } + guard model.registerScore(award.points) else { return } + + celebrateMake() } - /// Success cue: the success haptic plus a quick swell of the trophy, so - /// the score reads even when the phone is at arm's length. - private func celebrate() { + /// Success cue: the success haptic and a big swell of the trophy, so + /// the score reads even when the phone is at arm's length — followed by + /// the cup jumping to another step (US3). + /// + /// Scoring is suspended for the whole sequence. The trophy is being + /// scaled and then moved, so anything measured in the cup's frame + /// meanwhile is measured against a target that is not where it looks. + private func celebrateMake() { successHaptics.notificationOccurred(.success) successHaptics.prepare() - guard let trophy = podiumAnchor?.findEntity(named: PodiumBuilder.Name.trophy), - let rest = trophyRestTransform - else { return } + isCupMoving = true + guard let trophy = trophyEntity else { + isCupMoving = false + return + } + + var swollen = trophyRestTransform + swollen.scale = trophyRestTransform.scale * 1.28 + _ = trophy.move( + to: swollen, + relativeTo: trophy.parent, + duration: Self.makeSwellDuration, + timingFunction: .easeOut + ) + + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: UInt64(Self.makeSwellDuration * 1_000_000_000) + 10_000_000) + guard let self, !self.isTornDown else { return } + self.relocateCup() + } + } + + // MARK: - Difficulty ramp (spec US3) + + /// Slides the cup to a different podium step, so the next toss cannot + /// reuse the aim that just worked. + /// + /// The trophy is a child of the steps container, never of a step, so + /// this is one animation inside one parent — and the cup stays on the + /// podium's anchor, which is the anchor the balls are simulated on. + /// The move doubles as the return from the celebratory swell. + private func relocateCup() { + guard !isTornDown, let trophy = trophyEntity else { + isCupMoving = false + return + } + + // A ball lying in the cup would be left hanging in the air when the + // cup slides out from under it. It has already been paid, so it + // leaves with the cup. + clearBallsInsideCup() + + currentStep = PodiumBuilder.nextStep(after: currentStep) + let destination = trophyRestTransform + _ = trophy.move( + to: destination, + relativeTo: trophy.parent, + duration: Self.cupMoveDuration, + timingFunction: .easeInOut + ) + + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: UInt64(Self.cupMoveDuration * 1_000_000_000) + 30_000_000) + guard let self, !self.isTornDown else { return } + // Land exactly on the target pose rather than wherever the + // animation stopped, so the next swell has a clean rest state. + self.trophyEntity?.transform = self.trophyRestTransform + self.isCupMoving = false + } + } + + /// The trophy's pose when nothing is animating: upright, unscaled, on + /// whichever step the cup currently belongs to. + private var trophyRestTransform: Transform { + Transform( + scale: .one, + rotation: simd_quatf(angle: 0, axis: [0, 1, 0]), + translation: currentStep.trophyPosition + ) + } + + private func clearBallsInsideCup() { + guard cupEntity != nil else { return } + var survivors: [LiveBall] = [] + for ball in balls { + guard let placement = cupPlacement(of: ball.entity), + toss.isInsideCup(placement, ballRadius: toss.tuning.ballRadius) + else { + survivors.append(ball) + continue + } + ball.entity.removeFromParent() + toss.retire(ball.id) + } + balls = survivors + } + /// A short swell of the trophy, used as the light cue for the +1 tier. + /// Skipped while a make is being celebrated — that animation owns the + /// trophy. + private func pulse(scale: Float, rise: TimeInterval, fall: TimeInterval) { + guard !isCupMoving, let trophy = trophyEntity else { return } + let rest = trophyRestTransform var swollen = rest - swollen.scale = rest.scale * 1.28 - _ = trophy.move(to: swollen, relativeTo: trophy.parent, duration: 0.14, timingFunction: .easeOut) + swollen.scale = rest.scale * scale + _ = trophy.move(to: swollen, relativeTo: trophy.parent, duration: rise, timingFunction: .easeOut) Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 150_000_000) - guard let self, !self.isTornDown, let rest = self.trophyRestTransform else { return } - _ = trophy.move(to: rest, relativeTo: trophy.parent, duration: 0.22, timingFunction: .easeInOut) + try? await Task.sleep(nanoseconds: UInt64(rise * 1_000_000_000) + 10_000_000) + guard let self, !self.isTornDown, !self.isCupMoving, + let trophy = self.trophyEntity + else { return } + _ = trophy.move( + to: self.trophyRestTransform, + relativeTo: trophy.parent, + duration: fall, + timingFunction: .easeInOut + ) } } @@ -630,6 +820,10 @@ struct PodiumARViewContainer: UIViewRepresentable { var survivors: [LiveBall] = [] survivors.reserveCapacity(balls.count) + // Collected rather than credited on the spot: a make relocates the + // cup, which culls balls, and that must not happen underneath this + // loop's own rebuild of `balls`. + var landed: [TossController.BallID] = [] for var ball in balls { ball.age += deltaTime @@ -640,6 +834,23 @@ struct PodiumARViewContainer: UIViewRepresentable { delta: deltaTime ) let height = ball.entity.position(relativeTo: anchor).y + let placement = cupPlacement(of: ball.entity) + + // FR-005 / SC-002: the make is a geometric fact that has to + // hold for `insideDwell` seconds, not a sensor contact. A ball + // punched through the wall or skimming past crosses the + // interior in a frame or two and never gets there. + let inside = !isCupMoving && placement.map { + toss.isInsideCup($0, ballRadius: toss.tuning.ballRadius) + } ?? false + ball.insideFor = toss.containedDuration( + previous: ball.insideFor, + isInside: inside, + delta: deltaTime + ) + if toss.hasSettledInside(containedFor: ball.insideFor), !toss.hasMade(ball.id) { + landed.append(ball.id) + } let reason = toss.cullReason( age: ball.age, @@ -654,7 +865,12 @@ struct PodiumARViewContainer: UIViewRepresentable { if reason != .outOfBounds, toss.mayNudgeOffRim(nudgesSoFar: ball.rimNudges), - isPerchedOnRim(ball.entity) { + let placement, + toss.isPerchedOnRim( + placement, + ballRadius: toss.tuning.ballRadius, + cupOuterRadius: PodiumBuilder.Metrics.cupRimOuterRadius + ) { nudgeOffRim(ball.entity) ball.rimNudges += 1 ball.restingFor = 0 @@ -668,24 +884,19 @@ struct PodiumARViewContainer: UIViewRepresentable { } balls = survivors + for ball in landed { + creditMake(ball) + } } - // MARK: - Rim rescue (Gate 3 row 3.2) + // MARK: - Where a ball is relative to the cup - /// Is this ball balanced on the cup's rim right now? Measured in the - /// cup's own frame, where `TossController`'s rule is written. - private func isPerchedOnRim(_ ball: ModelEntity) -> Bool { - guard let cup = cupEntity else { return false } - let local = ball.position(relativeTo: cup) - let contact = TossController.RimContact( - heightAboveRim: local.y - PodiumBuilder.Metrics.cupRimHeight, - radialDistance: simd_length(SIMD2(local.x, local.z)) - ) - return toss.isPerchedOnRim( - contact, - ballRadius: toss.tuning.ballRadius, - cupOuterRadius: PodiumBuilder.Metrics.cupRimOuterRadius - ) + /// The ball's position in the cup's own frame, in the shape both cup + /// rules — inside (FR-005) and perched on the rim (Gate 3 row 3.2) — + /// are written against. `nil` before the podium is placed. + private func cupPlacement(of ball: ModelEntity) -> TossController.CupPlacement? { + guard let cup = cupEntity else { return nil } + return PodiumBuilder.cupPlacement(ofBallAt: ball.position(relativeTo: cup)) } /// Tips a perched ball off the rim in a random direction, so it falls @@ -799,12 +1010,14 @@ struct PodiumARViewContainer: UIViewRepresentable { lifecycleObservers.removeAll() subscriptions.removeAll() - cupSubscription = nil + contactSubscription = nil + cupContactIDs = [] balls.removeAll() toss.retireAll() - trophyRestTransform = nil + trophyEntity = nil cupEntity = nil + isCupMoving = false swipeStart = nil coachingOverlay.delegate = nil diff --git a/ios/IPP/Game/PodiumBuilder.swift b/ios/IPP/Game/PodiumBuilder.swift index 226c6df..c647491 100644 --- a/ios/IPP/Game/PodiumBuilder.swift +++ b/ios/IPP/Game/PodiumBuilder.swift @@ -35,7 +35,6 @@ enum PodiumBuilder { static let cup = "cup" static let cupWall = "cup_wall" static let cupFloor = "cup_floor" - static let cupTrigger = "cup_trigger" static let floor = "floor" /// Balls are named `ball_` so a scene dump stays readable; the game /// itself matches them by identity, not by name. @@ -124,6 +123,70 @@ enum PodiumBuilder { } } + // MARK: - Steps as a value (spec US3) + + /// The three podium steps, as something the difficulty ramp can reason + /// about without touching the scene graph. + /// + /// The trophy hangs off the **steps container**, not off a step, precisely + /// so relocation is one `move(to:relativeTo:)` inside a single parent + /// rather than a re-parent mid-animation — and so it never leaves the + /// podium's anchor, which is the anchor the balls are simulated on. + enum Step: String, CaseIterable { + case gold + case silver + case bronze + + var entityName: String { + switch self { + case .gold: return Name.goldStep + case .silver: return Name.silverStep + case .bronze: return Name.bronzeStep + } + } + + var height: Float { + switch self { + case .gold: return Metrics.goldHeight + case .silver: return Metrics.silverHeight + case .bronze: return Metrics.bronzeHeight + } + } + + var x: Float { + switch self { + case .gold: return Metrics.goldX + case .silver: return Metrics.silverX + case .bronze: return Metrics.bronzeX + } + } + + /// Where the trophy stands when it is on this step, in the steps + /// container's frame: centred on the step's top face. + var trophyPosition: SIMD3 { [x, height, 0] } + } + + /// Picks the step the cup jumps to after a make (spec US3: "consecutive + /// scores require re-aiming"). + /// + /// It **cannot** return the step the cup is already on — that one is + /// removed from the pool before the draw rather than being re-rolled away, + /// so there is no unlucky path where the cup stays put. + static func nextStep( + after current: Step, + using generator: inout G + ) -> Step { + let others = Step.allCases.filter { $0 != current } + // `others` always has two elements, so the fallback is unreachable. + return others.randomElement(using: &generator) ?? current + } + + /// ``nextStep(after:using:)`` with the system generator. + static func nextStep(after current: Step) -> Step { + var generator = SystemRandomNumberGenerator() + return nextStep(after: current, using: &generator) + } + // MARK: - Colors // // The exact `LeaderboardRow.medalColor` values, so the podium reads as the @@ -157,13 +220,14 @@ enum PodiumBuilder { let podium = Entity() podium.name = Name.steps - let gold = makeStep( - name: Name.goldStep, - color: Medal.gold, - height: Metrics.goldHeight, - x: Metrics.goldX + podium.addChild( + makeStep( + name: Name.goldStep, + color: Medal.gold, + height: Metrics.goldHeight, + x: Metrics.goldX + ) ) - podium.addChild(gold) podium.addChild( makeStep( name: Name.silverStep, @@ -181,12 +245,14 @@ enum PodiumBuilder { ) ) - // The trophy rides on the gold step, so Phase 5's cup relocation is a - // re-parent plus a move rather than a rebuild. The step's origin is its - // centre, so half its height puts the trophy on the top face. + // The trophy starts on the gold step but hangs off the steps container + // rather than off the step itself, so the difficulty ramp (US3) can + // slide it to another step with a single `move(to:relativeTo:)` in a + // parent that never changes — and never leaves the podium's anchor, + // which is the anchor the balls' physics runs on. let trophy = makeTrophy() - trophy.position = [0, Metrics.goldHeight / 2, 0] - gold.addChild(trophy) + trophy.position = Step.gold.trophyPosition + podium.addChild(trophy) return podium } @@ -213,12 +279,14 @@ enum PodiumBuilder { return step } - /// The trophy: base + stem + an **open** cup with an invisible trigger - /// volume filling its mouth. + /// The trophy: base + stem + an **open** cup. /// /// The cup is a ring of wall segments over a floor disc rather than a solid /// cylinder on purpose — a solid mesh would make the ball bounce off the - /// target instead of settling into it, which is what Phase 3 has to detect. + /// target instead of settling into it, which is what the scoring rule has + /// to detect. Nothing here is a sensor: whether a ball is inside the cup is + /// decided from its position (``cupPlacement(ofBallAt:)``), not from a + /// contact. static func makeTrophy() -> Entity { let trophy = Entity() trophy.name = Name.trophy @@ -353,30 +421,26 @@ enum PodiumBuilder { cup.addChild(segment) } - cup.addChild(makeCupTrigger()) return cup } - /// Invisible sensor filling the inside of the cup. It carries no - /// `ModelComponent`, so it is never drawn; Phase 3 subscribes to its - /// `CollisionEvents` to score a ball. - static func makeCupTrigger() -> Entity { - // Shorter than the wall so a ball perched on the rim does not count, - // and narrower so the sensor stays clear of the wall segments — which - // now lean *inward* at their base, so the clearance is measured there. - let height = Metrics.cupWallHeight * 0.75 - let side = (Metrics.cupInnerRadius - Metrics.cupWallThickness) * 1.25 - let trigger = Entity() - trigger.name = Name.cupTrigger - trigger.position = [0, Metrics.cupFloorThickness + height / 2, 0] - trigger.components.set( - CollisionComponent( - shapes: [.generateBox(width: side, height: height, depth: side)], - mode: .trigger, - filter: .sensor - ) + /// A ball's position in the cup's own frame, described the way + /// `TossController`'s cup rules want it (Gate 4 DEFECT). + /// + /// This is the single bridge between the geometry above and the scoring + /// rules: the AR side measures the ball's centre relative to the cup entity + /// and hands the result straight to ``TossController/isInsideCup(_:ballRadius:)`` + /// and ``TossController/isPerchedOnRim(_:ballRadius:cupOuterRadius:)``. + /// There is no sensor volume any more — Phase 5 deleted it, because contact + /// with a sensor cannot tell which side of the wall the ball is on, which + /// is exactly what Gate 4 caught. + static func cupPlacement(ofBallAt centre: SIMD3) -> TossController.CupPlacement { + TossController.CupPlacement( + heightAboveRim: centre.y - Metrics.cupRimHeight, + radialDistance: simd_length(SIMD2(centre.x, centre.z)), + heightAboveCupFloor: centre.y - Metrics.cupFloorThickness, + interiorRadius: Metrics.cupInnerRadius(atHeight: centre.y) ) - return trigger } /// Invisible static plane at anchor height (y = 0) standing in for the real @@ -616,9 +680,15 @@ extension PodiumBuilder { } } - // 2. Trophy on the tallest (gold) step, not loose in the scene. - if let trophy = requireEntity(Name.trophy), trophy.parent?.name != Name.goldStep { - problems.append("trophy is not parented to the gold step") + // 2. Trophy standing on the tallest (gold) step, hanging off the steps + // container so the ramp can slide it between steps (US3). + if let trophy = requireEntity(Name.trophy) { + if trophy.parent?.name != Name.steps { + problems.append("trophy is not parented to the steps container") + } + if simd_distance(trophy.position, Step.gold.trophyPosition) > 0.0001 { + problems.append("trophy does not start on the gold step's top face") + } } // 3. Cup: a closed ring of wall segments over a floor disc. @@ -639,21 +709,39 @@ extension PodiumBuilder { requireStaticBody(segment, segment.name) } - // 4. Trigger volume: collidable, invisible, in trigger mode. - if let trigger = requireEntity(Name.cupTrigger) { - requireCollision(trigger, Name.cupTrigger) - if trigger.components[ModelComponent.self] != nil { - problems.append("cup trigger is visible — it must have no ModelComponent") - } - if trigger.components[CollisionComponent.self]?.mode != .trigger { - problems.append("cup trigger is not in .trigger mode") + // 4. The scoring rule against the real geometry (Gate 4 DEFECT): a ball + // resting on the cup floor must read as inside, and a ball touching + // the *outside* of the wall must never read as inside, at any height + // — including the low front, which is where the false positives came + // from. + let toss = TossController() + let ballRadius = toss.tuning.ballRadius + let restingCentre = SIMD3(0, Metrics.cupFloorThickness + ballRadius, 0) + if !toss.isInsideCup(cupPlacement(ofBallAt: restingCentre), ballRadius: ballRadius) { + problems.append("a ball resting on the cup floor does not register as inside") + } + for step in 0...12 { + let height = Float(step) / 12 * Metrics.cupRimHeight + // Centre of a ball pressed against the outer face of the wall. + let outside = SIMD3( + Metrics.cupInnerRadius(atHeight: height) + Metrics.cupWallThickness + ballRadius, + height, + 0 + ) + if toss.isInsideCup(cupPlacement(ofBallAt: outside), ballRadius: ballRadius) { + problems.append( + "a ball touching the cup's outside at \(height) m reads as inside" + ) } } + // A ball perched on the rim is neither inside nor allowed to score. + let perched = SIMD3(Metrics.cupRimRingRadius, Metrics.cupRimHeight + ballRadius, 0) + if toss.isInsideCup(cupPlacement(ofBallAt: perched), ballRadius: ballRadius) { + problems.append("a ball perched on the rim reads as inside") + } // 4b. Cup geometry after the Gate 3 rim fix: the wall must flare - // outward, still admit the ball at the bottom, and keep the scoring - // trigger strictly below the rim so a perched ball cannot score. - let ballRadius = TossController.Tuning().ballRadius + // outward and still admit the ball at the bottom. let mouthRadius = Metrics.cupInnerRadius(atHeight: Metrics.cupRimHeight) let baseRadius = Metrics.cupInnerRadius(atHeight: Metrics.cupFloorThickness) if mouthRadius <= baseRadius { @@ -663,12 +751,6 @@ extension PodiumBuilder { if Metrics.cupInnerRadius(atHeight: restingHeight) <= ballRadius { problems.append("the flared wall is too tight for a ball to reach the cup floor") } - if let trigger = scene.findEntity(named: Name.cupTrigger) { - let triggerTop = trigger.position.y + Metrics.cupWallHeight * 0.75 / 2 - if triggerTop >= Metrics.cupRimHeight { - problems.append("the scoring trigger reaches the rim — a perched ball could score") - } - } // 5. Floor plane: invisible, static, top face at the anchor height. if let floor = requireEntity(Name.floor) { diff --git a/ios/IPP/Game/TossController.swift b/ios/IPP/Game/TossController.swift index 9a37c5b..8ee357a 100644 --- a/ios/IPP/Game/TossController.swift +++ b/ios/IPP/Game/TossController.swift @@ -77,6 +77,19 @@ import simd /// /// ``Tuning/fastFlick`` also came down 2400 → 2200 pt/s so the ceiling is /// actually reachable by a thumb rather than being a number in a file. +/// +/// # Scoring (Gate 4 → Phase 5) +/// +/// Two tiers, and the second absorbs the first: touching the cup anywhere pays +/// ``Tuning/hitPoints``, landing inside pays ``Tuning/makePoints`` *in total*. +/// See ``registerHit(_:)`` and ``registerMake(_:)``. +/// +/// "Inside" is a geometric fact about where the ball's centre is +/// (``isInsideCup(_:ballRadius:)``) that has to hold for +/// ``Tuning/insideDwell`` seconds — not a sensor contact. Gate 4 found the +/// contact version awarding makes for balls that only hit the cup's front from +/// the outside; a rule written about the centre of the ball cannot be satisfied +/// from outside the wall at all. struct TossController { // MARK: - Tuning @@ -137,14 +150,72 @@ struct TossController { /// Keeps a diagonal flick a nudge rather than a right-angle turn. var maxLateral: Float = 0.35 - // Spawn point — just in front of the camera, not inside it. + // Spawn point — just in front of the camera, under the finger. /// Metres in front of the camera the ball appears at, so it is outside - /// the near plane and visibly leaves the player's hand. + /// the near plane and visibly leaves the player's hand. With a touch + /// point this is the *depth* of the spawn plane; the sideways and + /// vertical position come from where the finger went down (FR-004, + /// amended at Gate 4). var spawnForwardOffset: Float = 0.16 - /// Metres below the camera the ball appears at, so it arcs up into view + /// Metres below the camera the ball appears at when there is no touch + /// point to anchor it to (fallback only), so it arcs up into view /// rather than starting dead centre over the crosshair. var spawnDownOffset: Float = 0.04 + /// Smallest angle-cosine between the touch ray and the aim that is + /// still treated as "in front of the camera". A ray flatter than this + /// (a bad projection, a touch beyond the frustum) is pulled back in + /// rather than spawning the ball beside or behind the player. + var minimumSpawnCosine: Float = 0.30 + /// Hard cap on how far from the camera's aim axis a touch-anchored + /// spawn may sit, in metres. A corner touch on a wide lens projects to + /// ~0.15 m at the spawn depth; the cap keeps a freak projection from + /// putting the ball an arm's length off to the side. + var maxSpawnLateral: Float = 0.18 + /// Bounds on the spawn's depth in front of the camera, in metres. The + /// lower bound keeps the ball out of the near plane (and out of the + /// player's own hand); the upper bound keeps it from being pushed into + /// whatever the player is standing at. + var minSpawnDepth: Float = 0.10 + var maxSpawnDepth: Float = 0.30 + + // Scoring (FR-005, amended at Gate 4: two tiers). + + /// Points for touching the cup anywhere — outside included. Once per + /// ball. + var hitPoints: Int = 1 + /// Points for a ball that lands *inside* the cup. Once per ball, and it + /// **absorbs** the hit: a made ball is worth `makePoints` in total, so + /// if the hit already paid out, the make only adds the difference. + var makePoints: Int = 10 + + // "Inside the cup", as a geometric fact rather than a sensor contact + // (Gate 4 DEFECT: hits on the cup's front were scoring as makes). + + /// How far *below* the rim, as a fraction of the ball's radius, the + /// ball's centre must be before it counts as inside the cup. + /// + /// Deliberately the same number as ``rimGraceFraction``, which makes + /// the two classifications exact complements on the height axis: a ball + /// is either low enough to be inside or high enough to be perched, never + /// both. A ball resting on the cup floor sits ≈ 0.7 radii below the rim, + /// so there is ≈ 1 cm of margin. + var insideDepthFraction: Float = 0.40 + /// Slack, in metres, on the "the whole ball fits inside the wall" + /// radial test. The wall is a 12-gon, so its corners sit ~3.5 % further + /// out than the nominal inner radius and a ball can settle a couple of + /// millimetres past it. + var insideRadialTolerance: Float = 0.004 + /// How long a ball has to stay geometrically inside the cup before the + /// make is credited, in seconds. + /// + /// This is what separates *landing* in the cup from *passing through* + /// it. The region where a ball counts as inside is only ~4 cm across, + /// so anything still travelling leaves it again within a frame or two; + /// a ball that has actually come to rest in the cup holds it forever. + /// 0.10 s is six frames at 60 Hz — imperceptible as a delay, decisive + /// as a filter. + var insideDwell: TimeInterval = 0.10 // Flood control (FR-006, edge case "ball spam"). @@ -245,6 +316,27 @@ struct TossController { } } + /// A screen touch turned into a world-space ray by the AR view. + /// + /// The AR side gets this from `ARView.ray(through:)`, which knows the real + /// projection matrix and the interface orientation; this type only decides + /// *where along it* the ball appears. Keeping the ray as the input is what + /// lets the spawn maths be tested without an `ARView` — and what keeps the + /// orientation question out of this file entirely. + struct TouchRay: Equatable { + /// World-space start of the ray (the camera, give or take the near + /// plane). + var origin: SIMD3 + /// World-space direction through the touched point. Need not be unit + /// length. + var direction: SIMD3 + + init(origin: SIMD3, direction: SIMD3) { + self.origin = origin + self.direction = direction + } + } + /// Everything the AR side needs to put one ball into the scene. struct Launch: Equatable { var ball: BallID @@ -272,6 +364,32 @@ struct TossController { case rejected(Rejection) } + /// The two ways a ball can be worth points (FR-005, amended at Gate 4). + enum ScoreTier: Equatable { + /// The ball touched the cup — anywhere, inside or out. + case hit + /// The ball came to rest inside the cup. + case make + } + + /// A tier that has just been earned, and what it actually pays. + /// + /// ``points`` is not the tier's face value: the make **absorbs** the hit, + /// so a ball that has already been paid its `hitPoints` collects only the + /// remainder when it drops in. A made ball is worth `makePoints` in total, + /// never `makePoints + hitPoints`. + struct Award: Equatable { + var ball: BallID + var tier: ScoreTier + var points: Int + + init(ball: BallID, tier: ScoreTier, points: Int) { + self.ball = ball + self.tier = tier + self.points = points + } + } + /// Why a ball is being taken out of the scene. enum CullReason: Equatable { /// It has been motionless long enough to be litter. @@ -288,9 +406,12 @@ struct TossController { /// Balls currently simulating, newest last. private(set) var liveBalls: [BallID] = [] - /// Balls that have already been credited. Cleared per ball on ``retire(_:)`` - /// — ids are never reused, so nothing can be double-credited afterwards. - private var scoredBalls: Set = [] + /// Balls that have already been paid the hit tier. Cleared per ball on + /// ``retire(_:)`` — ids are never reused, so nothing can be double-credited + /// afterwards. + private var hitBalls: Set = [] + /// Balls that have already been paid the make tier. + private var madeBalls: Set = [] private var lastLaunch: TimeInterval? private var nextBall: BallID = 1 @@ -300,7 +421,14 @@ struct TossController { var liveBallCount: Int { liveBalls.count } - func hasScored(_ ball: BallID) -> Bool { scoredBalls.contains(ball) } + /// Has this ball already been paid for touching the cup? + func hasHit(_ ball: BallID) -> Bool { hitBalls.contains(ball) } + + /// Has this ball already been paid for landing in the cup? + func hasMade(_ ball: BallID) -> Bool { madeBalls.contains(ball) } + + /// Has this ball earned anything at all? + func hasScored(_ ball: BallID) -> Bool { hasHit(ball) || hasMade(ball) } func isLive(_ ball: BallID) -> Bool { liveBalls.contains(ball) } @@ -340,8 +468,9 @@ struct TossController { return min(max(raw, -tuning.maxLateral), tuning.maxLateral) } - /// Where the ball leaves from: just in front of and slightly below the - /// camera, so it is outside the near plane and reads as leaving the hand. + /// Where the ball leaves from when there is no touch to anchor it to: just + /// in front of and slightly below the camera, so it is outside the near + /// plane and reads as leaving the hand. func launchOrigin(camera: CameraBasis) -> SIMD3 { let aim = Self.unit(camera.forward, fallback: Self.defaultForward) return camera.position @@ -349,6 +478,46 @@ struct TossController { - Self.worldUp * tuning.spawnDownOffset } + /// Where the ball leaves from: **under the finger** (FR-004, amended at + /// Gate 4). + /// + /// The ray is the touch-down point projected into the world by the AR view. + /// The ball is placed where that ray crosses the plane + /// ``Tuning/spawnForwardOffset`` in front of the camera, so the depth is + /// the same wherever the player touches and only the sideways/vertical + /// position follows the finger — which is exactly "the ball departs from + /// under my thumb" without also making corner throws start further away. + /// + /// Two clamps keep the result sane, whatever the projection hands over: + /// the ray's angle off the aim is capped (``Tuning/minimumSpawnCosine``) so + /// the spawn is always *in front of* the camera, and the final point is + /// clamped in depth (``Tuning/minSpawnDepth``…``Tuning/maxSpawnDepth``) and + /// in sideways offset (``Tuning/maxSpawnLateral``) so the ball cannot + /// appear inside the near plane or an arm's length off to one side. + /// + /// Passing `nil` falls back to the fixed spawn above. + func launchOrigin(camera: CameraBasis, touch: TouchRay?) -> SIMD3 { + guard let touch else { return launchOrigin(camera: camera) } + + let aim = Self.unit(camera.forward, fallback: Self.defaultForward) + let direction = Self.unit(touch.direction, fallback: aim) + let cosine = max(simd_dot(direction, aim), tuning.minimumSpawnCosine) + let projected = touch.origin + direction * (tuning.spawnForwardOffset / cosine) + + // Re-express around the camera so depth and sideways offset can be + // clamped independently. + let relative = projected - camera.position + let depth = simd_dot(relative, aim) + let lateral = relative - aim * depth + let lateralLength = simd_length(lateral) + let cappedLateral = lateralLength > tuning.maxSpawnLateral && lateralLength > 0 + ? lateral * (tuning.maxSpawnLateral / lateralLength) + : lateral + let cappedDepth = min(max(depth, tuning.minSpawnDepth), tuning.maxSpawnDepth) + + return camera.position + aim * cappedDepth + cappedLateral + } + /// World-space launch velocity: the camera's aim, lofted by ``Tuning/arc`` /// and steered by the swipe, scaled to ``launchSpeed(for:)``. /// @@ -384,7 +553,15 @@ struct TossController { /// /// On success the new ball is recorded as live; the caller is responsible /// for calling ``retire(_:)`` when it removes the entity again. - mutating func flick(_ swipe: Swipe, camera: CameraBasis, at now: TimeInterval) -> Outcome { + /// - Parameter touch: the swipe's *starting* point, projected into the + /// world by the AR view. The ball spawns under it; `nil` falls back to + /// the fixed in-front-of-the-camera spawn. + mutating func flick( + _ swipe: Swipe, + camera: CameraBasis, + at now: TimeInterval, + touch: TouchRay? = nil + ) -> Outcome { guard isToss(swipe) else { return .rejected(.notAToss) } if let blocker = launchBlocker(at: now) { return .rejected(blocker) } @@ -397,24 +574,44 @@ struct TossController { return .launched( Launch( ball: ball, - origin: launchOrigin(camera: camera), + origin: launchOrigin(camera: camera, touch: touch), velocity: velocity, impulse: velocity * tuning.ballMass ) ) } - // MARK: - Scoring (SC-002) + // MARK: - Scoring (FR-005, SC-002) + // + // Two tiers, each paid at most once per ball, and the make absorbs the hit. + // Both are gated on the ball still being live, so a late event about a + // culled ball can never resurrect it. + + /// Credits a ball for touching the cup — the +1 tier, anywhere on the cup, + /// outside included. + /// + /// Returns the award **exactly once** per ball: a thrown ball rattles round + /// the wall segments and fires a contact for each of them, and only the + /// first is worth anything. A ball that has already been paid the make + /// earns nothing more, so the order the two tiers arrive in does not change + /// the total. + mutating func registerHit(_ ball: BallID) -> Award? { + guard liveBalls.contains(ball), !madeBalls.contains(ball) else { return nil } + guard hitBalls.insert(ball).inserted else { return nil } + return Award(ball: ball, tier: .hit, points: tuning.hitPoints) + } - /// Credits a ball for landing in the cup. + /// Credits a ball for landing inside the cup — the +10 tier. /// - /// Returns `true` **exactly once** per ball: the cup's trigger volume fires - /// a collision every time the ball crosses it — settling, bouncing, rolling - /// — and only the first of those is a point. A ball that has already been - /// retired scores nothing, so a late event cannot resurrect it. - mutating func score(_ ball: BallID) -> Bool { - guard liveBalls.contains(ball) else { return false } - return scoredBalls.insert(ball).inserted + /// The payout is `makePoints` minus whatever the hit tier already paid for + /// the same ball, so a made ball is worth 10 in total rather than 11. The + /// caller decides *that* the ball is inside (see ``isInsideCup(_:ballRadius:)`` + /// and ``hasSettledInside(containedFor:)``); this only handles the money. + mutating func registerMake(_ ball: BallID) -> Award? { + guard liveBalls.contains(ball) else { return nil } + guard madeBalls.insert(ball).inserted else { return nil } + let alreadyPaid = hitBalls.contains(ball) ? tuning.hitPoints : 0 + return Award(ball: ball, tier: .make, points: tuning.makePoints - alreadyPaid) } // MARK: - Culling (FR-006) @@ -439,23 +636,94 @@ struct TossController { return nil } - // MARK: - Rim rescue (Gate 3 row 3.2) + // MARK: - Where the ball is relative to the cup - /// Where a ball sits relative to the cup, reduced to the two numbers the - /// rim rule needs. Both are measured in the cup's own frame. - struct RimContact: Equatable { + /// One ball's position in the cup's own frame, reduced to the four numbers + /// the two cup rules need. The AR side measures these off the real + /// entities; every rule below is arithmetic over them. + struct CupPlacement: Equatable { /// Ball centre minus the top of the cup wall, in metres. Positive means /// the ball is above the mouth. var heightAboveRim: Float /// Horizontal distance from the cup's axis, in metres. var radialDistance: Float - - init(heightAboveRim: Float, radialDistance: Float) { + /// Ball centre minus the inner surface of the cup's floor disc, in + /// metres. Negative means the ball is below the cup altogether — on the + /// step, on the stem, on the table. + var heightAboveCupFloor: Float + /// Inner radius of the flared wall **at the ball's own height**, in + /// metres. Widest at the mouth, narrowest at the floor. + var interiorRadius: Float + + init( + heightAboveRim: Float, + radialDistance: Float, + heightAboveCupFloor: Float = 0, + interiorRadius: Float = 0 + ) { self.heightAboveRim = heightAboveRim self.radialDistance = radialDistance + self.heightAboveCupFloor = heightAboveCupFloor + self.interiorRadius = interiorRadius } } + // MARK: - Inside the cup (Gate 4 DEFECT, SC-002) + + /// Is the ball **genuinely inside the cup** right now? + /// + /// Gate 4 found makes being awarded for balls that only hit the cup's front + /// wall from the outside. The cause was structural: a sensor volume fires + /// on *contact*, and contact says nothing about which side of the wall the + /// ball is on. This rule says it directly, and cannot be fooled from the + /// outside, because it is a statement about the ball's centre rather than + /// about a touch: + /// + /// 1. the centre is at least ``Tuning/insideDepthFraction`` of a radius + /// **below the rim** — a ball leaning on the outside of the wall at + /// mouth height is above it, a ball perched on the rim is a full radius + /// above it; + /// 2. the centre is **above the cup's floor**, which rules out everything + /// hanging under the mouth (the stem, the gold step, the table); + /// 3. the **whole ball** fits within the wall at that height — the centre + /// is within `interiorRadius − ballRadius` of the axis, plus a few + /// millimetres for the 12-gon's corners. + /// + /// Rule 3 is the one that kills the defect outright: a ball touching the + /// outside of the wall has its centre a full radius *beyond* the wall, + /// around 9 cm from the axis, where the test allows about 2. + /// + /// This is a snapshot, so it is also true for the single frame a ball + /// spends crossing the cup's middle. ``hasSettledInside(containedFor:)`` + /// is the other half: the ball has to *stay* inside. + func isInsideCup(_ placement: CupPlacement, ballRadius: Float) -> Bool { + guard placement.heightAboveRim <= -ballRadius * tuning.insideDepthFraction else { + return false + } + guard placement.heightAboveCupFloor >= 0 else { return false } + let clearance = max(placement.interiorRadius - ballRadius, 0) + tuning.insideRadialTolerance + return placement.radialDistance <= clearance + } + + /// Runs the "how long has this ball been inside the cup" accumulator, in + /// the same shape as ``restingDuration(previous:speed:delta:)``: it adds up + /// while the ball is inside and resets the instant it is not. + func containedDuration( + previous: TimeInterval, + isInside: Bool, + delta: TimeInterval + ) -> TimeInterval { + isInside ? previous + delta : 0 + } + + /// Has the ball been inside long enough to call it a landing rather than a + /// fly-through? See ``Tuning/insideDwell``. + func hasSettledInside(containedFor: TimeInterval) -> Bool { + containedFor >= tuning.insideDwell + } + + // MARK: - Rim rescue (Gate 3 row 3.2) + /// Is this ball balanced on the cup's rim, rather than resting inside the /// cup or somewhere else in the scene? /// @@ -465,12 +733,12 @@ struct TossController { /// has its centre below the rim, a ball on the rim has it a radius above — /// so the caller can shove the perched one instead of deleting it. func isPerchedOnRim( - _ contact: RimContact, + _ placement: CupPlacement, ballRadius: Float, cupOuterRadius: Float ) -> Bool { - contact.heightAboveRim > -ballRadius * tuning.rimGraceFraction - && contact.radialDistance < cupOuterRadius + ballRadius + placement.heightAboveRim > -ballRadius * tuning.rimGraceFraction + && placement.radialDistance < cupOuterRadius + ballRadius } /// The shove given to a perched ball: horizontal, in the direction @@ -502,14 +770,16 @@ struct TossController { /// Forgets a ball the caller has removed from the scene. mutating func retire(_ ball: BallID) { liveBalls.removeAll { $0 == ball } - scoredBalls.remove(ball) + hitBalls.remove(ball) + madeBalls.remove(ball) } /// Drops all per-ball state, e.g. when the podium is relocated and every /// ball is cleared out with it. Ids keep counting up. mutating func retireAll() { liveBalls.removeAll() - scoredBalls.removeAll() + hitBalls.removeAll() + madeBalls.removeAll() lastLaunch = nil } From ca4912fd730bacbc6c743ef3cc20ec40b486b19d Mon Sep 17 00:00:00 2001 From: Eddie Date: Mon, 24 Aug 2026 14:46:40 -0400 Subject: [PATCH 06/10] feat(ios): fix lateral steering, breathe the podium, show synthetic standings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5B, from the owner's Gate 5 feedback. 5B.0 (Q4, confirmed at gate row 5-g5) — diagonal flicks launched straight. ARKit expresses the camera transform in landscape-right axes whatever the device is doing, so a portrait-locked app reading `columns.0` as "right" was steering along the phone's long axis. `CameraBasis` now maps the transform onto the real interface orientation, `CameraBasis.sideAxis` strips whatever vertical part is left so steering can never trade itself for loft, and the AR side prefers to *measure* screen-right through `ARView.ray(through:)` — the same projection the touch-anchored spawn has been using correctly since 5-g3. 5B.1 (FR-012) — the three steps grow and shrink on their own periods and phases, so the cup's height keeps changing. Nothing is ever scaled: each step swaps its mesh and its collision shape together for a pre-built pair from a 17-rung ladder, which keeps the collider exactly where the faces are whatever RealityKit does with entity scale. The breathing clock stops while an animation owns the trophy, so a celebration freezes the podium and resumes from the same phase. 5B.2/5B.3/5B.4 (FR-013) — `SyntheticStandings` invents ten unmistakably fictional Spanish doctors and their scores from a seeded generator, entirely on device: the game still reads no leaderboard and makes no request. The top three get billboarded name-and-score labels in their step's medal colour that ride the breathing steps; places four and down march away and down through the floor as a Star Wars opening crawl, one recycled text entity per line, fading over a pre-built material ramp, with no collider anywhere in it. Verified: 184 unit tests on the iPhone 17 Pro simulator (68 new), simulator clean build with no new warnings, SC-005 grep still clean. --- ios/IPP/Game/PodiumARViewContainer.swift | 216 +++++++++++++- ios/IPP/Game/PodiumBreathing.swift | 177 +++++++++++ ios/IPP/Game/PodiumBuilder.swift | 61 +++- ios/IPP/Game/StandingsDisplay.swift | 355 +++++++++++++++++++++++ ios/IPP/Game/SyntheticStandings.swift | 231 +++++++++++++++ ios/IPP/Game/TossController.swift | 107 ++++++- 6 files changed, 1122 insertions(+), 25 deletions(-) create mode 100644 ios/IPP/Game/PodiumBreathing.swift create mode 100644 ios/IPP/Game/StandingsDisplay.swift create mode 100644 ios/IPP/Game/SyntheticStandings.swift diff --git a/ios/IPP/Game/PodiumARViewContainer.swift b/ios/IPP/Game/PodiumARViewContainer.swift index f5286c5..6827ce6 100644 --- a/ios/IPP/Game/PodiumARViewContainer.swift +++ b/ios/IPP/Game/PodiumARViewContainer.swift @@ -271,6 +271,43 @@ struct PodiumARViewContainer: UIViewRepresentable { /// is the +1 tier (FR-005). private var cupContactIDs: Set = [] + // MARK: Scenery state (Phase 5B — FR-012, FR-013) + + /// The three step entities, cached at placement so the breathing update + /// does not walk the hierarchy sixty times a second. + private var stepEntities: [PodiumBuilder.Step: ModelEntity] = [:] + /// Each step's height *right now*. Everything that rides a step — the + /// trophy, the name label — is placed from this rather than from the + /// step's resting height (FR-012). + private var stepHeights: [PodiumBuilder.Step: Float] = [:] + /// Which rung of the breathing ladder each step is currently showing, + /// so a frame that does not change the rung costs nothing. + private var stepRungs: [PodiumBuilder.Step: Int] = [:] + /// The breathing clock. Advances only while the trophy is still, so a + /// celebration freezes the podium and then resumes from the same phase + /// instead of jumping (FR-012). + private var breathTime: TimeInterval = 0 + /// The crawl clock. Never pauses — the crawl is scenery and touches + /// nothing (FR-013). + private var crawlTime: TimeInterval = 0 + /// The name labels and the crawl, built once at placement. + private var standings: StandingsDisplay.Display? + /// When the current +1 pulse finishes, in `CACurrentMediaTime()` + /// seconds. Zero when no pulse is running. + private var pulseEndsAt: TimeInterval = 0 + + /// True while any animation owns the trophy's transform — the light + /// pulse of a cup hit, the swell of a make, or the slide to a new step. + /// + /// The breathing update writes the trophy's transform every frame, so + /// it has to stand back while an animation is doing the same; that is + /// also exactly the pause FR-012 asks for around the celebration. It is + /// deliberately *not* `isCupMoving`, which additionally suspends + /// scoring: a +1 pulse must not stop the same ball going on to make. + private var isTrophyAnimating: Bool { + isCupMoving || CACurrentMediaTime() < pulseEndsAt + } + /// How long the cup takes to slide to its new step. private static let cupMoveDuration: TimeInterval = 0.4 /// How long the trophy holds its celebratory swell before the move. @@ -458,6 +495,8 @@ struct PodiumARViewContainer: UIViewRepresentable { cupEntity = scene.findEntity(named: PodiumBuilder.Name.cup) currentStep = .gold isCupMoving = false + pulseEndsAt = 0 + installScenery(in: scene) subscribeToCupContacts(in: arView, anchor: anchor) // Warms the Taptic Engine so the first score's haptic is immediate. successHaptics.prepare() @@ -470,6 +509,112 @@ struct PodiumARViewContainer: UIViewRepresentable { coachingOverlay.setActive(false, animated: true) } + // MARK: - Scenery (FR-012, FR-013) + + /// Wires up everything the podium does for show: the breathing steps + /// and the synthetic standings. + /// + /// The standings are invented on the spot by `SyntheticStandings` — no + /// leaderboard is read and no request is made, here or anywhere in the + /// game (FR-008, SC-005). + private func installScenery(in scene: Entity) { + stepEntities = [:] + stepHeights = [:] + stepRungs = [:] + breathTime = 0 + crawlTime = 0 + + for step in PodiumBuilder.Step.allCases { + guard let entity = scene.findEntity(named: step.entityName) as? ModelEntity else { + continue + } + stepEntities[step] = entity + stepHeights[step] = step.height + } + // Build the ladder now rather than on the first breathing frame, so + // the one-off mesh generation lands in the placement frame, which is + // already building a scene, instead of stuttering a second later. + _ = PodiumBreathing.ladders + + standings = StandingsDisplay.attach(to: scene, standings: SyntheticStandings.standings()) + + // Put the steps on their phase-zero rungs now, so the podium + // appears already breathing instead of snapping into shape on the + // frame after it is placed. + breathe() + } + + /// Drops the cached scenery handles. The entities themselves go with + /// the anchor, which the caller removes. + private func clearScenery() { + stepEntities = [:] + stepHeights = [:] + stepRungs = [:] + standings = nil + breathTime = 0 + crawlTime = 0 + } + + /// One frame of scenery: the steps breathe, the labels follow them and + /// turn to the player, the crawl marches. + /// + /// None of it can affect play. The steps' colliders are swapped with + /// their meshes so a ball always rests on what it looks like it is + /// resting on; the labels and the crawl have no collider at all. + private func stepScenery(deltaTime: TimeInterval) { + guard podiumAnchor != nil else { return } + + // FR-012: hold the podium still while the trophy is mid-animation. + // The clock stops too, so the breath resumes where it left off. + if !isTrophyAnimating { + breathTime += deltaTime + breathe() + } + + guard let standings else { return } + crawlTime += deltaTime + StandingsDisplay.update(standings, at: crawlTime) + seatLabels(standings) + } + + /// Moves each step to the rung its height function asks for, mesh and + /// collider together, and re-seats the trophy on top of whichever step + /// it is standing on. + private func breathe() { + for step in PodiumBuilder.Step.allCases { + guard let entity = stepEntities[step], + let ladder = PodiumBreathing.ladder(for: step) + else { continue } + + let index = ladder.index(nearest: PodiumBreathing.height(for: step, at: breathTime)) + guard index != stepRungs[step] else { continue } + stepRungs[step] = index + + let rung = ladder.rungs[index] + PodiumBuilder.resize(entity, mesh: rung.mesh, shape: rung.shape, height: rung.height) + stepHeights[step] = rung.height + } + // Nothing else owns the trophy right now (`isTrophyAnimating` is + // false), so it simply stands on its step's current top face. + trophyEntity?.transform = trophyRestTransform + } + + /// Keeps the three name labels on their steps and facing the player + /// (FR-013). Runs even while the podium holds its breath — the player + /// can still walk around it. + private func seatLabels(_ display: StandingsDisplay.Display) { + guard let arView else { return } + let camera = arView.cameraTransform.translation + for label in display.labels { + StandingsDisplay.seat( + label.entity, + on: label.step, + height: stepHeights[label.step] ?? label.step.height + ) + StandingsDisplay.billboard(label.entity, toward: camera) + } + } + /// Listens for balls touching the cup — the +1 tier (FR-005). /// /// One subscription for the whole scene rather than thirteen (twelve @@ -529,6 +674,8 @@ struct PodiumARViewContainer: UIViewRepresentable { cupEntity = nil currentStep = .gold isCupMoving = false + pulseEndsAt = 0 + clearScenery() arView.scene.removeAnchor(anchor) podiumAnchor = nil model.phase = hasSeenPlane ? .readyToPlace : .scanning @@ -591,7 +738,7 @@ struct PodiumARViewContainer: UIViewRepresentable { let frame = arView.session.currentFrame else { return } - let camera = TossController.CameraBasis(transform: frame.camera.transform) + let camera = cameraBasis(in: arView, transform: frame.camera.transform) // `ARView.ray(through:)` owns the projection matrix and the // interface orientation, so the touch point lands in the world // correctly without this file having to know either. A nil result @@ -613,6 +760,53 @@ struct PodiumARViewContainer: UIViewRepresentable { } } + /// The camera pose the throw is computed from, with its sideways axis + /// **measured** rather than assumed (Q4, Gate 5 row 5-g5). + /// + /// `TossController.CameraBasis(transform:orientation:)` already knows + /// how to read ARKit's landscape-right transform in a portrait app, but + /// that is a convention this file would be trusting from documentation. + /// ``measuredScreenRight(in:)`` asks the view itself instead, through + /// the same `ARView.ray(through:)` that the touch-anchored spawn has + /// been using correctly since Gate 5 row 5-g3 — so the axis comes from + /// the projection that is actually on screen. The derived basis is only + /// the fallback, for the frames where the view has no valid camera yet. + private func cameraBasis( + in arView: ARView, + transform: simd_float4x4 + ) -> TossController.CameraBasis { + let derived = TossController.CameraBasis(transform: transform, orientation: .portrait) + guard let measured = measuredScreenRight(in: arView) else { return derived } + return TossController.CameraBasis( + position: derived.position, + forward: derived.forward, + right: measured + ) + } + + /// World-space direction of "one point further right on the screen", + /// read off the view's own projection. + /// + /// Two rays through points on the same screen row differ only by the + /// horizontal sweep of the projection, so the difference of their unit + /// directions points along screen-right — with the interface + /// orientation, the field of view and any lens distortion correction + /// already baked in by `ARView`. + private func measuredScreenRight(in arView: ARView) -> SIMD3? { + let bounds = arView.bounds + guard bounds.width > 4, bounds.height > 4 else { return nil } + let inset = bounds.width / 4 + guard let left = arView.ray(through: CGPoint(x: bounds.midX - inset, y: bounds.midY)), + let right = arView.ray(through: CGPoint(x: bounds.midX + inset, y: bounds.midY)), + simd_length_squared(left.direction) > 1e-12, + simd_length_squared(right.direction) > 1e-12 + else { return nil } + + let delta = simd_normalize(right.direction) - simd_normalize(left.direction) + guard simd_length_squared(delta) > 1e-8 else { return nil } + return simd_normalize(delta) + } + /// Puts one ball into the scene and pushes it. /// /// The ball is parented to the **podium's own anchor** rather than to a @@ -748,12 +942,15 @@ struct PodiumARViewContainer: UIViewRepresentable { } /// The trophy's pose when nothing is animating: upright, unscaled, on - /// whichever step the cup currently belongs to. + /// whichever step the cup currently belongs to — at that step's + /// **current** height, because the podium breathes (FR-012). private var trophyRestTransform: Transform { Transform( scale: .one, rotation: simd_quatf(angle: 0, axis: [0, 1, 0]), - translation: currentStep.trophyPosition + translation: currentStep.trophyPosition( + atHeight: stepHeights[currentStep] ?? currentStep.height + ) ) } @@ -778,6 +975,9 @@ struct PodiumARViewContainer: UIViewRepresentable { /// trophy. private func pulse(scale: Float, rise: TimeInterval, fall: TimeInterval) { guard !isCupMoving, let trophy = trophyEntity else { return } + // Claim the trophy for the length of the animation, so the + // breathing update does not overwrite it mid-swell (FR-012). + pulseEndsAt = CACurrentMediaTime() + rise + fall + 0.05 let rest = trophyRestTransform var swollen = rest swollen.scale = rest.scale * scale @@ -799,10 +999,16 @@ struct PodiumARViewContainer: UIViewRepresentable { // MARK: - Culling (FR-006, SC-006) - /// One frame of the game: the round clock, then the balls. + /// One frame of the game: the round clock, then the scenery, then the + /// balls. + /// + /// The scenery goes before the balls on purpose: a step that has grown + /// this frame has already grown by the time the culler measures where a + /// ball is sitting on it. private func step(deltaTime: TimeInterval) { guard !isTornDown else { return } model.tick(deltaTime) + stepScenery(deltaTime: deltaTime) stepBalls(deltaTime: deltaTime) } @@ -1018,6 +1224,8 @@ struct PodiumARViewContainer: UIViewRepresentable { trophyEntity = nil cupEntity = nil isCupMoving = false + pulseEndsAt = 0 + clearScenery() swipeStart = nil coachingOverlay.delegate = nil diff --git a/ios/IPP/Game/PodiumBreathing.swift b/ios/IPP/Game/PodiumBreathing.swift new file mode 100644 index 0000000..a721cf9 --- /dev/null +++ b/ios/IPP/Game/PodiumBreathing.swift @@ -0,0 +1,177 @@ +import Foundation +import RealityKit +import simd + +/// The podium breathes (FR-012, added at Gate 5 by owner request). +/// +/// The three steps grow and shrink slowly and out of step with each other, so +/// the cup's height keeps changing and no two throws face the same target. Two +/// halves live here: +/// +/// - the **feel constants and the height function**, which are pure arithmetic +/// and unit-tested off-device, in the same one-line-edit spirit as +/// `TossController.Tuning`; +/// - the **rung ladder**, a set of box meshes and collision shapes built once +/// for a range of heights, which is how the animation is played back. +/// +/// ## Why a ladder rather than a scale +/// +/// Stretching a step with `Entity.scale.y` would be free and perfectly smooth, +/// but it puts the whole feature on one unverifiable assumption: that RealityKit +/// applies a non-uniform entity scale to the entity's `CollisionComponent` +/// shapes as well as to its mesh. If it does not, every ball rests on a +/// phantom step at the original height — the "balls float or sink" failure the +/// task explicitly rules out — and the agent cannot settle the question off +/// device, because ARKit and the physics solver need real hardware. +/// +/// So nothing is ever scaled. Each step's mesh *and* its collision shape are +/// swapped together for a pre-built pair of the right size, and the step is +/// re-seated so it still rests on the surface. The visual and the collider are +/// then the same object by construction, under any RealityKit behaviour. +/// +/// The cost of that choice is quantisation: the height moves in +/// ``rungCount`` discrete rungs rather than continuously. At the constants +/// below that is ~3 mm per rung, which is invisible at arm's length (0.17° at +/// 1 m) and about a tenth of a ball radius, so a resting ball is nudged rather +/// than punched. The pairs are built once, lazily, and shared by every +/// placement — the update loop allocates nothing at all. +@MainActor +enum PodiumBreathing { + + // MARK: - Feel constants + // + // Everything about how the podium breathes is here, so "too fast", "too + // subtle" or "too jumpy" is a one-line edit, exactly like + // `TossController.Tuning`. + + /// Peak deviation of a step's height from its resting value, in metres. + /// + /// 2.5 cm against the 6/9/12 cm steps: the shortest step swings between + /// 3.5 cm and 8.5 cm, so the movement is unmistakable and the podium never + /// approaches zero height. + static let amplitude: Float = 0.025 + + /// Seconds for one full grow-and-shrink cycle, per step. + /// + /// The three are deliberately unequal and not small multiples of each + /// other, so the steps drift in and out of phase for minutes instead of + /// locking into a single pulsing block. + static func period(for step: PodiumBuilder.Step) -> TimeInterval { + switch step { + case .gold: return 4.3 + case .silver: return 3.7 + case .bronze: return 5.1 + } + } + + /// Where in its cycle each step starts, in radians. Thirds of a turn, so + /// the podium is already asymmetric on the first frame. + static func phase(for step: PodiumBuilder.Step) -> Float { + switch step { + case .gold: return 0 + case .silver: return 2 * .pi / 3 + case .bronze: return 4 * .pi / 3 + } + } + + /// How many discrete heights each step can take. Odd, so the resting height + /// is one of them. + static let rungCount = 17 + + // MARK: - The height function (pure) + + /// A step's height at `time` seconds of breathing. + /// + /// `time` is the game's *breathing clock*, not wall time: the coordinator + /// stops advancing it while the trophy is being animated, so the podium + /// freezes for a celebration and then carries on from where it was rather + /// than jumping (FR-012: "the motion pauses during the make + /// celebration/relocation"). + static func height(for step: PodiumBuilder.Step, at time: TimeInterval) -> Float { + height( + base: step.height, + amplitude: amplitude, + period: period(for: step), + phase: phase(for: step), + at: time + ) + } + + /// The oscillator itself, with every input explicit so it can be asserted + /// without reference to the podium's constants. + static func height( + base: Float, + amplitude: Float, + period: TimeInterval, + phase: Float, + at time: TimeInterval + ) -> Float { + guard period > 0, amplitude != 0 else { return base } + let omega = 2 * Float.pi / Float(period) + return base + amplitude * sin(omega * Float(time) + phase) + } + + /// The band a step's height stays inside, for whatever `time`. + static func bounds(for step: PodiumBuilder.Step) -> ClosedRange { + (step.height - amplitude)...(step.height + amplitude) + } + + // MARK: - The rung ladder + + /// One height a step can actually be drawn and collided at. + struct Rung { + let height: Float + let mesh: MeshResource + let shape: ShapeResource + } + + /// The rungs for one step, evenly spaced across ``bounds(for:)``. + struct Ladder { + let rungs: [Rung] + let lowest: Float + let spacing: Float + + /// The rung closest to `height`, clamped to the ends. Pure — the tests + /// drive it directly. + func index(nearest height: Float) -> Int { + guard rungs.count > 1, spacing > 0 else { return 0 } + let raw = (height - lowest) / spacing + guard raw.isFinite else { return 0 } + return min(max(Int(raw.rounded()), 0), rungs.count - 1) + } + + func rung(nearest height: Float) -> Rung { + rungs[index(nearest: height)] + } + } + + /// One ladder per step, built on first use and then reused by every + /// placement — `PodiumBuilder.Step` is a fixed set and the geometry never + /// depends on where the podium was put. + static let ladders: [PodiumBuilder.Step: Ladder] = { + var built: [PodiumBuilder.Step: Ladder] = [:] + for step in PodiumBuilder.Step.allCases { + built[step] = makeLadder(for: step) + } + return built + }() + + static func ladder(for step: PodiumBuilder.Step) -> Ladder? { + ladders[step] + } + + private static func makeLadder(for step: PodiumBuilder.Step) -> Ladder { + let range = bounds(for: step) + let count = max(rungCount, 2) + let spacing = (range.upperBound - range.lowerBound) / Float(count - 1) + let rungs = (0.. Rung in + let height = range.lowerBound + spacing * Float(index) + return Rung( + height: height, + mesh: PodiumBuilder.stepMesh(height: height), + shape: PodiumBuilder.stepShape(height: height) + ) + } + return Ladder(rungs: rungs, lowest: range.lowerBound, spacing: spacing) + } +} diff --git a/ios/IPP/Game/PodiumBuilder.swift b/ios/IPP/Game/PodiumBuilder.swift index c647491..ef3dc7d 100644 --- a/ios/IPP/Game/PodiumBuilder.swift +++ b/ios/IPP/Game/PodiumBuilder.swift @@ -163,7 +163,15 @@ enum PodiumBuilder { /// Where the trophy stands when it is on this step, in the steps /// container's frame: centred on the step's top face. - var trophyPosition: SIMD3 { [x, height, 0] } + var trophyPosition: SIMD3 { trophyPosition(atHeight: height) } + + /// The same thing for a step that is not at its resting height — + /// the podium breathes (FR-012), so the top face the trophy stands on + /// moves, and everything that rides the step has to be told where it + /// is right now. + func trophyPosition(atHeight height: Float) -> SIMD3 { + [x, height, 0] + } } /// Picks the step the cup jumps to after a make (spec US3: "consecutive @@ -257,28 +265,53 @@ enum PodiumBuilder { return podium } - /// One podium step, resting on y = 0 with its centre at `x`. - static func makeStep(name: String, color: UIColor, height: Float, x: Float) -> ModelEntity { - let mesh = MeshResource.generateBox( + /// The box a step of `height` is drawn as. Split out of ``makeStep`` so the + /// breathing ladder (FR-012) can pre-build one per rung. + static func stepMesh(height: Float) -> MeshResource { + MeshResource.generateBox( width: Metrics.stepWidth, - height: height, + height: max(height, 0.001), depth: Metrics.stepDepth, cornerRadius: 0.004 ) - let step = ModelEntity(mesh: mesh, materials: [material(color)]) + } + + /// The collision shape that goes with ``stepMesh(height:)``. The two are + /// always swapped together, which is what keeps a breathing step's collider + /// exactly where its faces are. + static func stepShape(height: Float) -> ShapeResource { + .generateBox( + width: Metrics.stepWidth, + height: max(height, 0.001), + depth: Metrics.stepDepth + ) + } + + /// One podium step, resting on y = 0 with its centre at `x`. + static func makeStep(name: String, color: UIColor, height: Float, x: Float) -> ModelEntity { + let step = ModelEntity(mesh: stepMesh(height: height), materials: [material(color)]) step.name = name step.position = [x, height / 2, 0] - addStaticPhysics( - to: step, - shape: .generateBox( - width: Metrics.stepWidth, - height: height, - depth: Metrics.stepDepth - ) - ) + addStaticPhysics(to: step, shape: stepShape(height: height)) return step } + /// Re-sizes a step in place, mesh and collider together, and re-seats it so + /// it still rests on the anchor plane (FR-012). + /// + /// The pair comes from `PodiumBreathing`'s pre-built ladder, so this is + /// three assignments and no allocation. + static func resize( + _ step: ModelEntity, + mesh: MeshResource, + shape: ShapeResource, + height: Float + ) { + step.model?.mesh = mesh + step.collision?.shapes = [shape] + step.position.y = height / 2 + } + /// The trophy: base + stem + an **open** cup. /// /// The cup is a ring of wall segments over a floor disc rather than a solid diff --git a/ios/IPP/Game/StandingsDisplay.swift b/ios/IPP/Game/StandingsDisplay.swift new file mode 100644 index 0000000..e271ffd --- /dev/null +++ b/ios/IPP/Game/StandingsDisplay.swift @@ -0,0 +1,355 @@ +import CoreText +import Foundation +import RealityKit +import UIKit +import simd + +/// The podium's standings, as scenery (FR-013, added at Gate 5). +/// +/// Two pieces, both fed entirely by `SyntheticStandings` — the game reads no +/// leaderboard and makes no network request (FR-008, SC-005): +/// +/// - **Podium labels** for places #1/#2/#3: a name and a score floating in +/// front of each step in that step's medal colour, turned toward the player +/// every frame and riding the step as it breathes (FR-012). +/// - **The crawl** for places #4 and down: a Star Wars opening crawl running +/// away from the player and down through the floor beneath the podium, one +/// line per place, looping. +/// +/// Everything is procedural — text meshes generated from strings, no bundled +/// assets (FR-003) — and none of it has a `CollisionComponent` or a +/// `PhysicsBodyComponent`, so however it moves it cannot touch a ball. +/// +/// ## Cost +/// +/// Ten text meshes are built once when the podium is placed and then only ever +/// moved: the podium labels never change, and the crawl recycles its lines by +/// wrapping them back to the near end rather than creating entities. Fading is +/// a swap between pre-built materials on a sixteen-step ramp, so a frame of +/// crawl is a handful of transform writes and, occasionally, one material +/// assignment. Nothing is allocated in the update loop. +@MainActor +enum StandingsDisplay { + + // MARK: - Entity names + + enum Name { + static let labels = "standings_labels" + static let crawl = "standings_crawl" + static func label(rank: Int) -> String { "standing_label_\(rank)" } + static func crawlLine(_ index: Int) -> String { "crawl_line_\(index)" } + } + + // MARK: - Look constants + // + // Same spirit as `TossController.Tuning` and `PodiumBreathing`: if the + // owner says "too small" or "too fast", it is one line here. + + enum Look { + /// Em size of a podium name, in metres. ~9 mm of cap height, which + /// subtends about half a degree at 1 m — comfortably readable. + static let nameSize: Float = 0.013 + /// Em size of the score under the name. + static let pointsSize: Float = 0.0095 + /// Gap between the two lines of a label. + static let lineGap: Float = 0.003 + /// How far above the step's top face the label floats. + static let labelLift: Float = 0.022 + /// How far in front of the step's front face the label hangs, so it + /// never fights the trophy for the same air. + static let labelForward: Float = PodiumBuilder.Metrics.stepDepth / 2 + 0.015 + /// Depth of the text extrusion. Just enough to catch a highlight. + static let extrusion: Float = 0.0012 + /// Font size the meshes are generated at before being scaled down. + /// Core Text tessellates glyphs badly at hundredths of a point, so the + /// text is built big and shrunk. + static let designFontSize: CGFloat = 0.2 + } + + /// The crawl's geometry and motion (FR-013). + /// + /// The band is a straight line in the podium's own frame: it starts just in + /// front of the podium at surface height and runs **away from the player + /// and downward**, so the text sinks through the floor plane as it recedes. + /// Perspective does the shrinking for free — the lines keep their real + /// size, exactly like the flat crawl plane in the films. + enum Crawl { + /// How far below horizontal the band runs, in radians. + static let tilt: Float = 20 * .pi / 180 + /// Length of the band, in metres — how far a line travels before it + /// wraps back to the near end. + static let length: Float = 1.6 + /// Where the band starts, in the podium root's frame: in front of the + /// steps (+Z is the side the podium was turned toward at placement), a + /// whisker above the surface so it does not z-fight with the floor. + static let start = SIMD3(0, 0.004, 0.30) + /// How fast the text marches away, in metres per second. At 0.09 m/s a + /// line takes ~18 s to cross the band: slow enough to read, slow enough + /// to feel like scenery rather than a ticker. + static let speed: Float = 0.09 + /// Em size of a crawl line, in metres. + static let textSize: Float = 0.024 + /// Fraction of the band spent fading in at the near end… + static let fadeIn: Float = 0.10 + /// …and the fraction at which the fade out begins, so the text + /// dissolves into the distance instead of vanishing. + static let fadeOutStart: Float = 0.55 + /// How many discrete opacities the fade uses. Pre-built materials, one + /// per level, so a fading line allocates nothing. + static let fadeSteps = 16 + + /// Unit vector along the band: away from the player, and down. + static var direction: SIMD3 { + [0, -sin(tilt), -cos(tilt)] + } + + /// The rotation that lays a line of text flat in the band. + /// + /// A text mesh is drawn in its own XY plane facing +Z. This rotation + /// about the X axis sends its **up** (+Y) along ``direction`` — so the + /// tops of the letters point away down the band, which is what makes it + /// read as a crawl receding to a vanishing point — and its face (+Z) to + /// the band's normal, tilted up toward the player. + static var orientation: simd_quatf { + simd_quatf(angle: -(.pi / 2 + tilt), axis: [1, 0, 0]) + } + + /// How far along the band line `index` sits at `time`, wrapping at the + /// far end. Pure. + /// + /// At `time == 0` the lines are spread evenly over the band, so the + /// crawl is already populated the moment the podium is placed rather + /// than trickling in one line at a time. + static func distance(index: Int, count: Int, at time: TimeInterval) -> Float { + guard count > 0, length > 0, time.isFinite else { return 0 } + let spacing = length / Float(count) + let travelled = Float(max(time, 0)) * speed + Float(index) * spacing + return travelled.truncatingRemainder(dividingBy: length) + } + + /// Where that is, in the podium root's frame. Pure. + static func position(atDistance distance: Float) -> SIMD3 { + start + direction * distance + } + + /// How opaque a line is at that distance: fades up over the first + /// ``fadeIn`` of the band, holds, then fades away to nothing at the far + /// end. Pure, and always within 0…1. + static func opacity(atDistance distance: Float) -> Float { + guard length > 0, distance.isFinite else { return 0 } + let fraction = min(max(distance / length, 0), 1) + if fadeIn > 0, fraction < fadeIn { + return fraction / fadeIn + } + if fraction > fadeOutStart, fadeOutStart < 1 { + return max(0, (1 - fraction) / (1 - fadeOutStart)) + } + return 1 + } + + /// The rung of the pre-built fade ramp an opacity lands on. Pure. + /// + /// The `isFinite` guard is not decoration: `Swift.min`/`max` propagate a + /// NaN rather than clamping it, and `Int(nan)` traps — so without it a + /// single bad frame time would crash the game rather than skip a fade. + static func fadeLevel(forOpacity opacity: Float) -> Int { + guard opacity.isFinite else { return 0 } + let clamped = min(max(opacity, 0), 1) + return min(max(Int((clamped * Float(fadeSteps)).rounded()), 0), fadeSteps) + } + } + + // MARK: - What the coordinator holds on to + + /// One podium label and the step it rides. + struct PodiumLabel { + let step: PodiumBuilder.Step + let entity: Entity + } + + /// One crawl line: the pivot that moves, and the model whose material + /// carries the fade. + final class CrawlLine { + let pivot: Entity + let model: ModelEntity + var fadeLevel: Int = -1 + + init(pivot: Entity, model: ModelEntity) { + self.pivot = pivot + self.model = model + } + } + + /// Everything the update loop needs, built once at placement. + final class Display { + let labels: [PodiumLabel] + let lines: [CrawlLine] + let fadeRamp: [UnlitMaterial] + + init(labels: [PodiumLabel], lines: [CrawlLine], fadeRamp: [UnlitMaterial]) { + self.labels = labels + self.lines = lines + self.fadeRamp = fadeRamp + } + } + + // MARK: - Building + + /// Hangs the labels off the steps container and the crawl off the scene + /// root, and hands back the handles the update loop drives. + /// + /// The labels are children of the **steps container** (the same parent the + /// trophy uses) so they inherit the podium's placement yaw and can be + /// re-seated as their step breathes. The crawl is a child of the **scene + /// root** so it stays on the surface while the steps move. + static func attach(to scene: Entity, standings: [SyntheticStandings.Entry]) -> Display { + let podium = scene.findEntity(named: PodiumBuilder.Name.steps) ?? scene + + let labelRoot = Entity() + labelRoot.name = Name.labels + podium.addChild(labelRoot) + + var labels: [PodiumLabel] = [] + for (step, entry) in zip(podiumSteps, standings.prefix(podiumSteps.count)) { + let label = makeLabel(for: entry, tint: tint(for: step)) + labelRoot.addChild(label) + labels.append(PodiumLabel(step: step, entity: label)) + // Seated properly on the first update; this keeps it off the floor + // for the frame before that. + seat(label, on: step, height: step.height) + } + + // The band's start and tilt live on this one entity, so a line only has + // to slide along its parent's local +Y to travel down the band. + let crawlRoot = Entity() + crawlRoot.name = Name.crawl + crawlRoot.position = Crawl.start + crawlRoot.orientation = Crawl.orientation + scene.addChild(crawlRoot) + + let fadeRamp = makeFadeRamp(color: PodiumBuilder.Medal.gold) + var lines: [CrawlLine] = [] + for (offset, entry) in standings.dropFirst(podiumSteps.count).enumerated() { + let pivot = Entity() + pivot.name = Name.crawlLine(offset) + let model = makeTextModel( + SyntheticStandings.crawlLine(for: entry), + size: Crawl.textSize, + material: fadeRamp.last ?? UnlitMaterial(color: PodiumBuilder.Medal.gold) + ) + pivot.addChild(model) + crawlRoot.addChild(pivot) + lines.append(CrawlLine(pivot: pivot, model: model)) + } + + return Display(labels: labels, lines: lines, fadeRamp: fadeRamp) + } + + /// The three steps in podium order, so #1 lands on gold. + static let podiumSteps: [PodiumBuilder.Step] = [.gold, .silver, .bronze] + + static func tint(for step: PodiumBuilder.Step) -> UIColor { + switch step { + case .gold: return PodiumBuilder.Medal.gold + case .silver: return PodiumBuilder.Medal.silver + case .bronze: return PodiumBuilder.Medal.bronze + } + } + + /// A two-line label: the short name over the score. + static func makeLabel(for entry: SyntheticStandings.Entry, tint: UIColor) -> Entity { + let label = Entity() + label.name = Name.label(rank: entry.rank) + + let material = UnlitMaterial(color: tint) + let name = makeTextModel(entry.shortName, size: Look.nameSize, material: material) + let points = makeTextModel( + SyntheticStandings.formattedPoints(entry.points), + size: Look.pointsSize, + material: material + ) + name.position.y = (Look.nameSize + Look.lineGap) / 2 + points.position.y = -(Look.pointsSize + Look.lineGap) / 2 + + label.addChild(name) + label.addChild(points) + return label + } + + /// A line of text as a model entity whose **origin is the text's centre**. + /// + /// `MeshResource.generateText` puts the origin at the layout box's corner, + /// which would make every rotation swing the text around its own left edge. + /// Re-centring here means the caller can place, spin and billboard a label + /// by its middle. + static func makeTextModel(_ string: String, size: Float, material: UnlitMaterial) -> ModelEntity { + let scale = size / Float(Look.designFontSize) + let mesh = MeshResource.generateText( + string, + extrusionDepth: Look.extrusion / max(scale, 1e-5), + font: .systemFont(ofSize: Look.designFontSize, weight: .semibold), + containerFrame: .zero, + alignment: .center, + lineBreakMode: .byTruncatingTail + ) + let model = ModelEntity(mesh: mesh, materials: [material]) + model.scale = .init(repeating: scale) + // Transform order is translate ∘ scale, so the offset has to be scaled + // too for the centre to land on the parent's origin. + model.position = -mesh.bounds.center * scale + return model + } + + /// One material per fade level, built once. Level 0 is invisible, the last + /// level is fully opaque. + static func makeFadeRamp(color: UIColor) -> [UnlitMaterial] { + (0...Crawl.fadeSteps).map { level in + var material = UnlitMaterial(color: color) + material.blending = .transparent( + opacity: .init(floatLiteral: Float(level) / Float(Crawl.fadeSteps)) + ) + return material + } + } + + // MARK: - Per-frame updates + + /// Puts a label back on its step's top face. Called every frame, because + /// the step is breathing under it (FR-012). + static func seat(_ label: Entity, on step: PodiumBuilder.Step, height: Float) { + label.position = [step.x, height + Look.labelLift, Look.labelForward] + } + + /// Turns an entity to face the camera, yaw only, so text stays upright + /// instead of rolling over when the player crouches. + static func billboard(_ entity: Entity, toward camera: SIMD3) { + let here = entity.position(relativeTo: nil) + let dx = camera.x - here.x + let dz = camera.z - here.z + guard dx * dx + dz * dz > 1e-8 else { return } + entity.setOrientation(simd_quatf(angle: atan2(dx, dz), axis: [0, 1, 0]), relativeTo: nil) + } + + /// One frame of crawl: march every line along the band, wrap the ones that + /// reached the end, and swap the fade material where the level changed. + /// + /// The band's start and tilt are baked into the crawl root's transform, so + /// a line's local position is simply `distance` along the root's own +Y — + /// which the rotation has already aimed away from the player and down. That + /// keeps this to one vector write per line. ``Crawl/position(atDistance:)`` + /// is the same point stated in the podium's frame, for the tests. + static func update(_ display: Display, at time: TimeInterval) { + let count = display.lines.count + guard count > 0 else { return } + + for (index, line) in display.lines.enumerated() { + let distance = Crawl.distance(index: index, count: count, at: time) + line.pivot.position = [0, distance, 0] + + let level = Crawl.fadeLevel(forOpacity: Crawl.opacity(atDistance: distance)) + guard level != line.fadeLevel, display.fadeRamp.indices.contains(level) else { continue } + line.fadeLevel = level + line.model.model?.materials = [display.fadeRamp[level]] + } + } +} diff --git a/ios/IPP/Game/SyntheticStandings.swift b/ios/IPP/Game/SyntheticStandings.swift new file mode 100644 index 0000000..22a6ea5 --- /dev/null +++ b/ios/IPP/Game/SyntheticStandings.swift @@ -0,0 +1,231 @@ +import Foundation + +/// Made-up standings for the podium to display (FR-013, added at Gate 5). +/// +/// **Nothing here comes from the app.** Not from `/api/v1/leaderboard`, not +/// from `AppEnvironment`, not from disk, not from the network — the mini-game +/// is offline and disconnected from the app's data by FR-008 and SC-005, and it +/// stays that way while wearing a leaderboard's clothes. Every name below is +/// invented in this file, from a fixed pool, by a seeded generator. +/// +/// The names are also *obviously* invented, which is the point of the pool: +/// they are built from the Spanish placeholder tradition — Fulano, Mengano, +/// Zutano, Perengano, "de Tal" — plus surnames like *Ficticia*, *Ejemplo* and +/// *Anónimo*. A Spanish-speaking doctor reading "Dra. Marta Ficticia" on the +/// podium cannot mistake it for a colleague's score. +/// +/// Determinism is deliberate: the same seed always produces the same list, so +/// the podium looks the same every time the player places it, the tests can +/// assert on it, and nothing has to be stored anywhere (FR-008: the local best +/// score remains the game's only persistence). +/// +/// Pure Foundation — no UIKit, no RealityKit, no ARKit. +enum SyntheticStandings { + + /// One fictional entry. + struct Entry: Equatable { + /// 1-based place. `1`, `2` and `3` are the podium steps; the rest go in + /// the crawl. + let rank: Int + /// Full display name, e.g. `"Dra. Marta Ficticia"`. + let name: String + /// Title plus surname only, e.g. `"Dra. Ficticia"` — what fits on a + /// 10 cm podium step and still reads at a metre. + let shortName: String + let points: Int + } + + // MARK: - Tuning + + /// How many places the podium knows about: three on the steps and the rest + /// in the crawl. + static let defaultCount = 10 + + /// Fixed by default so the podium is the same every time it is placed. + /// Nothing about the game depends on the value; it is a parameter so the + /// tests can prove determinism with more than one. The bytes spell "IPP2026". + static let defaultSeed: UInt64 = 0x4950_5032_3032_36 + + // MARK: - Generation + + /// The standings, top first. + /// + /// - Parameters: + /// - count: how many places to invent. Clamped to the size of the name + /// pools, so every name is distinct. + /// - seed: same seed, same list, always. + static func standings(count: Int = defaultCount, seed: UInt64 = defaultSeed) -> [Entry] { + var generator = Generator(seed: seed) + + let wanted = max(0, min(count, min(givenNames.count, surnames.count))) + guard wanted > 0 else { return [] } + + var people = givenNames.shuffled(using: &generator) + let houses = surnames.shuffled(using: &generator) + people.removeSubrange(wanted...) + + var entries: [Entry] = [] + entries.reserveCapacity(wanted) + var points = topPoints(using: &generator) + + for index in 0.. String { + "\(entry.rank).º \(entry.name) · \(formattedPoints(entry.points)) puntos" + } + + /// Points with a Spanish thousands separator: `4820` → `"4.820"`. + /// + /// Written out rather than delegated to `NumberFormatter` so the result + /// cannot change with the device's locale — the podium is a fixed piece of + /// scenery, not a data display. + static func formattedPoints(_ points: Int) -> String { + let digits = String(abs(points)) + var grouped = "" + for (offset, digit) in digits.enumerated() { + if offset > 0, (digits.count - offset) % 3 == 0 { + grouped.append(".") + } + grouped.append(digit) + } + return points < 0 ? "-\(grouped)" : grouped + } + + // MARK: - Points + + /// The winner's score. Comfortably above anything a 60-second round can + /// produce (a round is tens of points), so the podium reads as a season + /// table rather than as something the player is about to beat. + private static func topPoints(using generator: inout Generator) -> Int { + 4_200 + 5 * Int(generator.next(upperBound: UInt64(180))) + } + + /// The drop from one place to the next. Always positive, so the list is + /// strictly descending, and small enough that ten places cannot reach zero + /// (9 × 340 = 3_060 against a floor of 4_200). + private static func gap(using generator: inout Generator) -> Int { + 120 + 5 * Int(generator.next(upperBound: UInt64(45))) + } + + // MARK: - The name pools + + fileprivate enum Gender { + case feminine + case masculine + + var title: String { + switch self { + case .feminine: return "Dra." + case .masculine: return "Dr." + } + } + } + + fileprivate struct Person { + let name: String + let gender: Gender + } + + /// A surname that agrees with the given name where Spanish asks it to. + fileprivate struct Surname { + let feminine: String + let masculine: String + + init(_ invariant: String) { + self.feminine = invariant + self.masculine = invariant + } + + init(feminine: String, masculine: String) { + self.feminine = feminine + self.masculine = masculine + } + + func form(for gender: Gender) -> String { + switch gender { + case .feminine: return feminine + case .masculine: return masculine + } + } + } + + /// Ordinary Spanish given names — the half of each name that is *supposed* + /// to look real, so the podium reads like a leaderboard. + fileprivate static let givenNames: [Person] = [ + Person(name: "Marta", gender: .feminine), + Person(name: "Javier", gender: .masculine), + Person(name: "Lucía", gender: .feminine), + Person(name: "Álvaro", gender: .masculine), + Person(name: "Elena", gender: .feminine), + Person(name: "Diego", gender: .masculine), + Person(name: "Nuria", gender: .feminine), + Person(name: "Íñigo", gender: .masculine), + Person(name: "Carmen", gender: .feminine), + Person(name: "Sergio", gender: .masculine), + Person(name: "Irene", gender: .feminine), + Person(name: "Tomás", gender: .masculine), + Person(name: "Pilar", gender: .feminine), + Person(name: "Hugo", gender: .masculine) + ] + + /// The half that makes the whole thing unmistakably fictional: Spain's + /// placeholder people (Fulano/Mengano/Zutano/Perengano, "de Tal") turned + /// into surnames, plus the plainly descriptive ones. + fileprivate static let surnames: [Surname] = [ + Surname("de Tal"), + Surname(feminine: "Ficticia", masculine: "Ficticio"), + Surname("Ejemplo"), + Surname("Placebo"), + Surname(feminine: "Anónima", masculine: "Anónimo"), + Surname("Fulánez"), + Surname("Menganez"), + Surname("Zutánez"), + Surname("Perengánez"), + Surname(feminine: "Imaginaria", masculine: "Imaginario"), + Surname(feminine: "Inventada", masculine: "Inventado"), + Surname(feminine: "Supuesta", masculine: "Supuesto"), + Surname("Demostración"), + Surname(feminine: "Prestada", masculine: "Prestado") + ] + + // MARK: - The generator + + /// SplitMix64 — small, fast, and identical on every device and every OS + /// version, which `SystemRandomNumberGenerator` is not. Determinism is a + /// requirement here (FR-013), not a convenience. + struct Generator: RandomNumberGenerator { + private var state: UInt64 + + init(seed: UInt64) { + // A zero seed is a legal SplitMix64 state, but mixing in the + // constant keeps a caller's "0" from looking special. + self.state = seed &+ 0x9E37_79B9_7F4A_7C15 + } + + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + return z ^ (z >> 31) + } + } +} diff --git a/ios/IPP/Game/TossController.swift b/ios/IPP/Game/TossController.swift index 8ee357a..042608f 100644 --- a/ios/IPP/Game/TossController.swift +++ b/ios/IPP/Game/TossController.swift @@ -78,6 +78,22 @@ import simd /// ``Tuning/fastFlick`` also came down 2400 → 2200 pt/s so the ceiling is /// actually reachable by a thumb rather than being a number in a file. /// +/// # Steering (Gate 5 → Phase 5B) +/// +/// The `side` in that formula used to be ARKit's `columns.0`, which is the +/// right-hand axis of a **landscape** screen. In a portrait-locked app that is +/// the phone's long axis, so the swipe's horizontal component was pushing the +/// throw up or down instead of left or right — Gate 5 row 5-g5, "I only see +/// straight ball launches even with diagonal swipes". +/// +/// Two changes fix it, and the second makes the first unfalsifiable: +/// ``CameraBasis/init(transform:orientation:)`` maps the transform onto the +/// real interface orientation, and ``CameraBasis/sideAxis`` then strips the +/// vertical part of whatever it gets. Steering is therefore always horizontal, +/// whatever the phone is doing and wherever the axis came from — the AR side +/// prefers to measure it through `ARView.ray(through:)`, which owns the +/// projection and the orientation. +/// /// # Scoring (Gate 4 → Phase 5) /// /// Two tiers, and the second absorbs the first: touching the cup anywhere pays @@ -287,6 +303,29 @@ struct TossController { var upwardTravel: Float { max(-translation.y, 0) } } + /// Which way up the screen is, so an ARKit camera transform can be read as + /// *screen* axes (Q4, fixed in Phase 5B). + /// + /// ARKit expresses `ARCamera.transform` in **landscape-right** orientation + /// whatever the device is actually doing: `columns.0` is the direction that + /// points to the right of a screen held in landscape-right, which is the + /// phone's **long** axis. IPP is portrait-locked, so reading `columns.0` as + /// "right" reads a vertical world direction — which is exactly what Gate 5 + /// row 5-g5 saw: diagonal flicks changed the throw's *height* instead of + /// steering it sideways. + /// + /// Rotating the landscape-right screen frame into each interface + /// orientation gives the mapping below (`x` = `columns.0`, `y` = + /// `columns.1`). + enum ScreenOrientation: Equatable, CaseIterable { + /// The only orientation IPP ever runs in — `Info.plist`'s + /// `UISupportedInterfaceOrientations` lists portrait and nothing else. + case portrait + case portraitUpsideDown + case landscapeLeft + case landscapeRight + } + /// The camera's world-space pose, reduced to the three things a throw needs. /// /// Built from an `ARCamera`'s transform by the AR side; `simd_float4x4` is a @@ -296,7 +335,8 @@ struct TossController { var position: SIMD3 /// Unit vector the camera looks along. var forward: SIMD3 - /// Unit vector out of the camera's right-hand side. + /// Unit vector along the direction the player perceives as "right of + /// the screen". Not necessarily horizontal — see ``sideAxis``, which is. var right: SIMD3 init(position: SIMD3, forward: SIMD3, right: SIMD3) { @@ -305,15 +345,65 @@ struct TossController { self.right = right } - /// ARKit's camera transform: `+x` right, `+y` up, `+z` **backward**, so - /// the viewing direction is the negated third column. - init(transform: simd_float4x4) { + /// ARKit's camera transform: `+x` right *in landscape-right*, `+y` up + /// *in landscape-right*, `+z` **backward**, so the viewing direction is + /// the negated third column and the screen's right-hand axis depends on + /// the interface orientation (``ScreenOrientation``). + /// + /// The default is `.portrait` because the app is portrait-locked; the + /// other cases exist so the mapping is stated once, testably, instead of + /// being an assumption buried in a column index (Q4). + init(transform: simd_float4x4, orientation: ScreenOrientation = .portrait) { + let landscapeRight = SIMD3( + transform.columns.0.x, transform.columns.0.y, transform.columns.0.z + ) + let landscapeUp = SIMD3( + transform.columns.1.x, transform.columns.1.y, transform.columns.1.z + ) + let screenRight: SIMD3 + switch orientation { + case .landscapeRight: screenRight = landscapeRight + case .landscapeLeft: screenRight = -landscapeRight + case .portrait: screenRight = landscapeUp + case .portraitUpsideDown: screenRight = -landscapeUp + } self.init( position: SIMD3(transform.columns.3.x, transform.columns.3.y, transform.columns.3.z), forward: -SIMD3(transform.columns.2.x, transform.columns.2.y, transform.columns.2.z), - right: SIMD3(transform.columns.0.x, transform.columns.0.y, transform.columns.0.z) + right: screenRight ) } + + /// The axis a sideways swipe actually steers along: unit length, + /// **orthogonal to gravity**, pointing to the player's right. + /// + /// Two things are going on, and both are the Q4 fix: + /// + /// 1. ``right`` is the screen's right-hand axis, which the initialiser + /// above now derives for the real interface orientation instead of + /// assuming landscape. + /// 2. Whatever it is, its vertical part is removed. The nudge is a + /// *steering* control — it should change where the throw goes, never + /// how high it goes — so it may not be allowed to borrow from the + /// loft even when the player rolls the phone a few degrees. + /// + /// Fallbacks, in order: a screen-right that is (near) vertical, which + /// only happens with the phone rolled onto its side, falls back to the + /// horizontal axis the aim itself defines, `forward × up`; a camera + /// basis that is degenerate in both falls back to world `+x`. Neither + /// can produce a NaN. + var sideAxis: SIMD3 { + let flattened = right - TossController.worldUp * simd_dot(right, TossController.worldUp) + if simd_length_squared(flattened) > 1e-6 { + return TossController.unit(flattened, fallback: TossController.defaultRight) + } + let aim = TossController.unit(forward, fallback: TossController.defaultForward) + let derived = simd_cross(aim, TossController.worldUp) + if simd_length_squared(derived) > 1e-6 { + return TossController.unit(derived, fallback: TossController.defaultRight) + } + return TossController.defaultRight + } } /// A screen touch turned into a world-space ray by the AR view. @@ -522,10 +612,13 @@ struct TossController { /// and steered by the swipe, scaled to ``launchSpeed(for:)``. /// /// The loft is added along **world** up rather than the camera's up, so the - /// arc is the same whether the player holds the phone level or tilted. + /// arc is the same whether the player holds the phone level or tilted, and + /// the steering is added along ``CameraBasis/sideAxis``, which is horizontal + /// — so a diagonal flick veers left or right and never trades that for + /// height (Q4, Gate 5 row 5-g5). func launchVelocity(for swipe: Swipe, camera: CameraBasis) -> SIMD3 { let aim = Self.unit(camera.forward, fallback: Self.defaultForward) - let side = Self.unit(camera.right, fallback: Self.defaultRight) + let side = camera.sideAxis let heading = aim + Self.worldUp * tuning.arc + side * lateralDeflection(for: swipe) From 5092c903bcb074778c4674785698e2ecb0955781 Mon Sep 17 00:00:00 2001 From: Eddie Date: Mon, 24 Aug 2026 15:39:59 -0400 Subject: [PATCH 07/10] feat(ios): reach the backend over the LAN and map the data under the podium Phase 5C. Two things, one at the app layer and one in the game. App layer (ios/IPP/Services/): - BackendLocator finds the backend at launch. On device it asks 192.168.100.15 then 192.168.100.11 for /health and keeps the first that answers; in the Simulator it keeps the configured localhost, so nothing there changes. The candidate ordering is a pure function and is unit-tested; only the probe touches the network. - AppEnvironment.resolveBackend() runs that probe once, before the schema refresh, and re-points the patient store, the anchor client and the schema service. The whole app - login, patients, field stats, leaderboard - therefore works against the real backend from the phone. - MapPinsService does one read-only GET of /api/v1/map-pins and drops everything but the coordinates. Its resolve() is the pure fallback rule: an unreachable, empty or unusable answer means the offline sample, and the map's own caption says which it got. - Info.plist gains a Spanish NSLocalNetworkUsageDescription. NSAllowsLocalNetworking already covers plain HTTP to private-range IPs, so no new ATS exception was needed. Game (ios/IPP/Game/) - still makes no request of any kind: - FloorMap draws a 1 m plate on the floor under the podium with one dot per location, fitted by bounding box with the longitude compressed by cos(latitude) so the shape is not stretched, tinted by local density from the brand teal to the podium gold, and captioned "Datos en vivo / de ejemplo - N ubicaciones". Procedural: no tiles, no imagery, no external provider. No collider and no physics body anywhere in it, so a ball flies straight through onto the floor collider as before. - The pins arrive as a plain array from the app layer, so nothing under Game/ knows what a URL is (question Q5, option A). The SC-005 grep over the folder is still clean. - SyntheticMapPins is the seeded offline sample: four invented clusters in open water, well away from any real record. - The Star Wars crawl is deleted - its band, fade ramp and per-frame update are gone, along with SyntheticStandings.crawlLine. The three step labels are untouched and still ride the breathing steps. Tests: 230 XCTest cases green on the iPhone 17 Pro simulator (169 kept from Phases 2-5B, 62 new for the projection, the probe ordering, the fallback rule, the sample and the built map; 16 crawl cases removed with the crawl). The test target is temporary as always and is not committed. --- ios/IPP/Game/FloorMap.swift | 380 ++++++++++++++++++++++ ios/IPP/Game/PodiumARViewContainer.swift | 85 +++-- ios/IPP/Game/StandingsDisplay.swift | 209 ++---------- ios/IPP/Game/SyntheticMapPins.swift | 79 +++++ ios/IPP/Game/SyntheticStandings.swift | 17 +- ios/IPP/Game/TrophyTossView.swift | 14 +- ios/IPP/Models/MapPin.swift | 58 ++++ ios/IPP/Resources/Info.plist | 2 + ios/IPP/Services/APIPatientStore.swift | 6 +- ios/IPP/Services/AppEnvironment.swift | 55 +++- ios/IPP/Services/BackendLocator.swift | 108 ++++++ ios/IPP/Services/EffectStreamClient.swift | 4 +- ios/IPP/Services/MapPinsService.swift | 58 ++++ ios/IPP/Services/SchemaService.swift | 5 +- ios/IPP/Views/IPPApp.swift | 4 + ios/IPP/Views/LeaderboardView.swift | 24 +- 16 files changed, 879 insertions(+), 229 deletions(-) create mode 100644 ios/IPP/Game/FloorMap.swift create mode 100644 ios/IPP/Game/SyntheticMapPins.swift create mode 100644 ios/IPP/Models/MapPin.swift create mode 100644 ios/IPP/Services/BackendLocator.swift create mode 100644 ios/IPP/Services/MapPinsService.swift diff --git a/ios/IPP/Game/FloorMap.swift b/ios/IPP/Game/FloorMap.swift new file mode 100644 index 0000000..f2ba20b --- /dev/null +++ b/ios/IPP/Game/FloorMap.swift @@ -0,0 +1,380 @@ +import Foundation +import RealityKit +import UIKit +import simd + +/// Turns latitude/longitude into points on a square plate. Pure, no RealityKit, +/// no UIKit — every rule the floor map's layout depends on is here so it can be +/// tested off-device (Phase 5C testing item 1). +enum FloorMapProjection { + + /// A bounding-box fit of one pin set onto one plate. + /// + /// Equirectangular, which is the right projection for a map a metre across + /// covering a few kilometres: longitude is compressed by `cos(latitude)` so + /// the shape is not stretched, and a single uniform `metresPerDegree` + /// scales both axes so the set keeps its real aspect ratio instead of being + /// squashed to fill the square. + struct Fit: Equatable { + let centerLatitude: Double + let centerLongitude: Double + /// `cos(centerLatitude)` — how much a degree of longitude is worth + /// against a degree of latitude at this latitude. + let longitudeScale: Double + /// Plate metres per degree of latitude. **Zero** when the pin set has + /// no extent (one pin, or every pin at the same coordinate), which + /// collapses the whole set onto the centre of the plate — the sane + /// answer, and the one that cannot divide by zero. + let metresPerDegree: Double + /// Side of the square the pins are fitted into, in metres. + let extent: Float + + /// Where a pin lands on the plate, in metres, as `(x, z)` in the + /// podium's own frame. + /// + /// North is **−Z**: the podium is turned to face the player at + /// placement and +Z is the side they stand on, so higher latitudes + /// belong further away from them. + /// + /// Always finite and always inside the plate: an unusable pin gives the + /// centre, and anything the arithmetic produces is clamped to the + /// half-extent, so a stray coordinate cannot fling a dot across the + /// room. + func project(_ pin: GeoPin) -> SIMD2 { + guard pin.isUsable, metresPerDegree.isFinite else { return .zero } + let x = (pin.longitude - centerLongitude) * longitudeScale * metresPerDegree + let z = -(pin.latitude - centerLatitude) * metresPerDegree + guard x.isFinite, z.isFinite else { return .zero } + let half = Double(extent) / 2 + return SIMD2( + Float(min(max(x, -half), half)), + Float(min(max(z, -half), half)) + ) + } + } + + /// The fit that puts `pins` inside a square of side `extent`, centred. + /// + /// Degenerate inputs are answered rather than rejected: an empty list, a + /// single pin and a list where every pin is identical all produce a fit + /// whose `metresPerDegree` is zero, so every pin projects to the plate's + /// centre and the map shows one dot in the middle instead of `NaN`. + static func fit(_ pins: [GeoPin], extent: Float) -> Fit { + let usable = pins.filter(\.isUsable) + guard !usable.isEmpty, extent > 0 else { + return Fit( + centerLatitude: 0, + centerLongitude: 0, + longitudeScale: 1, + metresPerDegree: 0, + extent: max(extent, 0) + ) + } + + var minLatitude = usable[0].latitude + var maxLatitude = usable[0].latitude + var minLongitude = usable[0].longitude + var maxLongitude = usable[0].longitude + for pin in usable.dropFirst() { + minLatitude = min(minLatitude, pin.latitude) + maxLatitude = max(maxLatitude, pin.latitude) + minLongitude = min(minLongitude, pin.longitude) + maxLongitude = max(maxLongitude, pin.longitude) + } + + let centerLatitude = (minLatitude + maxLatitude) / 2 + let centerLongitude = (minLongitude + maxLongitude) / 2 + // Floored so a pin set near a pole cannot drive the scale to infinity. + let longitudeScale = max(cos(centerLatitude * .pi / 180), 0.05) + + let spanLatitude = maxLatitude - minLatitude + let spanLongitude = (maxLongitude - minLongitude) * longitudeScale + let span = max(spanLatitude, spanLongitude) + // 1e-9° is about 0.1 mm on the ground: below this the pins are one + // point as far as a metre-wide plate is concerned. + let metresPerDegree = span > 1e-9 ? Double(extent) / span : 0 + + return Fit( + centerLatitude: centerLatitude, + centerLongitude: centerLongitude, + longitudeScale: longitudeScale, + metresPerDegree: metresPerDegree, + extent: extent + ) + } + + /// Evenly thins a pin list down to at most `limit` entries, deterministically. + /// + /// The backend can return a thousand pins and the scene should not grow a + /// thousand entities for a plate a metre across, where they would overlap + /// into a solid blob anyway. Sampling by *stride over the whole list* + /// rather than by taking the first `limit` keeps the geographic spread — + /// the seeded data arrives grouped by month and city, so a prefix would + /// show only the first few cities. + static func sample(_ pins: [GeoPin], limit: Int) -> [GeoPin] { + guard limit > 0 else { return [] } + guard pins.count > limit else { return pins } + let step = Double(pins.count) / Double(limit) + return (0..], radius: Float, stops: Int) -> [Int] { + guard stops > 1, !points.isEmpty, radius > 0 else { + return Array(repeating: 0, count: points.count) + } + + let radiusSquared = radius * radius + var counts = [Int](repeating: 0, count: points.count) + for i in points.indices { + for j in points.index(after: i).. 0 else { + return Array(repeating: 0, count: points.count) + } + let top = Float(stops - 1) + return counts.map { count in + min(max(Int((Float(count) / Float(busiest) * top).rounded()), 0), stops - 1) + } + } + + /// The line printed on the plate's near edge. + /// + /// It names the source on purpose: with the backend up the owner must be + /// able to see "en vivo" under the podium, and with it stopped the same + /// glance must show "de ejemplo" (gate rows 5C-g1 and 5C-g3). + static func caption(pinCount: Int, isLive: Bool) -> String { + let source = isLive ? "Datos en vivo" : "Datos de ejemplo" + let places = pinCount == 1 ? "1 ubicación" : "\(pinCount) ubicaciones" + return "\(source) · \(places)" + } +} + +/// The map of the app's data locations, laid on the floor under the podium +/// (FR-013, Phase 5C — it replaces the Star Wars crawl of Phase 5B). +/// +/// A square plate a metre across, centred on the podium, carrying one dot per +/// (already anonymized) data location, tinted by how crowded its neighbourhood +/// is, plus a caption on the near edge naming the source. +/// +/// **The game does no networking.** The pins arrive as a plain `FloorMapData` +/// value built by the app layer (`MapPinsService`), which substitutes an +/// offline sample when no backend answers. Nothing in this file — or anywhere +/// under `ios/IPP/Game/` — knows what a URL is (FR-008, SC-005, question Q5). +/// +/// No tiles, no imagery, no external map provider: the plate is a procedural +/// box and the dots are procedural cylinders, so the feature adds no asset +/// files (FR-003) and contacts nobody (SC-005). +/// +/// ## Cost, and why it cannot touch the game +/// +/// Everything is built once, when the podium is placed, and then never +/// touched again — there is no per-frame update at all. One dot mesh and five +/// materials are shared by every dot. Nothing in the subtree carries a +/// `CollisionComponent` or a `PhysicsBodyComponent`, so a ball flies straight +/// through the plate and lands on the invisible floor collider underneath, +/// exactly as it did before the map existed. +@MainActor +enum FloorMap { + + enum Name { + static let root = "floor_map" + static let plate = "floor_map_plate" + static let frame = "floor_map_frame" + static let caption = "floor_map_caption" + static func pin(_ index: Int) -> String { "floor_map_pin_\(index)" } + } + + /// Same one-line-edit spirit as `TossController.Tuning`, `PodiumBreathing` + /// and `StandingsDisplay.Look`. + enum Look { + /// Side of the plate, in metres. A metre across puts the map well + /// outside the 30 cm podium and still fits on a desk. + static let side: Float = 1.00 + /// Margin between the plate's edge and the outermost dot. + static let inset: Float = 0.06 + /// Thickness of the plate slab. Thin, but not zero: a zero-height box + /// z-fights with the floor from a shallow angle. + static let thickness: Float = 0.004 + /// How far the whole map floats above the anchor plane, so it never + /// z-fights the invisible floor collider whose top face is y = 0. + static let lift: Float = 0.001 + /// Width of the border showing around the plate. + static let frameWidth: Float = 0.012 + static let cornerRadius: Float = 0.02 + + /// Diameter of one location dot. 12 mm on a 1 m plate subtends ~0.7° + /// at a metre — a clearly separate dot, not a pixel. + static let dotDiameter: Float = 0.012 + static let dotHeight: Float = 0.0035 + static let dotSegments = 10 + /// Hard cap on rendered dots. The seeded backend returns ~960. + static let maxDots = 260 + /// Neighbourhood radius for the density tint, in plate metres. + static let densityRadius: Float = 0.05 + /// How many colours the tint ramp has, coolest first. + static let densityStops = 5 + + /// Em size of the caption on the near edge. + static let captionSize: Float = 0.020 + /// How far the caption's face is tipped up toward the player from flat, + /// in radians. Flat text on a floor is hard to read from standing + /// height; 20° is enough to help without it looking like a signpost. + static let captionLean: Float = 20 * .pi / 180 + + static let plateOpacity: Float = 0.42 + static let frameOpacity: Float = 0.30 + static let captionOpacity: Float = 0.85 + } + + /// The plate's ground colour: the app's ink, so the dots read against it. + static let plateColor = UIColor(red: 0.07, green: 0.11, blue: 0.13, alpha: 1) + + /// Builds the whole map. Returns an entity to be added to the podium's + /// scene root, where it inherits the placement yaw. + static func make(_ data: FloorMapData) -> Entity { + let root = Entity() + root.name = Name.root + root.position = [0, Look.lift, 0] + + // Border first, lower, and wider — what shows around the plate is the + // frame. + let frame = ModelEntity( + mesh: .generateBox( + width: Look.side + 2 * Look.frameWidth, + height: Look.thickness * 0.6, + depth: Look.side + 2 * Look.frameWidth, + cornerRadius: Look.cornerRadius + ), + materials: [material(color: PodiumBuilder.Medal.ball, opacity: Look.frameOpacity)] + ) + frame.name = Name.frame + frame.position.y = Look.thickness * 0.3 + root.addChild(frame) + + let plate = ModelEntity( + mesh: .generateBox( + width: Look.side, + height: Look.thickness, + depth: Look.side, + cornerRadius: Look.cornerRadius + ), + materials: [material(color: plateColor, opacity: Look.plateOpacity)] + ) + plate.name = Name.plate + plate.position.y = Look.thickness / 2 + root.addChild(plate) + + let sampled = FloorMapProjection.sample(data.pins, limit: Look.maxDots) + let fit = FloorMapProjection.fit(sampled, extent: Look.side - 2 * Look.inset) + let points = sampled.map(fit.project) + let levels = FloorMapProjection.densityLevels( + for: points, + radius: Look.densityRadius, + stops: Look.densityStops + ) + + let ramp = densityRamp() + // One mesh for every dot — 260 entities sharing a single 10-segment + // cylinder rather than 260 meshes. + let dotMesh = PodiumBuilder.cylinderMesh( + height: Look.dotHeight, + radius: Look.dotDiameter / 2, + segments: Look.dotSegments + ) + let dotY = Look.thickness + Look.dotHeight / 2 + + for (index, point) in points.enumerated() { + let level = levels.indices.contains(index) ? levels[index] : 0 + let dot = ModelEntity( + mesh: dotMesh, + materials: [ramp[min(max(level, 0), ramp.count - 1)]] + ) + dot.name = Name.pin(index) + dot.position = [point.x, dotY, point.y] + root.addChild(dot) + } + + root.addChild(makeCaption(pinCount: sampled.count, isLive: data.isLive)) + return root + } + + /// The caption, lying on the plate's near edge and tipped up toward the + /// player. + /// + /// The rotation is the flat-on-the-floor case of the same construction the + /// Phase 5B crawl used: a text mesh is drawn in its own XY plane facing +Z, + /// so rotating about X by `-(π/2 − lean)` sends its up (+Y) away from the + /// player and its face (+Z) up out of the floor, leaning back toward them. + static func makeCaption(pinCount: Int, isLive: Bool) -> Entity { + let pivot = Entity() + pivot.name = Name.caption + pivot.position = [ + 0, + Look.thickness + 0.001, + Look.side / 2 - Look.inset / 2, + ] + pivot.orientation = simd_quatf(angle: -(.pi / 2 - Look.captionLean), axis: [1, 0, 0]) + pivot.addChild( + StandingsDisplay.makeTextModel( + FloorMapProjection.caption(pinCount: pinCount, isLive: isLive), + size: Look.captionSize, + material: material(color: .white, opacity: Look.captionOpacity) + ) + ) + return pivot + } + + /// Coolest (sparse) to hottest (crowded): the app's brand teal warming into + /// the podium's gold, so the map is built from colours the rest of the app + /// already uses (FR-009). + static func densityRamp() -> [UnlitMaterial] { + let stops = max(2, Look.densityStops) + return (0.. UnlitMaterial { + var material = UnlitMaterial(color: color) + material.blending = .transparent(opacity: .init(floatLiteral: min(max(opacity, 0), 1))) + return material + } + + /// Straight RGB interpolation. `t` is clamped, so a level outside the ramp + /// cannot produce a colour outside it. + static func blend(_ from: UIColor, _ to: UIColor, _ t: Float) -> UIColor { + let amount = CGFloat(min(max(t, 0), 1)) + var fr: CGFloat = 0, fg: CGFloat = 0, fb: CGFloat = 0, fa: CGFloat = 0 + var tr: CGFloat = 0, tg: CGFloat = 0, tb: CGFloat = 0, ta: CGFloat = 0 + from.getRed(&fr, green: &fg, blue: &fb, alpha: &fa) + to.getRed(&tr, green: &tg, blue: &tb, alpha: &ta) + return UIColor( + red: fr + (tr - fr) * amount, + green: fg + (tg - fg) * amount, + blue: fb + (tb - fb) * amount, + alpha: fa + (ta - fa) * amount + ) + } +} diff --git a/ios/IPP/Game/PodiumARViewContainer.swift b/ios/IPP/Game/PodiumARViewContainer.swift index 6827ce6..0517afa 100644 --- a/ios/IPP/Game/PodiumARViewContainer.swift +++ b/ios/IPP/Game/PodiumARViewContainer.swift @@ -178,13 +178,19 @@ final class PodiumARModel: ObservableObject { /// un-delegated, anchors and subscriptions dropped — so closing the game leaves /// nothing running behind the leaderboard (FR-011). /// -/// Offline by construction: nothing here performs any networking (FR-008). +/// Offline by construction: nothing here performs any networking (FR-008). The +/// floor map's pins arrive as a plain `FloorMapData` value fetched by the app +/// layer and handed down through `TrophyTossView` (FR-013, question Q5). struct PodiumARViewContainer: UIViewRepresentable { @ObservedObject var model: PodiumARModel + /// Locations for the floor map under the podium. Already resolved by the + /// app layer to either live backend pins or the offline sample; this view + /// just draws whatever it is given. + var floorMap: FloorMapData func makeCoordinator() -> Coordinator { - Coordinator(model: model) + Coordinator(model: model, floorMap: floorMap) } func makeUIView(context: Context) -> ARView { @@ -206,7 +212,9 @@ struct PodiumARViewContainer: UIViewRepresentable { } func updateUIView(_ uiView: ARView, context: Context) { - // All state flows out of the coordinator; nothing to push back in. + // The one thing that flows *in*: the pins, which the app layer may + // still have been fetching when this screen opened. + context.coordinator.updateFloorMap(floorMap) } /// Full teardown when the game screen goes away (FR-011). @@ -287,11 +295,18 @@ struct PodiumARViewContainer: UIViewRepresentable { /// celebration freezes the podium and then resumes from the same phase /// instead of jumping (FR-012). private var breathTime: TimeInterval = 0 - /// The crawl clock. Never pauses — the crawl is scenery and touches - /// nothing (FR-013). - private var crawlTime: TimeInterval = 0 - /// The name labels and the crawl, built once at placement. + /// The three name labels, built once at placement. private var standings: StandingsDisplay.Display? + /// The pins the floor map is currently drawing. Set at init from the + /// SwiftUI value and replaced whenever the app layer's fetch lands + /// (FR-013). + private var floorMap: FloorMapData + /// The built map, kept so a late fetch can swap it without disturbing + /// anything else in the scene. + private weak var floorMapEntity: Entity? + /// The podium's scene root, cached so the map can be rebuilt into the + /// same parent the rest of the scenery hangs from. + private weak var podiumScene: Entity? /// When the current +1 pulse finishes, in `CACurrentMediaTime()` /// seconds. Zero when no pulse is running. private var pulseEndsAt: TimeInterval = 0 @@ -326,8 +341,9 @@ struct PodiumARViewContainer: UIViewRepresentable { var rimNudges: Int = 0 } - init(model: PodiumARModel) { + init(model: PodiumARModel, floorMap: FloorMapData) { self.model = model + self.floorMap = floorMap super.init() model.relocateHandler = { [weak self] in self?.relocate() } } @@ -488,6 +504,7 @@ struct PodiumARViewContainer: UIViewRepresentable { arView.scene.addAnchor(anchor) podiumAnchor = anchor + podiumScene = scene model.phase = .placed model.transientHint = nil @@ -511,18 +528,18 @@ struct PodiumARViewContainer: UIViewRepresentable { // MARK: - Scenery (FR-012, FR-013) - /// Wires up everything the podium does for show: the breathing steps - /// and the synthetic standings. + /// Wires up everything the podium does for show: the breathing steps, + /// the three name labels and the floor map of data locations. /// - /// The standings are invented on the spot by `SyntheticStandings` — no - /// leaderboard is read and no request is made, here or anywhere in the - /// game (FR-008, SC-005). + /// The names are invented on the spot by `SyntheticStandings`. The + /// map's pins were fetched by the **app layer** and handed in as plain + /// coordinates — no request is made here or anywhere else under + /// `ios/IPP/Game/` (FR-008, SC-005, question Q5). private func installScenery(in scene: Entity) { stepEntities = [:] stepHeights = [:] stepRungs = [:] breathTime = 0 - crawlTime = 0 for step in PodiumBuilder.Step.allCases { guard let entity = scene.findEntity(named: step.entityName) as? ModelEntity else { @@ -537,6 +554,7 @@ struct PodiumARViewContainer: UIViewRepresentable { _ = PodiumBreathing.ladders standings = StandingsDisplay.attach(to: scene, standings: SyntheticStandings.standings()) + rebuildFloorMap(in: scene) // Put the steps on their phase-zero rungs now, so the podium // appears already breathing instead of snapping into shape on the @@ -551,16 +569,45 @@ struct PodiumARViewContainer: UIViewRepresentable { stepHeights = [:] stepRungs = [:] standings = nil + floorMapEntity = nil + podiumScene = nil breathTime = 0 - crawlTime = 0 } - /// One frame of scenery: the steps breathe, the labels follow them and - /// turn to the player, the crawl marches. + // MARK: Floor map (FR-013, Phase 5C) + + /// New pins from the app layer. + /// + /// Called from `updateUIView`, so it runs on every SwiftUI update of + /// the containing view — hence the equality guard, which makes all but + /// the one update that actually changes the data free. When the podium + /// is not down yet there is nothing to rebuild: `installScenery` will + /// use the stored value. + func updateFloorMap(_ data: FloorMapData) { + guard data != floorMap else { return } + floorMap = data + guard let podiumScene else { return } + rebuildFloorMap(in: podiumScene) + } + + /// Replaces the map under the podium with one drawn from the current + /// pins. The map carries no collider and no physics body, so removing + /// and re-adding it cannot disturb a ball in flight. + private func rebuildFloorMap(in scene: Entity) { + floorMapEntity?.removeFromParent() + let map = FloorMap.make(floorMap) + scene.addChild(map) + floorMapEntity = map + } + + /// One frame of scenery: the steps breathe and the labels follow them + /// and turn to the player. /// /// None of it can affect play. The steps' colliders are swapped with /// their meshes so a ball always rests on what it looks like it is - /// resting on; the labels and the crawl have no collider at all. + /// resting on; the labels and the floor map have no collider at all. + /// The map is static — it is built at placement and never touched per + /// frame. private func stepScenery(deltaTime: TimeInterval) { guard podiumAnchor != nil else { return } @@ -572,8 +619,6 @@ struct PodiumARViewContainer: UIViewRepresentable { } guard let standings else { return } - crawlTime += deltaTime - StandingsDisplay.update(standings, at: crawlTime) seatLabels(standings) } diff --git a/ios/IPP/Game/StandingsDisplay.swift b/ios/IPP/Game/StandingsDisplay.swift index e271ffd..34ba8ec 100644 --- a/ios/IPP/Game/StandingsDisplay.swift +++ b/ios/IPP/Game/StandingsDisplay.swift @@ -6,15 +6,17 @@ import simd /// The podium's standings, as scenery (FR-013, added at Gate 5). /// -/// Two pieces, both fed entirely by `SyntheticStandings` — the game reads no -/// leaderboard and makes no network request (FR-008, SC-005): +/// **Podium labels** for places #1/#2/#3: a name and a score floating in front +/// of each step in that step's medal colour, turned toward the player every +/// frame and riding the step as it breathes (FR-012). The names come entirely +/// from `SyntheticStandings` — the game reads no leaderboard and makes no +/// network request (FR-008, SC-005). /// -/// - **Podium labels** for places #1/#2/#3: a name and a score floating in -/// front of each step in that step's medal colour, turned toward the player -/// every frame and riding the step as it breathes (FR-012). -/// - **The crawl** for places #4 and down: a Star Wars opening crawl running -/// away from the player and down through the floor beneath the podium, one -/// line per place, looping. +/// > The Star Wars crawl of places #4 and down that used to live here was +/// > **removed in Phase 5C** by owner decision: the space under the podium is +/// > now the floor map of data locations (`FloorMap`). Nothing of the crawl +/// > remains — its band, its fade ramp and its per-frame update are gone, and +/// > with them the only thing in this file that needed a clock. /// /// Everything is procedural — text meshes generated from strings, no bundled /// assets (FR-003) — and none of it has a `CollisionComponent` or a @@ -22,12 +24,10 @@ import simd /// /// ## Cost /// -/// Ten text meshes are built once when the podium is placed and then only ever -/// moved: the podium labels never change, and the crawl recycles its lines by -/// wrapping them back to the near end rather than creating entities. Fading is -/// a swap between pre-built materials on a sixteen-step ramp, so a frame of -/// crawl is a handful of transform writes and, occasionally, one material -/// assignment. Nothing is allocated in the update loop. +/// Six text meshes are built once when the podium is placed and then only ever +/// moved: the labels never change their text, so a frame costs three transform +/// writes and three billboard rotations. Nothing is allocated in the update +/// loop. @MainActor enum StandingsDisplay { @@ -35,9 +35,7 @@ enum StandingsDisplay { enum Name { static let labels = "standings_labels" - static let crawl = "standings_crawl" static func label(rank: Int) -> String { "standing_label_\(rank)" } - static func crawlLine(_ index: Int) -> String { "crawl_line_\(index)" } } // MARK: - Look constants @@ -66,99 +64,6 @@ enum StandingsDisplay { static let designFontSize: CGFloat = 0.2 } - /// The crawl's geometry and motion (FR-013). - /// - /// The band is a straight line in the podium's own frame: it starts just in - /// front of the podium at surface height and runs **away from the player - /// and downward**, so the text sinks through the floor plane as it recedes. - /// Perspective does the shrinking for free — the lines keep their real - /// size, exactly like the flat crawl plane in the films. - enum Crawl { - /// How far below horizontal the band runs, in radians. - static let tilt: Float = 20 * .pi / 180 - /// Length of the band, in metres — how far a line travels before it - /// wraps back to the near end. - static let length: Float = 1.6 - /// Where the band starts, in the podium root's frame: in front of the - /// steps (+Z is the side the podium was turned toward at placement), a - /// whisker above the surface so it does not z-fight with the floor. - static let start = SIMD3(0, 0.004, 0.30) - /// How fast the text marches away, in metres per second. At 0.09 m/s a - /// line takes ~18 s to cross the band: slow enough to read, slow enough - /// to feel like scenery rather than a ticker. - static let speed: Float = 0.09 - /// Em size of a crawl line, in metres. - static let textSize: Float = 0.024 - /// Fraction of the band spent fading in at the near end… - static let fadeIn: Float = 0.10 - /// …and the fraction at which the fade out begins, so the text - /// dissolves into the distance instead of vanishing. - static let fadeOutStart: Float = 0.55 - /// How many discrete opacities the fade uses. Pre-built materials, one - /// per level, so a fading line allocates nothing. - static let fadeSteps = 16 - - /// Unit vector along the band: away from the player, and down. - static var direction: SIMD3 { - [0, -sin(tilt), -cos(tilt)] - } - - /// The rotation that lays a line of text flat in the band. - /// - /// A text mesh is drawn in its own XY plane facing +Z. This rotation - /// about the X axis sends its **up** (+Y) along ``direction`` — so the - /// tops of the letters point away down the band, which is what makes it - /// read as a crawl receding to a vanishing point — and its face (+Z) to - /// the band's normal, tilted up toward the player. - static var orientation: simd_quatf { - simd_quatf(angle: -(.pi / 2 + tilt), axis: [1, 0, 0]) - } - - /// How far along the band line `index` sits at `time`, wrapping at the - /// far end. Pure. - /// - /// At `time == 0` the lines are spread evenly over the band, so the - /// crawl is already populated the moment the podium is placed rather - /// than trickling in one line at a time. - static func distance(index: Int, count: Int, at time: TimeInterval) -> Float { - guard count > 0, length > 0, time.isFinite else { return 0 } - let spacing = length / Float(count) - let travelled = Float(max(time, 0)) * speed + Float(index) * spacing - return travelled.truncatingRemainder(dividingBy: length) - } - - /// Where that is, in the podium root's frame. Pure. - static func position(atDistance distance: Float) -> SIMD3 { - start + direction * distance - } - - /// How opaque a line is at that distance: fades up over the first - /// ``fadeIn`` of the band, holds, then fades away to nothing at the far - /// end. Pure, and always within 0…1. - static func opacity(atDistance distance: Float) -> Float { - guard length > 0, distance.isFinite else { return 0 } - let fraction = min(max(distance / length, 0), 1) - if fadeIn > 0, fraction < fadeIn { - return fraction / fadeIn - } - if fraction > fadeOutStart, fadeOutStart < 1 { - return max(0, (1 - fraction) / (1 - fadeOutStart)) - } - return 1 - } - - /// The rung of the pre-built fade ramp an opacity lands on. Pure. - /// - /// The `isFinite` guard is not decoration: `Swift.min`/`max` propagate a - /// NaN rather than clamping it, and `Int(nan)` traps — so without it a - /// single bad frame time would crash the game rather than skip a fade. - static func fadeLevel(forOpacity opacity: Float) -> Int { - guard opacity.isFinite else { return 0 } - let clamped = min(max(opacity, 0), 1) - return min(max(Int((clamped * Float(fadeSteps)).rounded()), 0), fadeSteps) - } - } - // MARK: - What the coordinator holds on to /// One podium label and the step it rides. @@ -167,41 +72,23 @@ enum StandingsDisplay { let entity: Entity } - /// One crawl line: the pivot that moves, and the model whose material - /// carries the fade. - final class CrawlLine { - let pivot: Entity - let model: ModelEntity - var fadeLevel: Int = -1 - - init(pivot: Entity, model: ModelEntity) { - self.pivot = pivot - self.model = model - } - } - /// Everything the update loop needs, built once at placement. final class Display { let labels: [PodiumLabel] - let lines: [CrawlLine] - let fadeRamp: [UnlitMaterial] - init(labels: [PodiumLabel], lines: [CrawlLine], fadeRamp: [UnlitMaterial]) { + init(labels: [PodiumLabel]) { self.labels = labels - self.lines = lines - self.fadeRamp = fadeRamp } } // MARK: - Building - /// Hangs the labels off the steps container and the crawl off the scene - /// root, and hands back the handles the update loop drives. + /// Hangs the three labels off the steps container and hands back the + /// handles the update loop drives. /// /// The labels are children of the **steps container** (the same parent the /// trophy uses) so they inherit the podium's placement yaw and can be - /// re-seated as their step breathes. The crawl is a child of the **scene - /// root** so it stays on the surface while the steps move. + /// re-seated as their step breathes. static func attach(to scene: Entity, standings: [SyntheticStandings.Entry]) -> Display { let podium = scene.findEntity(named: PodiumBuilder.Name.steps) ?? scene @@ -219,30 +106,7 @@ enum StandingsDisplay { seat(label, on: step, height: step.height) } - // The band's start and tilt live on this one entity, so a line only has - // to slide along its parent's local +Y to travel down the band. - let crawlRoot = Entity() - crawlRoot.name = Name.crawl - crawlRoot.position = Crawl.start - crawlRoot.orientation = Crawl.orientation - scene.addChild(crawlRoot) - - let fadeRamp = makeFadeRamp(color: PodiumBuilder.Medal.gold) - var lines: [CrawlLine] = [] - for (offset, entry) in standings.dropFirst(podiumSteps.count).enumerated() { - let pivot = Entity() - pivot.name = Name.crawlLine(offset) - let model = makeTextModel( - SyntheticStandings.crawlLine(for: entry), - size: Crawl.textSize, - material: fadeRamp.last ?? UnlitMaterial(color: PodiumBuilder.Medal.gold) - ) - pivot.addChild(model) - crawlRoot.addChild(pivot) - lines.append(CrawlLine(pivot: pivot, model: model)) - } - - return Display(labels: labels, lines: lines, fadeRamp: fadeRamp) + return Display(labels: labels) } /// The three steps in podium order, so #1 lands on gold. @@ -300,18 +164,6 @@ enum StandingsDisplay { return model } - /// One material per fade level, built once. Level 0 is invisible, the last - /// level is fully opaque. - static func makeFadeRamp(color: UIColor) -> [UnlitMaterial] { - (0...Crawl.fadeSteps).map { level in - var material = UnlitMaterial(color: color) - material.blending = .transparent( - opacity: .init(floatLiteral: Float(level) / Float(Crawl.fadeSteps)) - ) - return material - } - } - // MARK: - Per-frame updates /// Puts a label back on its step's top face. Called every frame, because @@ -329,27 +181,4 @@ enum StandingsDisplay { guard dx * dx + dz * dz > 1e-8 else { return } entity.setOrientation(simd_quatf(angle: atan2(dx, dz), axis: [0, 1, 0]), relativeTo: nil) } - - /// One frame of crawl: march every line along the band, wrap the ones that - /// reached the end, and swap the fade material where the level changed. - /// - /// The band's start and tilt are baked into the crawl root's transform, so - /// a line's local position is simply `distance` along the root's own +Y — - /// which the rotation has already aimed away from the player and down. That - /// keeps this to one vector write per line. ``Crawl/position(atDistance:)`` - /// is the same point stated in the podium's frame, for the tests. - static func update(_ display: Display, at time: TimeInterval) { - let count = display.lines.count - guard count > 0 else { return } - - for (index, line) in display.lines.enumerated() { - let distance = Crawl.distance(index: index, count: count, at: time) - line.pivot.position = [0, distance, 0] - - let level = Crawl.fadeLevel(forOpacity: Crawl.opacity(atDistance: distance)) - guard level != line.fadeLevel, display.fadeRamp.indices.contains(level) else { continue } - line.fadeLevel = level - line.model.model?.materials = [display.fadeRamp[level]] - } - } } diff --git a/ios/IPP/Game/SyntheticMapPins.swift b/ios/IPP/Game/SyntheticMapPins.swift new file mode 100644 index 0000000..1b4b987 --- /dev/null +++ b/ios/IPP/Game/SyntheticMapPins.swift @@ -0,0 +1,79 @@ +import Foundation + +/// The floor map's offline sample (FR-013, Phase 5C). +/// +/// When no backend answers, the app layer hands the game this array instead of +/// real pins (see `MapPinsService.resolve`), so the map under the podium is +/// never a blank plate and the game stays fully playable with no network — +/// which is the property FR-008 and SC-005 protect. +/// +/// **Nothing here is real.** The coordinates are invented on the spot by a +/// seeded generator from four fictional cluster centres placed off the Chilean +/// coast, in water, several kilometres from any of the cities the real data +/// uses. Nobody lives at these coordinates, so an onlooker who reads the map +/// while the game is offline learns nothing about anybody. +/// +/// The clusters have deliberately different populations and spreads, so the +/// density tint (`FloorMap`) has something to show and the sample map looks +/// like the real one rather than like noise. +/// +/// Pure Foundation — no UIKit, no RealityKit, no ARKit, no networking. +enum SyntheticMapPins { + + /// One invented cluster: where it sits, how many pins it holds, and how + /// far they scatter (1σ, in degrees — 0.01° ≈ 1.1 km). + struct Cluster { + let latitude: Double + let longitude: Double + let count: Int + let sigma: Double + } + + /// Fixed by default so the sample map is the same every time it is shown. + /// The bytes spell "IPPMAP". + static let defaultSeed: UInt64 = 0x4950_504D_4150 + + /// Four clusters in open water west of Valparaíso: same latitude band as + /// the real data (so the map's aspect ratio looks familiar) but ~40–60 km + /// offshore, which puts them in the Pacific. + static let clusters: [Cluster] = [ + Cluster(latitude: -33.04, longitude: -72.20, count: 34, sigma: 0.016), + Cluster(latitude: -33.19, longitude: -72.36, count: 22, sigma: 0.011), + Cluster(latitude: -32.92, longitude: -72.41, count: 15, sigma: 0.022), + Cluster(latitude: -33.28, longitude: -72.12, count: 9, sigma: 0.008), + ] + + /// The sample pins. Same seed, same pins, always. + static func pins(seed: UInt64 = defaultSeed) -> [GeoPin] { + var generator = SyntheticStandings.Generator(seed: seed) + var pins: [GeoPin] = [] + pins.reserveCapacity(clusters.reduce(0) { $0 + max(0, $1.count) }) + + for cluster in clusters { + for _ in 0.. (Double, Double) { + let u1 = max(Double.random(in: 0..<1, using: &generator), 1e-12) + let u2 = Double.random(in: 0..<1, using: &generator) + let radius = (-2 * log(u1)).squareRoot() + let angle = 2 * Double.pi * u2 + return (radius * cos(angle), radius * sin(angle)) + } +} diff --git a/ios/IPP/Game/SyntheticStandings.swift b/ios/IPP/Game/SyntheticStandings.swift index 22a6ea5..d4f9a89 100644 --- a/ios/IPP/Game/SyntheticStandings.swift +++ b/ios/IPP/Game/SyntheticStandings.swift @@ -24,8 +24,10 @@ enum SyntheticStandings { /// One fictional entry. struct Entry: Equatable { - /// 1-based place. `1`, `2` and `3` are the podium steps; the rest go in - /// the crawl. + /// 1-based place. `1`, `2` and `3` are the podium steps and the only + /// ones displayed since Phase 5C removed the crawl; the rest of the + /// list is still generated so the three shown places are the top of a + /// real ranking rather than the whole of one. let rank: Int /// Full display name, e.g. `"Dra. Marta Ficticia"`. let name: String @@ -37,8 +39,8 @@ enum SyntheticStandings { // MARK: - Tuning - /// How many places the podium knows about: three on the steps and the rest - /// in the crawl. + /// How many places the podium knows about. Three go on the steps; the rest + /// only exist so those three sit at the top of a plausible ranking. static let defaultCount = 10 /// Fixed by default so the podium is the same every time it is placed. @@ -85,13 +87,6 @@ enum SyntheticStandings { return entries } - /// The line the Star Wars crawl shows for a place outside the podium - /// (FR-013). Spanish, and in the app's own vocabulary — the leaderboard - /// says "puntos". - static func crawlLine(for entry: Entry) -> String { - "\(entry.rank).º \(entry.name) · \(formattedPoints(entry.points)) puntos" - } - /// Points with a Spanish thousands separator: `4820` → `"4.820"`. /// /// Written out rather than delegated to `NumberFormatter` so the result diff --git a/ios/IPP/Game/TrophyTossView.swift b/ios/IPP/Game/TrophyTossView.swift index 0c170ba..3e0c8e5 100644 --- a/ios/IPP/Game/TrophyTossView.swift +++ b/ios/IPP/Game/TrophyTossView.swift @@ -15,11 +15,21 @@ import UIKit /// /// Closing the screen removes the AR container, which tears the session down /// (FR-011). The game is fully offline: this file makes no network request and -/// never touches `AppEnvironment` or the leaderboard data (FR-008). +/// never touches `AppEnvironment` or the leaderboard data (FR-008). The floor +/// map's locations arrive from outside as a plain value — see ``floorMap``. struct TrophyTossView: View { @Environment(\.dismiss) private var dismiss @Environment(\.scenePhase) private var scenePhase + /// Locations for the floor map under the podium (FR-013). + /// + /// Resolved by the **app layer** before this screen opens: either pins the + /// backend returned or the offline sample. Passing it in rather than + /// fetching it here is what keeps every file under `ios/IPP/Game/` free of + /// networking (question Q5, option A). The default means the game is still + /// launchable from nothing — it just shows the sample. + var floorMap: FloorMapData = FloorMapData(pins: SyntheticMapPins.pins(), isLive: false) + @State private var permission: ARSupport.CameraPermission = .notDetermined @State private var isAsking = false /// Guards against asking twice if the view's task runs again. @@ -57,7 +67,7 @@ struct TrophyTossView: View { ZStack { Color.black.ignoresSafeArea() - PodiumARViewContainer(model: arModel) + PodiumARViewContainer(model: arModel, floorMap: floorMap) .ignoresSafeArea() VStack(spacing: 10) { diff --git a/ios/IPP/Models/MapPin.swift b/ios/IPP/Models/MapPin.swift new file mode 100644 index 0000000..72a3e02 --- /dev/null +++ b/ios/IPP/Models/MapPin.swift @@ -0,0 +1,58 @@ +import Foundation + +/// One anonymized data location: a latitude/longitude and nothing else. +/// +/// This is the *only* shape in which location data reaches the AR mini-game. +/// The backend's `/api/v1/map-pins` payload also carries a record id and an +/// `anchorKey`; both are dropped at the app layer (``MapPinsService``) so the +/// game — which renders these on a floor plane visible to anyone standing near +/// the phone — never holds anything that could identify a record. +struct GeoPin: Equatable, Hashable, Sendable { + let latitude: Double + let longitude: Double + + init(latitude: Double, longitude: Double) { + self.latitude = latitude + self.longitude = longitude + } + + /// Both coordinates are real numbers in range. The projection filters on + /// this rather than trusting the wire. + var isUsable: Bool { + latitude.isFinite && longitude.isFinite + && abs(latitude) <= 90 && abs(longitude) <= 180 + } +} + +/// What the app layer hands the game for its floor map (FR-013). +/// +/// `isLive` is not used to decide anything — it only picks the caption, so a +/// person looking at the podium can tell real data from the offline sample +/// without opening a debugger. The game does no networking either way +/// (FR-008, Q5 option A). +struct FloorMapData: Equatable { + let pins: [GeoPin] + let isLive: Bool + + init(pins: [GeoPin], isLive: Bool) { + self.pins = pins + self.isLive = isLive + } + + static let none = FloorMapData(pins: [], isLive: false) +} + +/// Wire model for `GET /api/v1/map-pins`, the backend's public, anonymized +/// pin list. Read-only; the app never POSTs to it. +struct MapPinsResponse: Decodable { + struct Pin: Decodable { + let latitude: Double + let longitude: Double + } + + let pins: [Pin] + + var coordinates: [GeoPin] { + pins.map { GeoPin(latitude: $0.latitude, longitude: $0.longitude) } + } +} diff --git a/ios/IPP/Resources/Info.plist b/ios/IPP/Resources/Info.plist index 139c0d5..f069567 100644 --- a/ios/IPP/Resources/Info.plist +++ b/ios/IPP/Resources/Info.plist @@ -24,6 +24,8 @@ NSCameraUsageDescription IPP usa la cámara únicamente en el mini-juego de realidad aumentada "Tiro al Trofeo", para detectar una superficie y colocar el podio sobre ella. No se graban ni se envían imágenes. + NSLocalNetworkUsageDescription + IPP busca el servidor de la clínica en tu red local (Wi-Fi) para cargar y guardar las fichas, el ranking y el mapa de ubicaciones. No se contacta ningún servicio externo. UILaunchScreen UISupportedInterfaceOrientations diff --git a/ios/IPP/Services/APIPatientStore.swift b/ios/IPP/Services/APIPatientStore.swift index 20a74f3..cfeb149 100644 --- a/ios/IPP/Services/APIPatientStore.swift +++ b/ios/IPP/Services/APIPatientStore.swift @@ -4,7 +4,11 @@ import Foundation // IPP backend, which persists to Neon Postgres. The backend extracts id, RUT // and coords for indexing; the full record lives in a JSONB column. final class APIPatientStore: PatientStore { - let baseURL: URL + /// Mutable so `AppEnvironment.resolveBackend()` can re-point the app at the + /// LAN host it found at launch (Phase 5C). Written once, from the main + /// actor, before the first request — same convention as the two provider + /// closures below. + var baseURL: URL /// Closure returning the current doctor name (or nil). Read lazily so a /// rename via the leaderboard UI takes effect on the next save. var doctorNameProvider: () -> String? = { nil } diff --git a/ios/IPP/Services/AppEnvironment.swift b/ios/IPP/Services/AppEnvironment.swift index d77f182..cc73b46 100644 --- a/ios/IPP/Services/AppEnvironment.swift +++ b/ios/IPP/Services/AppEnvironment.swift @@ -9,6 +9,10 @@ final class AppEnvironment: ObservableObject { let schemaService: SchemaService /// URL of the web dashboard (map + búsqueda + feedback) embedded in-app. let webURL: URL + /// Reads the backend's anonymized pins for the mini-game's floor map. + /// Lives here, not in the game, so `ios/IPP/Game/` stays network-free + /// (FR-008, question Q5 option A). + let mapPins = MapPinsService() var store: PatientStore { apiStore } var schema: FormSchema { schemaService.schema } @@ -16,18 +20,29 @@ final class AppEnvironment: ObservableObject { @Published var lastAnchor: AnchorResponse? @Published var lastError: String? + /// What `Info.plist` asked for — `http://localhost:3334` in a stock build. + /// Kept so the launch probe can tell "the user configured a host" from + /// "nobody configured anything" (see `BackendLocator.candidates`). + private let configuredBackendURL: URL + /// The base URL every client is actually using right now. Starts at the + /// configured value and moves once, if the launch probe finds a LAN host. + @Published private(set) var backendURL: URL + init( apiStore: APIPatientStore, effectStream: EffectStreamClient, session: SessionService, schemaService: SchemaService, - webURL: URL + webURL: URL, + backendURL: URL ) { self.apiStore = apiStore self.effectStream = effectStream self.session = session self.schemaService = schemaService self.webURL = webURL + self.configuredBackendURL = backendURL + self.backendURL = backendURL // Doctor name on every save comes from the session - the username // becomes the leaderboard attribution. Read via a nonisolated, // thread-safe snapshot so the API store can call it off the main actor. @@ -54,8 +69,44 @@ final class AppEnvironment: ObservableObject { effectStream: EffectStreamClient(baseURL: url), session: SessionService(), schemaService: SchemaService(baseURL: url), - webURL: webURL + webURL: webURL, + backendURL: url + ) + } + + // MARK: - Finding the backend on the LAN (Phase 5C) + + /// Probes the candidate hosts once at launch and re-points every client at + /// whichever answers `/health` first. + /// + /// On a phone the bundled `http://localhost:3334` can never work, so this + /// is what makes the *whole* app — login, patients, field stats, schema, + /// leaderboard — reach the Mac running the backend. In the Simulator the + /// candidate list is just the configured URL, so behaviour there is + /// unchanged. + /// + /// Failure is silent and harmless: nothing moves, the app keeps the + /// configured URL, and every screen shows the offline state it always did. + func resolveBackend() async { + let candidates = BackendLocator.candidates( + configured: configuredBackendURL, + isSimulator: BackendLocator.isSimulator ) + guard let found = await BackendLocator.probe(candidates), + found != backendURL + else { return } + + apiStore.baseURL = found + effectStream.baseURL = found + schemaService.baseURL = found + backendURL = found + } + + /// Read-only fetch of the anonymized map pins for the mini-game's floor + /// map. `nil` when the backend is unreachable — the caller substitutes the + /// offline sample (`MapPinsService.resolve`). + func fetchMapPins() async -> [GeoPin]? { + await mapPins.fetch(baseURL: backendURL) } func saveAndAnchor(_ patient: Patient) async -> Patient? { diff --git a/ios/IPP/Services/BackendLocator.swift b/ios/IPP/Services/BackendLocator.swift new file mode 100644 index 0000000..32f19c1 --- /dev/null +++ b/ios/IPP/Services/BackendLocator.swift @@ -0,0 +1,108 @@ +import Foundation + +/// Finds the IPP backend on the local network at launch (Phase 5C task 5C.1). +/// +/// The bundled `BackendURL` is `http://localhost:3334`, which is right in the +/// Simulator and useless on a phone. On device the app therefore asks a short, +/// ordered list of candidates for `/health` and keeps the first one that +/// answers; `AppEnvironment.resolveBackend()` then points every client at it, +/// so login, patients, field stats, the schema and the leaderboard all follow. +/// +/// Two LAN addresses are tried because the host Mac has two interfaces on the +/// same `/24` — Ethernet `192.168.100.15` (`en10`) and Wi-Fi `192.168.100.11` +/// (`en0`) — and only the phone can say which one its subnet reaches. +/// +/// Everything about *which* URLs are tried and *in what order* is a pure +/// function (``candidates(configured:isSimulator:)``) so it is unit-tested +/// off-device; only ``probe(_:timeout:session:)`` touches the network. +enum BackendLocator { + + /// The owner's two host addresses, in the order the phone should try them. + static let lanCandidates: [URL] = [ + URL(string: "http://192.168.100.15:3334")!, + URL(string: "http://192.168.100.11:3334")!, + ] + + /// How long a single `/health` request may take before the next candidate + /// is tried. Short: on a LAN a live host answers in single-digit + /// milliseconds, and a dead one should not hold up the launch. + static let defaultTimeout: TimeInterval = 1.5 + + static var isSimulator: Bool { + #if targetEnvironment(simulator) + return true + #else + return false + #endif + } + + /// Hosts that only ever mean "this machine". + static func isLoopback(_ url: URL) -> Bool { + guard let host = url.host?.lowercased() else { return false } + return host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]" + } + + /// The candidate list, most likely first, with duplicates removed. + /// + /// - In the **Simulator** only the configured URL is tried. `localhost` + /// there is the Mac, which is exactly where the backend runs, and + /// reaching out to the LAN would be both pointless and slower. + /// - On **device**, a configured loopback URL cannot possibly work, so the + /// two LAN addresses go first and the configured URL stays at the back as + /// a last resort. + /// - A configured URL that is *not* loopback is someone deliberately + /// pointing the app somewhere, so it is tried first and the LAN addresses + /// become the fallback. + static func candidates(configured: URL, isSimulator: Bool) -> [URL] { + let ordered: [URL] + if isSimulator { + ordered = [configured] + } else if isLoopback(configured) { + ordered = lanCandidates + [configured] + } else { + ordered = [configured] + lanCandidates + } + + var seen = Set() + return ordered.filter { seen.insert($0.absoluteString).inserted } + } + + static func healthURL(for base: URL) -> URL { + base.appendingPathComponent("health") + } + + /// Asks each candidate for `/health` in turn and returns the first that + /// answers 2xx. `nil` when none does — the caller then keeps whatever base + /// URL it already had, which is the offline case. + static func probe( + _ candidates: [URL], + timeout: TimeInterval = defaultTimeout, + session: URLSession? = nil + ) async -> URL? { + let session = session ?? makeSession(timeout: timeout) + for candidate in candidates { + var request = URLRequest(url: healthURL(for: candidate)) + request.httpMethod = "GET" + request.timeoutInterval = timeout + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + do { + let (_, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, + (200..<300).contains(http.statusCode) + else { continue } + return candidate + } catch { + continue + } + } + return nil + } + + static func makeSession(timeout: TimeInterval) -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = timeout + configuration.timeoutIntervalForResource = timeout + configuration.waitsForConnectivity = false + return URLSession(configuration: configuration) + } +} diff --git a/ios/IPP/Services/EffectStreamClient.swift b/ios/IPP/Services/EffectStreamClient.swift index c9b3758..ed32c1d 100644 --- a/ios/IPP/Services/EffectStreamClient.swift +++ b/ios/IPP/Services/EffectStreamClient.swift @@ -38,7 +38,9 @@ enum EffectStreamError: Error, LocalizedError { } final class EffectStreamClient { - let baseURL: URL + /// Mutable so `AppEnvironment.resolveBackend()` can re-point the client at + /// the LAN host discovered at launch (Phase 5C). + var baseURL: URL init(baseURL: URL) { self.baseURL = baseURL diff --git a/ios/IPP/Services/MapPinsService.swift b/ios/IPP/Services/MapPinsService.swift new file mode 100644 index 0000000..4a3c76c --- /dev/null +++ b/ios/IPP/Services/MapPinsService.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Reads the backend's anonymized map pins for the AR mini-game's floor map +/// (FR-013), at the **app layer** — this is the piece that keeps +/// `ios/IPP/Game/` free of networking (FR-008, question Q5, option A). +/// +/// The game is handed a plain `FloorMapData` and cannot tell whether it came +/// from the backend or from the offline sample, so it needs no reachability +/// logic, no error states and no timeouts of its own. +/// +/// Read-only by construction: one GET, no auth headers, nothing written back. +/// The response's record ids and anchor keys are discarded here — see +/// ``GeoPin``. +struct MapPinsService { + + /// Long enough for a LAN round-trip carrying ~1000 pins, short enough that + /// a player who opens the leaderboard offline is not left waiting. + static let defaultTimeout: TimeInterval = 2.5 + + var timeout: TimeInterval = defaultTimeout + var session: URLSession? + + /// Chooses what the floor map shows. Pure — the whole fallback rule in one + /// testable place. + /// + /// An **empty** backend answer counts as a miss, not as live data: a map + /// with no pins on it looks broken, and "the backend is up but has no + /// patients yet" is exactly the demo case where the sample is better than + /// a blank plate. + static func resolve(fetched: [GeoPin]?, fallback: [GeoPin]) -> FloorMapData { + let usable = (fetched ?? []).filter(\.isUsable) + if usable.isEmpty { + return FloorMapData(pins: fallback.filter(\.isUsable), isLive: false) + } + return FloorMapData(pins: usable, isLive: true) + } + + /// `GET {baseURL}/api/v1/map-pins`. Returns `nil` on any failure — an + /// unreachable host, a non-2xx status or an undecodable body — because the + /// caller's next move is the same in all three cases. + func fetch(baseURL: URL) async -> [GeoPin]? { + var request = URLRequest(url: baseURL.appendingPathComponent("api/v1/map-pins")) + request.httpMethod = "GET" + request.timeoutInterval = timeout + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + + let session = session ?? BackendLocator.makeSession(timeout: timeout) + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, + (200..<300).contains(http.statusCode) + else { return nil } + return try JSONDecoder().decode(MapPinsResponse.self, from: data).coordinates + } catch { + return nil + } + } +} diff --git a/ios/IPP/Services/SchemaService.swift b/ios/IPP/Services/SchemaService.swift index 85644ff..f2b1e5b 100644 --- a/ios/IPP/Services/SchemaService.swift +++ b/ios/IPP/Services/SchemaService.swift @@ -9,7 +9,10 @@ import SwiftUI @MainActor final class SchemaService: ObservableObject { @Published private(set) var schema: FormSchema - private let baseURL: URL + /// Mutable so `AppEnvironment.resolveBackend()` can re-point the schema + /// refresh at the LAN host discovered at launch (Phase 5C). Set before the + /// first `refresh()`. + var baseURL: URL private static let cacheKey = "ipp.schema.v1" private static var cachedFromDisk: FormSchema? { diff --git a/ios/IPP/Views/IPPApp.swift b/ios/IPP/Views/IPPApp.swift index 6eb216f..4c3a325 100644 --- a/ios/IPP/Views/IPPApp.swift +++ b/ios/IPP/Views/IPPApp.swift @@ -13,6 +13,10 @@ struct IPPApp: App { .tint(.ippTeal) .preferredColorScheme(.light) .task { + // Find the backend before anything asks it a question: on + // device the bundled localhost URL is nobody, and the LAN + // host has to be discovered first (Phase 5C). + await env.resolveBackend() await env.schemaService.refresh() } } diff --git a/ios/IPP/Views/LeaderboardView.swift b/ios/IPP/Views/LeaderboardView.swift index 8305945..730fcb5 100644 --- a/ios/IPP/Views/LeaderboardView.swift +++ b/ios/IPP/Views/LeaderboardView.swift @@ -9,6 +9,13 @@ struct LeaderboardView: View { @State private var loading = true @State private var error: String? @State private var showingGame = false + /// Locations for the mini-game's floor map (FR-013). + /// + /// Fetched **here**, at the app layer, and handed to the game as plain + /// coordinates so that nothing under `ios/IPP/Game/` performs a request + /// (FR-008, question Q5). Starts as the offline sample, so opening the game + /// before the fetch lands shows a map rather than an empty plate. + @State private var floorMap = FloorMapData(pins: SyntheticMapPins.pins(), isLive: false) /// The AR mini-game only runs where ARKit world tracking does — elsewhere /// (Simulator, unsupported hardware) the entry point stays disabled with an @@ -77,8 +84,9 @@ struct LeaderboardView: View { } } .task { await load() } + .task { await loadFloorMap() } .fullScreenCover(isPresented: $showingGame) { - TrophyTossView() + TrophyTossView(floorMap: floorMap) } } } @@ -157,6 +165,20 @@ struct LeaderboardView: View { } loading = false } + + /// Reads the anonymized map pins for the mini-game's floor map. + /// + /// Best-effort and silent: when the backend does not answer, `resolve` + /// substitutes the offline sample and the map's own caption says so. Only + /// runs where the game can run, so a device that will never show the map + /// never makes the request. + private func loadFloorMap() async { + guard gameAvailable else { return } + floorMap = MapPinsService.resolve( + fetched: await env.fetchMapPins(), + fallback: SyntheticMapPins.pins() + ) + } } private struct LeaderboardRow: View { From 5fe214f9ff819b6a6669c2ba8354b43a3b861cab Mon Sep 17 00:00:00 2001 From: Eddie Date: Mon, 24 Aug 2026 16:05:15 -0400 Subject: [PATCH 08/10] feat(ios): drop the floor map's plate and make the dots live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner rework at Gate 5C (task 5C.3): "remove the green bounding box; just leave the data points. Make them slightly animated (randomly) — blink/grow so it looks alive; some can disappear for a few seconds, so it looks like data is changing." - The plate and its border are gone. The real desk, seen through the camera, is now the map's background; only the dots and the caption float above it. Dot opacity went up (0.75-1.0) to carry the map without a slab behind it. - New FloorMapAnimation: pure, deterministic per-dot constants drawn from the dot's own index, so the field shimmers organically and the same map always animates the same way. Each dot pulses 1.5-4 s with a random phase and a 20-40 % amplitude; 12 % of them also fade out for 2-4 s once every 16-26 s and fade back, staggered so the field never guttters. - Cost is one sin and one transform write per dot per frame. Materials are a pre-built densityStops x (fadeSteps+1) ramp, reassigned only for the ~12 % of dots that fade and only when their rung changes; a fully faded dot is disabled rather than drawn at zero. Still no collider and no physics body anywhere in the subtree, so the animation cannot touch a ball. - The shimmer clock runs continuously and is deliberately not paused with the trophy: the dots are on the ground and touch nothing, so freezing them during a celebration would read as a glitch. One tuning bug caught by the tests rather than by eye: with the first cycle range a blinking dot was absent 44 % of the time, which flickers instead of occasionally vanishing. minDropoutCycle is now 16 s, which keeps every dot present for at least three quarters of its cycle, and a test pins that ratio so the two constants must be tuned together. Tests: 257 XCTest cases green on the iPhone 17 Pro simulator (+27 for the animation, and FloorMapTests reshaped for the plate's removal). Temporary target as always, not committed. --- ios/IPP/Game/FloorMap.swift | 402 +++++++++++++++++------ ios/IPP/Game/PodiumARViewContainer.swift | 36 +- 2 files changed, 328 insertions(+), 110 deletions(-) diff --git a/ios/IPP/Game/FloorMap.swift b/ios/IPP/Game/FloorMap.swift index f2ba20b..11ddc1a 100644 --- a/ios/IPP/Game/FloorMap.swift +++ b/ios/IPP/Game/FloorMap.swift @@ -3,12 +3,12 @@ import RealityKit import UIKit import simd -/// Turns latitude/longitude into points on a square plate. Pure, no RealityKit, +/// Turns latitude/longitude into points on a square field. Pure, no RealityKit, /// no UIKit — every rule the floor map's layout depends on is here so it can be /// tested off-device (Phase 5C testing item 1). enum FloorMapProjection { - /// A bounding-box fit of one pin set onto one plate. + /// A bounding-box fit of one pin set onto one field. /// /// Equirectangular, which is the right projection for a map a metre across /// covering a few kilometres: longitude is compressed by `cos(latitude)` so @@ -23,20 +23,20 @@ enum FloorMapProjection { let longitudeScale: Double /// Plate metres per degree of latitude. **Zero** when the pin set has /// no extent (one pin, or every pin at the same coordinate), which - /// collapses the whole set onto the centre of the plate — the sane + /// collapses the whole set onto the centre of the field — the sane /// answer, and the one that cannot divide by zero. let metresPerDegree: Double /// Side of the square the pins are fitted into, in metres. let extent: Float - /// Where a pin lands on the plate, in metres, as `(x, z)` in the + /// Where a pin lands on the field, in metres, as `(x, z)` in the /// podium's own frame. /// /// North is **−Z**: the podium is turned to face the player at /// placement and +Z is the side they stand on, so higher latitudes /// belong further away from them. /// - /// Always finite and always inside the plate: an unusable pin gives the + /// Always finite and always inside the field: an unusable pin gives the /// centre, and anything the arithmetic produces is clamped to the /// half-extent, so a stray coordinate cannot fling a dot across the /// room. @@ -57,7 +57,7 @@ enum FloorMapProjection { /// /// Degenerate inputs are answered rather than rejected: an empty list, a /// single pin and a list where every pin is identical all produce a fit - /// whose `metresPerDegree` is zero, so every pin projects to the plate's + /// whose `metresPerDegree` is zero, so every pin projects to the field's /// centre and the map shows one dot in the middle instead of `NaN`. static func fit(_ pins: [GeoPin], extent: Float) -> Fit { let usable = pins.filter(\.isUsable) @@ -91,7 +91,7 @@ enum FloorMapProjection { let spanLongitude = (maxLongitude - minLongitude) * longitudeScale let span = max(spanLatitude, spanLongitude) // 1e-9° is about 0.1 mm on the ground: below this the pins are one - // point as far as a metre-wide plate is concerned. + // point as far as a metre-wide field is concerned. let metresPerDegree = span > 1e-9 ? Double(extent) / span : 0 return Fit( @@ -106,7 +106,7 @@ enum FloorMapProjection { /// Evenly thins a pin list down to at most `limit` entries, deterministically. /// /// The backend can return a thousand pins and the scene should not grow a - /// thousand entities for a plate a metre across, where they would overlap + /// thousand entities for a field a metre across, where they would overlap /// into a solid blob anyway. Sampling by *stride over the whole list* /// rather than by taking the first `limit` keeps the geographic spread — /// the seeded data arrives grouped by month and city, so a prefix would @@ -121,7 +121,7 @@ enum FloorMapProjection { } /// A density level in `0.. Dot { + var generator = SyntheticStandings.Generator(seed: seed &+ UInt64(bitPattern: Int64(index))) + + let period = TimeInterval.random( + in: Tuning.minPulsePeriod...Tuning.maxPulsePeriod, + using: &generator + ) + let phase = Float.random(in: 0..<(2 * .pi), using: &generator) + let amplitude = Float.random( + in: Tuning.minPulseAmplitude...Tuning.maxPulseAmplitude, + using: &generator + ) + let dropsOut = Double.random(in: 0..<1, using: &generator) < Tuning.dropoutFraction + let cycle = TimeInterval.random( + in: Tuning.minDropoutCycle...Tuning.maxDropoutCycle, + using: &generator + ) + // Staggered over the whole cycle, so the vanishing dots take turns + // instead of blinking out together. + let start = TimeInterval.random(in: 0.. Float { + guard time.isFinite, dot.pulsePeriod > 0 else { return 1 } + let angle = Float(time.truncatingRemainder(dividingBy: dot.pulsePeriod) / dot.pulsePeriod) + * 2 * .pi + dot.pulsePhase + return 1 + dot.pulseAmplitude * sin(angle) + } + + /// How visible the dot is right now, 0…1. Always 1 for a dot that never + /// drops out, which is the great majority of them. + static func visibility(_ dot: Dot, at time: TimeInterval) -> Float { + guard dot.dropsOut, time.isFinite, dot.dropoutCycle > 0, dot.dropoutDuration > 0 else { + return 1 + } + + let offset = (time - dot.dropoutStart).truncatingRemainder(dividingBy: dot.dropoutCycle) + let phase = offset < 0 ? offset + dot.dropoutCycle : offset + guard phase < dot.dropoutDuration else { return 1 } + + let fade = min(Tuning.fadeDuration, dot.dropoutDuration / 2) + guard fade > 0 else { return 0 } + if phase < fade { + return Float(1 - phase / fade) + } + if phase > dot.dropoutDuration - fade { + return Float((phase - (dot.dropoutDuration - fade)) / fade) + } + return 0 + } + + /// The rung of the pre-built fade ramp a visibility lands on. + /// + /// The `isFinite` guard is not decoration — `Swift.min`/`max` propagate a + /// NaN rather than clamping it and `Int(nan)` traps, the crash Phase 5B's + /// crawl fade found the hard way. + static func fadeLevel(forVisibility visibility: Float) -> Int { + guard visibility.isFinite else { return Tuning.fadeSteps } + let clamped = min(max(visibility, 0), 1) + return min(max(Int((clamped * Float(Tuning.fadeSteps)).rounded()), 0), Tuning.fadeSteps) + } +} + +/// The map of the app's data locations, on the floor under the podium (FR-013, +/// Phase 5C — it replaces the Star Wars crawl of Phase 5B). +/// +/// A field of dots floating just above the surface, one per (already +/// anonymized) data location, fitted to a metre-wide square centred on the +/// podium, tinted by how crowded each neighbourhood is, with a caption on the +/// near edge naming the source. The dots pulse and occasionally blink out and +/// back, so the field reads as live data rather than as a printed chart +/// (owner rework at Gate 5C, task 5C.3). +/// +/// > There is deliberately **no plate and no border**. An earlier version drew +/// > the dots on a translucent slab; the owner asked for the box to go, so the +/// > floor itself — the real desk, seen through the camera — is the map's +/// > background. /// /// **The game does no networking.** The pins arrive as a plain `FloorMapData` /// value built by the app layer (`MapPinsService`), which substitutes an /// offline sample when no backend answers. Nothing in this file — or anywhere /// under `ios/IPP/Game/` — knows what a URL is (FR-008, SC-005, question Q5). /// -/// No tiles, no imagery, no external map provider: the plate is a procedural -/// box and the dots are procedural cylinders, so the feature adds no asset -/// files (FR-003) and contacts nobody (SC-005). +/// No tiles, no imagery, no external map provider: the dots are procedural +/// cylinders, so the feature adds no asset files (FR-003) and contacts nobody +/// (SC-005). /// /// ## Cost, and why it cannot touch the game /// -/// Everything is built once, when the podium is placed, and then never -/// touched again — there is no per-frame update at all. One dot mesh and five -/// materials are shared by every dot. Nothing in the subtree carries a -/// `CollisionComponent` or a `PhysicsBodyComponent`, so a ball flies straight -/// through the plate and lands on the invisible floor collider underneath, -/// exactly as it did before the map existed. +/// The field is built once, when the podium is placed: one shared dot mesh and +/// a pre-built ramp of `densityStops × (fadeSteps + 1)` materials, so 260 dots +/// allocate one mesh and 45 materials between them. A frame is then one `sin` +/// and one transform write per dot, plus a material assignment **only** for the +/// ~12 % of dots that ever fade, and only when their rung actually changes — +/// the other 88 % never touch their materials at all. +/// +/// Nothing in the subtree carries a `CollisionComponent` or a +/// `PhysicsBodyComponent`, so a ball flies straight through the field and lands +/// on the invisible floor collider underneath, exactly as it did before the map +/// existed. The animation therefore cannot affect play however it moves. @MainActor enum FloorMap { enum Name { static let root = "floor_map" - static let plate = "floor_map_plate" - static let frame = "floor_map_frame" static let caption = "floor_map_caption" - static func pin(_ index: Int) -> String { "floor_map_pin_\(index)" } + static let pinPrefix = "floor_map_pin_" + static func pin(_ index: Int) -> String { "\(pinPrefix)\(index)" } } /// Same one-line-edit spirit as `TossController.Tuning`, `PodiumBreathing` /// and `StandingsDisplay.Look`. enum Look { - /// Side of the plate, in metres. A metre across puts the map well - /// outside the 30 cm podium and still fits on a desk. + /// Side of the square the dots are fitted into, in metres. A metre + /// across puts the field well outside the 30 cm podium and still fits + /// on a desk. static let side: Float = 1.00 - /// Margin between the plate's edge and the outermost dot. + /// Margin between that square's edge and the outermost dot. static let inset: Float = 0.06 - /// Thickness of the plate slab. Thin, but not zero: a zero-height box - /// z-fights with the floor from a shallow angle. - static let thickness: Float = 0.004 - /// How far the whole map floats above the anchor plane, so it never + /// How far the field floats above the anchor plane, so a dot never /// z-fights the invisible floor collider whose top face is y = 0. static let lift: Float = 0.001 - /// Width of the border showing around the plate. - static let frameWidth: Float = 0.012 - static let cornerRadius: Float = 0.02 - /// Diameter of one location dot. 12 mm on a 1 m plate subtends ~0.7° + /// Diameter of one location dot. 12 mm on a 1 m field subtends ~0.7° /// at a metre — a clearly separate dot, not a pixel. static let dotDiameter: Float = 0.012 static let dotHeight: Float = 0.0035 static let dotSegments = 10 /// Hard cap on rendered dots. The seeded backend returns ~960. static let maxDots = 260 - /// Neighbourhood radius for the density tint, in plate metres. + /// Neighbourhood radius for the density tint, in field metres. static let densityRadius: Float = 0.05 /// How many colours the tint ramp has, coolest first. static let densityStops = 5 @@ -237,53 +390,58 @@ enum FloorMap { /// height; 20° is enough to help without it looking like a signpost. static let captionLean: Float = 20 * .pi / 180 - static let plateOpacity: Float = 0.42 - static let frameOpacity: Float = 0.30 + /// Opacity of the sparsest dot, and of the most crowded one. Higher + /// than the plated version was — without a slab behind them the dots + /// carry the whole map. + static let minDotOpacity: Float = 0.75 + static let maxDotOpacity: Float = 1.0 static let captionOpacity: Float = 0.85 } - /// The plate's ground colour: the app's ink, so the dots read against it. - static let plateColor = UIColor(red: 0.07, green: 0.11, blue: 0.13, alpha: 1) + // MARK: - What the update loop holds on to + + /// The built field: the entities, their constants, and the material ramp + /// they share. Built once at placement, then only read. + final class Display { + let root: Entity + let dots: [ModelEntity] + let motion: [FloorMapAnimation.Dot] + /// `ramp[densityLevel][fadeLevel]`, pre-built so a fading dot never + /// allocates a material. + let ramp: [[UnlitMaterial]] + let density: [Int] + /// Which rung each dot is showing, so an unchanged frame costs nothing. + var levels: [Int] - /// Builds the whole map. Returns an entity to be added to the podium's - /// scene root, where it inherits the placement yaw. - static func make(_ data: FloorMapData) -> Entity { + init( + root: Entity, + dots: [ModelEntity], + motion: [FloorMapAnimation.Dot], + ramp: [[UnlitMaterial]], + density: [Int] + ) { + self.root = root + self.dots = dots + self.motion = motion + self.ramp = ramp + self.density = density + self.levels = Array(repeating: -1, count: dots.count) + } + } + + // MARK: - Building + + /// Builds the whole field. The returned `root` goes on the podium's scene + /// root, where it inherits the placement yaw. + static func make(_ data: FloorMapData, seed: UInt64 = FloorMapAnimation.Tuning.defaultSeed) -> Display { let root = Entity() root.name = Name.root root.position = [0, Look.lift, 0] - // Border first, lower, and wider — what shows around the plate is the - // frame. - let frame = ModelEntity( - mesh: .generateBox( - width: Look.side + 2 * Look.frameWidth, - height: Look.thickness * 0.6, - depth: Look.side + 2 * Look.frameWidth, - cornerRadius: Look.cornerRadius - ), - materials: [material(color: PodiumBuilder.Medal.ball, opacity: Look.frameOpacity)] - ) - frame.name = Name.frame - frame.position.y = Look.thickness * 0.3 - root.addChild(frame) - - let plate = ModelEntity( - mesh: .generateBox( - width: Look.side, - height: Look.thickness, - depth: Look.side, - cornerRadius: Look.cornerRadius - ), - materials: [material(color: plateColor, opacity: Look.plateOpacity)] - ) - plate.name = Name.plate - plate.position.y = Look.thickness / 2 - root.addChild(plate) - let sampled = FloorMapProjection.sample(data.pins, limit: Look.maxDots) let fit = FloorMapProjection.fit(sampled, extent: Look.side - 2 * Look.inset) let points = sampled.map(fit.project) - let levels = FloorMapProjection.densityLevels( + let density = FloorMapProjection.densityLevels( for: points, radius: Look.densityRadius, stops: Look.densityStops @@ -297,24 +455,39 @@ enum FloorMap { radius: Look.dotDiameter / 2, segments: Look.dotSegments ) - let dotY = Look.thickness + Look.dotHeight / 2 + + var dots: [ModelEntity] = [] + var motion: [FloorMapAnimation.Dot] = [] + dots.reserveCapacity(points.count) + motion.reserveCapacity(points.count) for (index, point) in points.enumerated() { - let level = levels.indices.contains(index) ? levels[index] : 0 - let dot = ModelEntity( - mesh: dotMesh, - materials: [ramp[min(max(level, 0), ramp.count - 1)]] - ) + let tint = ramp[min(max(density[index], 0), ramp.count - 1)] + let dot = ModelEntity(mesh: dotMesh, materials: [tint[tint.count - 1]]) dot.name = Name.pin(index) - dot.position = [point.x, dotY, point.y] + dot.position = [point.x, Look.dotHeight / 2, point.y] root.addChild(dot) + dots.append(dot) + motion.append(FloorMapAnimation.dot(index: index, seed: seed)) } root.addChild(makeCaption(pinCount: sampled.count, isLive: data.isLive)) - return root + + let display = Display( + root: root, + dots: dots, + motion: motion, + ramp: ramp, + density: density + ) + // Put every dot on its phase-zero size and opacity now, so the field + // appears already alive instead of snapping into motion on the frame + // after the podium is placed. + update(display, at: 0) + return display } - /// The caption, lying on the plate's near edge and tipped up toward the + /// The caption, lying on the field's near edge and tipped up toward the /// player. /// /// The rotation is the flat-on-the-floor case of the same construction the @@ -324,11 +497,7 @@ enum FloorMap { static func makeCaption(pinCount: Int, isLive: Bool) -> Entity { let pivot = Entity() pivot.name = Name.caption - pivot.position = [ - 0, - Look.thickness + 0.001, - Look.side / 2 - Look.inset / 2, - ] + pivot.position = [0, 0.002, Look.side / 2 - Look.inset / 2] pivot.orientation = simd_quatf(angle: -(.pi / 2 - Look.captionLean), axis: [1, 0, 0]) pivot.addChild( StandingsDisplay.makeTextModel( @@ -340,19 +509,21 @@ enum FloorMap { return pivot } - /// Coolest (sparse) to hottest (crowded): the app's brand teal warming into - /// the podium's gold, so the map is built from colours the rest of the app - /// already uses (FR-009). - static func densityRamp() -> [UnlitMaterial] { + /// `ramp[densityLevel][fadeLevel]` — the tint ramp crossed with the fade + /// ramp, built once. Coolest (sparse) to hottest (crowded) is the app's + /// brand teal warming into the podium's gold, so the map is built from + /// colours the rest of the app already uses (FR-009); within each tint, + /// level 0 is invisible and the last level is the dot at full strength. + static func densityRamp() -> [[UnlitMaterial]] { let stops = max(2, Look.densityStops) + let steps = max(1, FloorMapAnimation.Tuning.fadeSteps) return (0.. 0 else { + dot.isEnabled = false + continue + } + dot.isEnabled = true + let row = display.ramp[min(max(display.density[index], 0), display.ramp.count - 1)] + dot.model?.materials = [row[min(level, row.count - 1)]] + } + } } diff --git a/ios/IPP/Game/PodiumARViewContainer.swift b/ios/IPP/Game/PodiumARViewContainer.swift index 0517afa..49e3ac7 100644 --- a/ios/IPP/Game/PodiumARViewContainer.swift +++ b/ios/IPP/Game/PodiumARViewContainer.swift @@ -302,8 +302,13 @@ struct PodiumARViewContainer: UIViewRepresentable { /// (FR-013). private var floorMap: FloorMapData /// The built map, kept so a late fetch can swap it without disturbing - /// anything else in the scene. - private weak var floorMapEntity: Entity? + /// anything else in the scene, and so the update loop can shimmer it. + private var floorMapDisplay: FloorMap.Display? + /// The floor map's clock. Never pauses — the dots are scenery on the + /// ground, they touch nothing, and freezing them during a celebration + /// would read as a glitch rather than as the podium holding its breath + /// (FR-013, task 5C.3). + private var floorMapTime: TimeInterval = 0 /// The podium's scene root, cached so the map can be rebuilt into the /// same parent the rest of the scenery hangs from. private weak var podiumScene: Entity? @@ -569,7 +574,8 @@ struct PodiumARViewContainer: UIViewRepresentable { stepHeights = [:] stepRungs = [:] standings = nil - floorMapEntity = nil + floorMapDisplay = nil + floorMapTime = 0 podiumScene = nil breathTime = 0 } @@ -593,21 +599,24 @@ struct PodiumARViewContainer: UIViewRepresentable { /// Replaces the map under the podium with one drawn from the current /// pins. The map carries no collider and no physics body, so removing /// and re-adding it cannot disturb a ball in flight. + /// + /// The shimmer clock is deliberately **not** reset: a late fetch should + /// look like the data changing under a running animation, not like the + /// field restarting. private func rebuildFloorMap(in scene: Entity) { - floorMapEntity?.removeFromParent() - let map = FloorMap.make(floorMap) - scene.addChild(map) - floorMapEntity = map + floorMapDisplay?.root.removeFromParent() + let display = FloorMap.make(floorMap) + scene.addChild(display.root) + floorMapDisplay = display + FloorMap.update(display, at: floorMapTime) } - /// One frame of scenery: the steps breathe and the labels follow them - /// and turn to the player. + /// One frame of scenery: the steps breathe, the labels follow them and + /// turn to the player, and the floor map's dots pulse and blink. /// /// None of it can affect play. The steps' colliders are swapped with /// their meshes so a ball always rests on what it looks like it is /// resting on; the labels and the floor map have no collider at all. - /// The map is static — it is built at placement and never touched per - /// frame. private func stepScenery(deltaTime: TimeInterval) { guard podiumAnchor != nil else { return } @@ -618,6 +627,11 @@ struct PodiumARViewContainer: UIViewRepresentable { breathe() } + if let floorMapDisplay { + floorMapTime += deltaTime + FloorMap.update(floorMapDisplay, at: floorMapTime) + } + guard let standings else { return } seatLabels(standings) } From e011341a29ba130ea4b0fafa237bae9e09275fe2 Mon Sep 17 00:00:00 2001 From: Eddie Date: Mon, 24 Aug 2026 20:34:45 -0400 Subject: [PATCH 09/10] test(ios): make IPPTests permanent; read backend hosts from Info.plist Phase 6 close-out for the Tiro al Trofeo AR mini-game. - IPPTests is now a real xcodegen target hosted by the app, with the scheme wired for `xcodebuild test`. The 270 cases that were written and run per-phase against a throwaway target are committed: toss physics, rounds, best score, podium and floor-map geometry, plus the backend locator and map-pin decoding. Every suite is offline and deterministic; none needs a running backend. - The candidate backend hosts move out of Swift source into Info.plist (`BackendCandidates`), next to BackendURL/WebURL. Behaviour is unchanged; pointing a phone at another Mac is now a plist edit. A missing, empty or malformed list degrades to "use BackendURL". - Requests can no longer outrun the launch probe: resolution is a single awaitable task (`AppEnvironment.backendReady()`) and every request path awaits it, including the two views that talk to a client directly. A tap into Ranking straight from a cold launch waits ~10ms instead of sending one doomed request to localhost and self-healing on refresh. - README: a section for the mini-game (entry point, rules, offline behaviour and the live floor map), the launch-time host probe, a note on running the backend against a local Postgres, and the now-false "no ARKit/RealityKit overlay" line rewritten. --- README.md | 99 +- ios/IPP/Resources/Info.plist | 8 + ios/IPP/Services/AppEnvironment.swift | 59 +- ios/IPP/Services/BackendLocator.swift | 55 +- ios/IPP/Views/PatientFormView.swift | 3 + ios/IPP/Views/PatientListView.swift | 3 + .../AppEnvironmentBackendReadyTests.swift | 239 ++++ ios/IPPTests/BackendLocatorTests.swift | 173 +++ ios/IPPTests/BestScoreStoreTests.swift | 99 ++ ios/IPPTests/FloorMapAnimationTests.swift | 257 ++++ ios/IPPTests/FloorMapProjectionTests.swift | 283 ++++ ios/IPPTests/FloorMapTests.swift | 327 +++++ ios/IPPTests/GameRoundTests.swift | 290 +++++ ios/IPPTests/MapPinsServiceTests.swift | 89 ++ ios/IPPTests/PodiumBreathingTests.swift | 233 ++++ ios/IPPTests/PodiumBuilderTests.swift | 389 ++++++ ios/IPPTests/StandingsDisplayTests.swift | 187 +++ ios/IPPTests/SyntheticMapPinsTests.swift | 98 ++ ios/IPPTests/SyntheticStandingsTests.swift | 150 +++ ios/IPPTests/TossControllerTests.swift | 1144 +++++++++++++++++ ios/project.yml | 31 + 21 files changed, 4192 insertions(+), 24 deletions(-) create mode 100644 ios/IPPTests/AppEnvironmentBackendReadyTests.swift create mode 100644 ios/IPPTests/BackendLocatorTests.swift create mode 100644 ios/IPPTests/BestScoreStoreTests.swift create mode 100644 ios/IPPTests/FloorMapAnimationTests.swift create mode 100644 ios/IPPTests/FloorMapProjectionTests.swift create mode 100644 ios/IPPTests/FloorMapTests.swift create mode 100644 ios/IPPTests/GameRoundTests.swift create mode 100644 ios/IPPTests/MapPinsServiceTests.swift create mode 100644 ios/IPPTests/PodiumBreathingTests.swift create mode 100644 ios/IPPTests/PodiumBuilderTests.swift create mode 100644 ios/IPPTests/StandingsDisplayTests.swift create mode 100644 ios/IPPTests/SyntheticMapPinsTests.swift create mode 100644 ios/IPPTests/SyntheticStandingsTests.swift create mode 100644 ios/IPPTests/TossControllerTests.swift diff --git a/README.md b/README.md index 33b1893..acd5512 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,9 @@ size the radius of affectation, and act locally. │ ├── Models/ # Patient, FormSchema, FieldStats, ... │ ├── Services/ # APIPatientStore, SessionService, Wallet, SchemaService, AppEnvironment │ ├── Components/ # AddressPicker (CoreLocation), MultiSelectAutocomplete +│ ├── Game/ # Tiro al Trofeo - ARKit/RealityKit podium mini-game │ └── Views/ # Home, DynamicForm (StatCaption), Leaderboard, WebDashboard, Theme +├── ios/IPPTests/ # unit tests - game logic, geometry, backend locator (no backend needed) ├── web/src/ # Vite + React dashboard │ └── components/ # MapView, MapFilters, AnnotationsLayer, DrawingController, Feedback, PinVerify └── cardano/ # EffectStream workspace - local Cardano devnet + sync @@ -112,7 +114,9 @@ areas** ([AnnotationsLayer](web/src/components/AnnotationsLayer.tsx), [DrawingController](web/src/components/DrawingController.tsx), [AnnotationsList](web/src/components/AnnotationsList.tsx)) to mark interventions - the manual planning layer that turns a cluster into "assign a specialist here." -(Again: location-based AR anchored through GPS, not a camera overlay.) +(Again: this layer is anchored through GPS, not through the camera - the one +camera-based piece in IPP is the +[Tiro al Trofeo](#tiro-al-trofeo---camera-based-ar-mini-game) mini-game.) ### Engine @@ -147,6 +151,48 @@ Search actions are logged via `POST /api/v1/events`; field/record counts are computed from the stored data. Each account also has a Cardano wallet address shown in the app, tying the contributor to an on-chain identity. +### Tiro al Trofeo - camera-based AR mini-game + +The leaderboard has a **"Jugar"** entry point that opens **Tiro al Trofeo**, a +single-player ARKit/RealityKit mini-game built around the podium the ranking +already draws in gold/silver/bronze +([ios/IPP/Game/](ios/IPP/Game/), opened from +[LeaderboardView.swift](ios/IPP/Views/LeaderboardView.swift)). + +> **Screenshot / GIF placeholder** - a short capture of a round (place the +> podium → flick → make → summary) will be added here. + +- **Place it.** Scan a desk or the floor, tap a detected horizontal plane, and a + procedural podium appears - three steps in the leaderboard's exact medal + colours plus a trophy cup. No 3D asset files: everything is generated in code. +- **Play it.** Flick upward from anywhere on the screen; the ball launches from + where your finger started, aimed where the phone points. Touching the cup + scores **+1**, landing inside scores **+10**, and the cup hops to a different + step after every make. Rounds are 60 s with a countdown, an end-of-round + summary, and a **device-local** best score. +- **Watch it.** The steps slowly breathe, the top-3 places float above them as + labels, and a field of dots on the floor around the podium maps where the + app's records are, pulsing so it reads as live data. +- **The game awards no leaderboard points and writes nothing.** It never touches + the ranking, never posts an event, and its only persistence is one integer in + `UserDefaults`. The game module itself makes **zero** network requests. +- **Offline behaviour.** The one thing that is live is the floor map: the app + layer (not the game) reads the public, anonymized `GET /api/v1/map-pins` and + hands the game plain coordinates. With the backend up the caption reads + **"Datos en vivo · N ubicaciones"**; with it down or unreachable the app + substitutes a synthetic offline sample and the caption reads **"Datos de + ejemplo · N ubicaciones"**. The game is fully playable either way. +- **Permissions.** The camera is requested the first time you open the game - + never at app launch - and denying it shows a Spanish explanation with a + shortcut to Ajustes. On devices without ARKit world tracking (and in the + Simulator) the "Jugar" row is disabled with a label saying why; the rest of + the app is unaffected. + +Game logic that does not need a camera - toss physics, rounds, best score, +podium and floor-map geometry - is covered by unit tests in +[ios/IPPTests/](ios/IPPTests/) (`xcodebuild test`, see +[iOS app](#ios-app)). + ## Cardano anchor The [`cardano/`](cardano/) workspace runs a local Cardano devnet on the @@ -297,6 +343,32 @@ curl http://localhost:3334/health Schema is created on startup (idempotent `CREATE TABLE IF NOT EXISTS`). +#### Running against a local Postgres instead of Neon + +The backend targets Neon, and three things bite if you point it at a local +database instead. None of them is fixed in code (a fix would touch the +production path); they are written down here so the next person does not +rediscover them: + +- **Postgres must serve TLS.** [backend/src/db.ts](backend/src/db.ts) passes + `ssl: "require"` as a postgres.js *client option*, which overrides any + `sslmode` in `DATABASE_URL`; unlike `prefer`, `require` never falls back to + plaintext, so a stock `docker run postgres:16` fails the handshake. A + self-signed certificate is enough (`require` does not verify the chain): + generate `server.crt`/`server.key`, copy them into the container's `PGDATA` + (owner `postgres`, key mode `600`), `ALTER SYSTEM SET ssl = on`, and restart + the container. +- **`scripts/seed-cities.ts` no longer works.** It POSTs `/api/v1/patients` + with only a `Content-Type` header, but that route is behind `requireDoctor` + and every row comes back `401 missing auth headers`. The script predates the + signed-request auth ([backend/src/auth.ts](backend/src/auth.ts)). +- **`scripts/seed-year.ts` needs a column the schema no longer creates.** Its + INSERT still lists the legacy plaintext `passcode`, while `initSchema` now + creates only `passcode_hash`. One + `ALTER TABLE patients ADD COLUMN IF NOT EXISTS passcode TEXT` before seeding + makes it run; it writes straight to Postgres, so it needs no auth and is the + richer data set anyway. + ### Cardano devnet (for `CHAIN=cardano`) ```bash @@ -324,12 +396,28 @@ brew install xcodegen # one-time cd ios xcodegen generate open IPP.xcodeproj # pick an iPhone simulator, ⌘R + +# unit tests (no backend needed - every suite is offline and deterministic) +xcodebuild test -scheme IPP -destination 'platform=iOS Simulator,name=iPhone 17 Pro' ``` The app talks to `http://localhost:3334` and embeds the web dashboard at `http://localhost:5174` (both set via `Info.plist`: `BackendURL`, `WebURL`). Demo logins: `user01`…`user10` / `pass01`…`pass10`. +**Finding the backend from a real iPhone.** `localhost` is the phone itself, so +a device build cannot use `BackendURL` as-is. At launch the app asks each host +in the `Info.plist` array **`BackendCandidates`** for `/health` (1.5 s each, in +order) and points every client - login, patients, field stats, schema, +leaderboard, map pins - at the first one that answers, falling back to +`BackendURL` if none does +([ios/IPP/Services/BackendLocator.swift](ios/IPP/Services/BackendLocator.swift)). +To run against your own Mac, put its LAN address in that array - a plist edit, +no Swift change. In the Simulator the list is skipped and `BackendURL` is used +directly. Requests wait for that resolution, so nothing can leave with a +half-resolved URL; when nothing is reachable the first request pays the probe +timeout once and then the app behaves offline as before. + ## Doctor authentication Doctor-scope endpoints require a **signed request**: the client signs @@ -362,9 +450,12 @@ is the long-form write-up of the engineering and use-cases. ## What's intentionally not done -- **AR is location-based, not camera-based.** There is no ARKit/RealityKit - overlay; the augmentation is anchored to place through GPS - the - location-aware stats and map planning layer. +- **The clinical AR is location-based, not camera-based.** Everything that + augments the *work* - the local/país/mundo stat lines and the map planning + layer - is anchored to place through GPS, not to a camera feed. The only + camera-based AR in IPP is the [Tiro al Trofeo](#tiro-al-trofeo---camera-based-ar-mini-game) + mini-game on the leaderboard, which is a game and nothing else: it reads no + clinical record, awards no points and writes nothing. - **Demo accounts ship fixed seeds** - fine for a demo, but a real deployment needs per-user generated keys (see Roadmap). - **No smart-contract token mint** - the chain layer is metadata anchoring only. diff --git a/ios/IPP/Resources/Info.plist b/ios/IPP/Resources/Info.plist index f069567..519875c 100644 --- a/ios/IPP/Resources/Info.plist +++ b/ios/IPP/Resources/Info.plist @@ -34,6 +34,14 @@ BackendURL http://localhost:3334 + + BackendCandidates + + http://192.168.100.15:3334 + http://192.168.100.11:3334 + WebURL http://localhost:5174 NSAppTransportSecurity diff --git a/ios/IPP/Services/AppEnvironment.swift b/ios/IPP/Services/AppEnvironment.swift index cc73b46..0bc8705 100644 --- a/ios/IPP/Services/AppEnvironment.swift +++ b/ios/IPP/Services/AppEnvironment.swift @@ -28,6 +28,20 @@ final class AppEnvironment: ObservableObject { /// configured value and moves once, if the launch probe finds a LAN host. @Published private(set) var backendURL: URL + /// The one and only backend resolution, kept so that anything that needs a + /// URL can *wait* for it instead of racing it (question Q8). + private var resolution: Task? + + /// What the resolution actually asks the network. A stored closure only so + /// a test can hold resolution open and prove that dependent work waits; + /// production never replaces it. + var probeBackend: @Sendable (_ configured: URL, _ isSimulator: Bool) async -> URL? = { + configured, isSimulator in + await BackendLocator.probe( + BackendLocator.candidates(configured: configured, isSimulator: isSimulator) + ) + } + init( apiStore: APIPatientStore, effectStream: EffectStreamClient, @@ -88,11 +102,35 @@ final class AppEnvironment: ObservableObject { /// Failure is silent and harmless: nothing moves, the app keeps the /// configured URL, and every screen shows the offline state it always did. func resolveBackend() async { - let candidates = BackendLocator.candidates( - configured: configuredBackendURL, - isSimulator: BackendLocator.isSimulator - ) - guard let found = await BackendLocator.probe(candidates), + await backendReady() + } + + /// Waits until the backend URL is settled, starting the probe if nobody + /// has yet, and returns immediately once it is (question Q8). + /// + /// Every request in the app goes through this first, so the launch window + /// in which a screen could fire a request at the *unresolved* URL is + /// closed: a user who taps straight into Ranking from a cold launch waits + /// out the probe (~10 ms when the first host answers) instead of sending + /// one doomed request to `localhost` and self-healing on refresh. + /// + /// Resolution happens exactly once per app run — the first caller starts + /// the task, everyone else awaits the same one — so this is free after + /// launch. + func backendReady() async { + if let resolution { + return await resolution.value + } + let task = Task { @MainActor [weak self] in + guard let self else { return } + await self.performResolution() + } + resolution = task + await task.value + } + + private func performResolution() async { + guard let found = await probeBackend(configuredBackendURL, BackendLocator.isSimulator), found != backendURL else { return } @@ -106,10 +144,12 @@ final class AppEnvironment: ObservableObject { /// map. `nil` when the backend is unreachable — the caller substitutes the /// offline sample (`MapPinsService.resolve`). func fetchMapPins() async -> [GeoPin]? { - await mapPins.fetch(baseURL: backendURL) + await backendReady() + return await mapPins.fetch(baseURL: backendURL) } func saveAndAnchor(_ patient: Patient) async -> Patient? { + await backendReady() do { guard let wallet = session.wallet else { lastError = "Inicia sesión para guardar y anclar." @@ -132,16 +172,19 @@ final class AppEnvironment: ObservableObject { } func fetchLeaderboard() async throws -> [LeaderboardEntry] { - try await apiStore.fetchLeaderboard() + await backendReady() + return try await apiStore.fetchLeaderboard() } /// Records a search for ranking points (+10). Best-effort, viewer-safe. func recordSearch() async { + await backendReady() await apiStore.logSearchEvent() } /// Per-field comparison stats for the patient form (nil for viewers/errors). func fetchFieldStats(lat: Double?, lng: Double?) async -> FieldStatsBundle? { - await apiStore.fetchFieldStats(lat: lat, lng: lng) + await backendReady() + return await apiStore.fetchFieldStats(lat: lat, lng: lng) } } diff --git a/ios/IPP/Services/BackendLocator.swift b/ios/IPP/Services/BackendLocator.swift index 32f19c1..416c20b 100644 --- a/ios/IPP/Services/BackendLocator.swift +++ b/ios/IPP/Services/BackendLocator.swift @@ -8,20 +8,44 @@ import Foundation /// answers; `AppEnvironment.resolveBackend()` then points every client at it, /// so login, patients, field stats, the schema and the leaderboard all follow. /// -/// Two LAN addresses are tried because the host Mac has two interfaces on the -/// same `/24` — Ethernet `192.168.100.15` (`en10`) and Wi-Fi `192.168.100.11` -/// (`en0`) — and only the phone can say which one its subnet reaches. +/// The candidate hosts live in `Info.plist` under ``candidatesInfoKey``, next +/// to `BackendURL` and `WebURL` — **not** in this file (question Q7). A demo +/// Mac usually offers more than one address for the same server (an Ethernet +/// and a Wi-Fi interface on the same `/24`, say), and only the phone can say +/// which one its subnet actually reaches, so the list is ordered and tried in +/// turn. Pointing the app at a different machine is a plist edit, not a source +/// edit, and an empty or missing list simply means "only use `BackendURL`". /// /// Everything about *which* URLs are tried and *in what order* is a pure -/// function (``candidates(configured:isSimulator:)``) so it is unit-tested +/// function (``candidates(configured:isSimulator:lan:)``) so it is unit-tested /// off-device; only ``probe(_:timeout:session:)`` touches the network. enum BackendLocator { - /// The owner's two host addresses, in the order the phone should try them. - static let lanCandidates: [URL] = [ - URL(string: "http://192.168.100.15:3334")!, - URL(string: "http://192.168.100.11:3334")!, - ] + /// `Info.plist` key holding the ordered array of candidate base URLs. + static let candidatesInfoKey = "BackendCandidates" + + /// Turns the raw `Info.plist` value into URLs, ignoring anything unusable. + /// + /// Pure, so a malformed plist is a test case rather than a crash: a missing + /// key, a wrong type, an empty array and junk strings all degrade to "no + /// candidates", which just leaves the configured `BackendURL` in charge. + static func parseCandidates(_ raw: Any?) -> [URL] { + guard let strings = raw as? [String] else { return [] } + return strings.compactMap { string in + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let url = URL(string: trimmed), + url.scheme != nil, + url.host != nil + else { return nil } + return url + } + } + + /// The candidate hosts this build was configured with, in order. + static var lanCandidates: [URL] { + parseCandidates(Bundle.main.object(forInfoDictionaryKey: candidatesInfoKey)) + } /// How long a single `/health` request may take before the next candidate /// is tried. Short: on a LAN a live host answers in single-digit @@ -53,14 +77,21 @@ enum BackendLocator { /// - A configured URL that is *not* loopback is someone deliberately /// pointing the app somewhere, so it is tried first and the LAN addresses /// become the fallback. - static func candidates(configured: URL, isSimulator: Bool) -> [URL] { + /// + /// `lan` defaults to the `Info.plist` list; it is a parameter only so the + /// ordering can be tested against a fixed list. + static func candidates( + configured: URL, + isSimulator: Bool, + lan: [URL] = BackendLocator.lanCandidates + ) -> [URL] { let ordered: [URL] if isSimulator { ordered = [configured] } else if isLoopback(configured) { - ordered = lanCandidates + [configured] + ordered = lan + [configured] } else { - ordered = [configured] + lanCandidates + ordered = [configured] + lan } var seen = Set() diff --git a/ios/IPP/Views/PatientFormView.swift b/ios/IPP/Views/PatientFormView.swift index 1fa3c24..b8aa42a 100644 --- a/ios/IPP/Views/PatientFormView.swift +++ b/ios/IPP/Views/PatientFormView.swift @@ -255,6 +255,9 @@ struct PatientFormView: View { verifyResult = nil defer { verifying = false } do { + // Straight to the client, so the wait for the launch probe has to + // be explicit here (question Q8). + await env.backendReady() verifyResult = try await env.effectStream.verify(rut: patient.rut) } catch { verifyError = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription diff --git a/ios/IPP/Views/PatientListView.swift b/ios/IPP/Views/PatientListView.swift index aea12a8..fc1319a 100644 --- a/ios/IPP/Views/PatientListView.swift +++ b/ios/IPP/Views/PatientListView.swift @@ -53,6 +53,9 @@ struct PatientListView: View { } private func refresh() async { + // Straight to the store, so the wait for the launch probe has to be + // explicit here (question Q8). + await env.backendReady() if let list = try? await env.store.list() { patients = list } diff --git a/ios/IPPTests/AppEnvironmentBackendReadyTests.swift b/ios/IPPTests/AppEnvironmentBackendReadyTests.swift new file mode 100644 index 0000000..e61706a --- /dev/null +++ b/ios/IPPTests/AppEnvironmentBackendReadyTests.swift @@ -0,0 +1,239 @@ +import Foundation +import XCTest + +@testable import IPP + +/// Question Q8 — the launch window in which a request could leave with the +/// *unresolved* backend URL. +/// +/// Before the fix, `resolveBackend()` was started and forgotten: a user who +/// tapped straight into Ranking within the probe's window sent one request to +/// the bundled `localhost` and got `-1004` before the app self-healed. The fix +/// is `backendReady()`, awaited by every request path. +/// +/// These tests hold the probe open on purpose, so "the request waited" is a +/// fact about ordering rather than a race that happens to come out right. +@MainActor +final class AppEnvironmentBackendReadyTests: XCTestCase { + + /// Nothing listens here, in either direction — a request to `resolved` + /// fails instantly with "connection refused" rather than waiting on a + /// timeout, which keeps these tests fast and offline. + private let configured = URL(string: "http://127.0.0.1:2")! + private let resolved = URL(string: "http://127.0.0.1:1")! + + private func makeEnvironment() -> AppEnvironment { + AppEnvironment( + apiStore: APIPatientStore(baseURL: configured), + effectStream: EffectStreamClient(baseURL: configured), + session: SessionService(), + schemaService: SchemaService(baseURL: configured), + webURL: URL(string: "http://127.0.0.1:3")!, + backendURL: configured + ) + } + + /// Installs a probe that answers only once `gate.release()` is called. + private func gate(_ env: AppEnvironment, answering answer: URL?) -> ProbeGate { + let gate = ProbeGate() + env.probeBackend = { _, _ in + await gate.noteProbe() + await gate.wait() + return answer + } + return gate + } + + /// Enough hops for anything that was *not* waiting to have finished. + private func settle() async { + for _ in 0..<20 { await Task.yield() } + } + + // MARK: - The window itself + + func testBackendReadyDoesNotReturnUntilTheProbeHasAnswered() async { + let env = makeEnvironment() + let gate = gate(env, answering: resolved) + let done = Flag() + + let waiter = Task { @MainActor in + await env.backendReady() + done.value = true + } + await settle() + + XCTAssertFalse(done.value, "backendReady() returned while the probe was still in flight") + XCTAssertEqual(env.backendURL, configured, "the URL moved before the probe answered") + + await gate.release() + await waiter.value + + XCTAssertTrue(done.value) + XCTAssertEqual(env.backendURL, resolved) + XCTAssertEqual(env.apiStore.baseURL, resolved) + XCTAssertEqual(env.effectStream.baseURL, resolved) + XCTAssertEqual(env.schemaService.baseURL, resolved) + } + + func testARequestStartedMidProbeWaitsForItRatherThanUsingTheStaleURL() async { + // This is the exact Q8 scenario: an already-authenticated user taps a + // data screen while the launch probe is still running. Without the + // await, this call would have completed against `configured` long + // before the gate opened. + let env = makeEnvironment() + let gate = gate(env, answering: resolved) + let done = Flag() + + let request = Task { @MainActor in + _ = await env.fetchMapPins() + done.value = true + } + await settle() + + XCTAssertFalse(done.value, "a map-pin fetch outran the probe") + XCTAssertEqual(env.backendURL, configured) + + await gate.release() + await request.value + + XCTAssertTrue(done.value) + XCTAssertEqual(env.backendURL, resolved, "the fetch ran against the resolved URL") + let probes = await gate.probeCount + XCTAssertEqual(probes, 1) + } + + func testTheLeaderboardFetchAlsoWaits() async { + // The screen the owner actually hit the -1004 on. + let env = makeEnvironment() + let gate = gate(env, answering: resolved) + let done = Flag() + + let request = Task { @MainActor in + _ = try? await env.fetchLeaderboard() + done.value = true + } + await settle() + + XCTAssertFalse(done.value, "the leaderboard fetch outran the probe") + + await gate.release() + await request.value + XCTAssertEqual(env.backendURL, resolved) + } + + // MARK: - Resolution happens once + + func testConcurrentCallersShareASingleProbe() async { + let env = makeEnvironment() + let gate = gate(env, answering: resolved) + + let waiters = (0..<8).map { _ in + Task { @MainActor in await env.backendReady() } + } + await settle() + await gate.release() + for waiter in waiters { await waiter.value } + + let probes = await gate.probeCount + XCTAssertEqual(probes, 1, "the launch probe must run once per app run, not once per caller") + XCTAssertEqual(env.backendURL, resolved) + } + + func testOnceResolvedFurtherCallsReturnImmediatelyAndReProbeNothing() async { + let env = makeEnvironment() + let gate = gate(env, answering: resolved) + + let first = Task { @MainActor in await env.backendReady() } + await gate.release() + await first.value + + // The gate is open now, so a second probe *would* succeed — the point + // is that it never happens. + await env.backendReady() + await env.resolveBackend() + _ = await env.fetchMapPins() + + let probes = await gate.probeCount + XCTAssertEqual(probes, 1) + } + + func testResolveBackendIsJustTheLaunchSpellingOfBackendReady() async { + // `IPPApp` still calls `resolveBackend()`; it must be the same single + // resolution the request paths await. + let env = makeEnvironment() + let gate = gate(env, answering: resolved) + + let launch = Task { @MainActor in await env.resolveBackend() } + let request = Task { @MainActor in await env.backendReady() } + await settle() + await gate.release() + await launch.value + await request.value + + let probes = await gate.probeCount + XCTAssertEqual(probes, 1) + XCTAssertEqual(env.backendURL, resolved) + } + + // MARK: - Nothing reachable + + func testAFailedProbeLeavesTheConfiguredURLInPlaceAndIsNotRetried() async { + // Offline is a sanctioned outcome, not an error: every screen shows + // the offline state it always did, and the app does not re-probe on + // each request (which would make every later request pay the timeout). + let env = makeEnvironment() + let gate = gate(env, answering: nil) + + let waiter = Task { @MainActor in await env.backendReady() } + await gate.release() + await waiter.value + + XCTAssertEqual(env.backendURL, configured) + XCTAssertEqual(env.apiStore.baseURL, configured) + + await env.backendReady() + let probes = await gate.probeCount + XCTAssertEqual(probes, 1) + } + + func testAProbeAnsweringTheURLWeAlreadyHaveChangesNothing() async { + let env = makeEnvironment() + let gate = gate(env, answering: configured) + + let waiter = Task { @MainActor in await env.backendReady() } + await gate.release() + await waiter.value + + XCTAssertEqual(env.backendURL, configured) + XCTAssertEqual(env.schemaService.baseURL, configured) + } +} + +// MARK: - Helpers + +/// A probe that answers only when the test says so. +private actor ProbeGate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + private(set) var probeCount = 0 + + func noteProbe() { probeCount += 1 } + + func wait() async { + if isOpen { return } + await withCheckedContinuation { waiters.append($0) } + } + + func release() { + isOpen = true + let pending = waiters + waiters = [] + for continuation in pending { continuation.resume() } + } +} + +/// A main-actor box, so "did that task finish?" is readable without racing. +@MainActor +private final class Flag { + var value = false +} diff --git a/ios/IPPTests/BackendLocatorTests.swift b/ios/IPPTests/BackendLocatorTests.swift new file mode 100644 index 0000000..19fec98 --- /dev/null +++ b/ios/IPPTests/BackendLocatorTests.swift @@ -0,0 +1,173 @@ +import Foundation +import XCTest + +@testable import IPP + +/// Phase 5C task 5C.1: which backend the phone tries, and in what order. +/// Phase 6 (question Q7): *where the list comes from* — `Info.plist`, not +/// source — is now part of the contract, so it is asserted here too. +/// +/// Deliberately **no LAN address literals in this file**: the ordering cases +/// run against a synthetic pair, and the bundle case reads the expected values +/// out of `Info.plist` itself. That way the suite says nothing about anybody's +/// house, and it keeps passing when the plist is edited for another network. +/// +/// Only the ordering and the parsing are tested — that is the whole of the +/// logic. The probe itself is one `URLSession` call per candidate and is +/// exercised for real by the device gate (5C-g2) rather than pretended at here. +final class BackendLocatorTests: XCTestCase { + + private let localhost = URL(string: "http://localhost:3334")! + /// Stand-ins for "the first host to try" and "the second host to try". + private let first = URL(string: "http://10.1.2.3:3334")! + private let second = URL(string: "http://10.1.2.4:3334")! + private var pair: [URL] { [first, second] } + + // MARK: - Where the list comes from (Q7) + + func testTheCandidateListIsReadFromInfoPlistRatherThanFromSource() { + // The suite is hosted by the app, so `Bundle.main` is the app bundle + // and this is the very array a device build would probe. + let raw = Bundle.main.object(forInfoDictionaryKey: BackendLocator.candidatesInfoKey) + let declared = raw as? [String] + XCTAssertNotNil( + declared, + "Info.plist must carry a \(BackendLocator.candidatesInfoKey) array of host URLs" + ) + XCTAssertFalse(declared?.isEmpty ?? true, "a build with no candidates can never find a LAN host") + + XCTAssertEqual( + BackendLocator.lanCandidates.map(\.absoluteString), + declared, + "the probed list must be exactly the plist's, in the plist's order" + ) + } + + func testEveryCandidateInThisBuildIsAUsablePlainURL() { + for url in BackendLocator.lanCandidates { + XCTAssertNotNil(url.scheme, "\(url) has no scheme") + XCTAssertNotNil(url.host, "\(url) has no host") + XCTAssertFalse( + BackendLocator.isLoopback(url), + "\(url) is loopback — it can never answer on a phone, so it belongs in BackendURL" + ) + } + } + + func testAMissingOrMalformedPlistEntryMeansNoCandidatesRatherThanACrash() { + XCTAssertEqual(BackendLocator.parseCandidates(nil), []) + XCTAssertEqual(BackendLocator.parseCandidates([String]()), []) + XCTAssertEqual(BackendLocator.parseCandidates("http://10.1.2.3:3334"), [], "a bare string is not a list") + XCTAssertEqual(BackendLocator.parseCandidates([1, 2, 3]), [], "a list of non-strings is not a list of URLs") + XCTAssertEqual(BackendLocator.parseCandidates(["", " ", "not a url", "/relative/path"]), []) + } + + func testParsingKeepsOrderTrimsWhitespaceAndDropsOnlyTheJunk() { + XCTAssertEqual( + BackendLocator.parseCandidates([ + " http://10.1.2.3:3334 ", + "nonsense", + "http://10.1.2.4:3334", + ]), + pair + ) + } + + // MARK: - Ordering + + func testTheSimulatorOnlyEverTriesTheConfiguredURL() { + // localhost in the Simulator *is* the Mac running the backend, so + // reaching for the LAN would be slower and pointless. + XCTAssertEqual( + BackendLocator.candidates(configured: localhost, isSimulator: true, lan: pair), + [localhost] + ) + } + + func testOnDeviceTheConfiguredCandidatesComeFirstInTheirDeclaredOrder() { + // The bundled localhost cannot possibly answer on a phone, so it goes + // last rather than being dropped — if someone ever runs the backend on + // the device itself, it still works. + XCTAssertEqual( + BackendLocator.candidates(configured: localhost, isSimulator: false, lan: pair), + [first, second, localhost] + ) + } + + func testWithNoCandidatesConfiguredTheAppJustUsesBackendURL() { + XCTAssertEqual( + BackendLocator.candidates(configured: localhost, isSimulator: false, lan: []), + [localhost] + ) + } + + func testADeliberatelyConfiguredHostWinsAndIsNotDuplicated() { + // Someone pointing BackendURL at a real host means it, so it is tried + // first; and when that host is already one of the candidates it must + // appear once, not twice. + XCTAssertEqual( + BackendLocator.candidates(configured: second, isSimulator: false, lan: pair), + [second, first] + ) + + let elsewhere = URL(string: "http://10.0.0.7:3334")! + XCTAssertEqual( + BackendLocator.candidates(configured: elsewhere, isSimulator: false, lan: pair), + [elsewhere, first, second] + ) + } + + func testTheDefaultCandidateListIsTheBundlesOne() { + // The `lan:` parameter exists for these tests; production callers omit + // it and must get the plist's list. + XCTAssertEqual( + BackendLocator.candidates(configured: localhost, isSimulator: false), + BackendLocator.lanCandidates + [localhost] + ) + } + + func testLoopbackIsRecognisedInEveryFormAndPrivateIPsAreNot() { + for string in [ + "http://localhost:3334", + "http://LOCALHOST:3334", + "http://127.0.0.1:3334", + ] { + XCTAssertTrue(BackendLocator.isLoopback(URL(string: string)!), string) + } + for string in [ + "http://10.1.2.3:3334", + "http://10.0.0.7:3334", + "https://api.example.com", + ] { + XCTAssertFalse(BackendLocator.isLoopback(URL(string: string)!), string) + } + } + + // MARK: - Probing + + func testTheHealthPathIsAppendedWithoutDoublingSlashes() { + XCTAssertEqual( + BackendLocator.healthURL(for: first).absoluteString, + "http://10.1.2.3:3334/health" + ) + XCTAssertEqual( + BackendLocator.healthURL(for: URL(string: "http://10.1.2.3:3334/")!).absoluteString, + "http://10.1.2.3:3334/health" + ) + } + + func testTheProbeTimeoutIsShortEnoughNotToStallTheLaunch() { + // Every candidate × the timeout is the worst case a player waits + // before the app gives up and shows its offline state. Q8 made the + // first request wait for this, so the bound now matters twice over. + let worstCase = BackendLocator.defaultTimeout + * Double(BackendLocator.lanCandidates.count + 1) + XCTAssertLessThanOrEqual(worstCase, 5) + XCTAssertGreaterThan(BackendLocator.defaultTimeout, 0.5, "a busy LAN needs some slack") + } + + func testAnEmptyCandidateListResolvesToNothingWithoutTouchingTheNetwork() async { + let found = await BackendLocator.probe([], timeout: 0.1) + XCTAssertNil(found) + } +} diff --git a/ios/IPPTests/BestScoreStoreTests.swift b/ios/IPPTests/BestScoreStoreTests.swift new file mode 100644 index 0000000..c5b2bc5 --- /dev/null +++ b/ios/IPPTests/BestScoreStoreTests.swift @@ -0,0 +1,99 @@ +import XCTest + +@testable import IPP + +/// Throwaway harness used during Phase 4 to exercise `BestScoreStore` for real +/// on a Simulator. Phase 6a adds the permanent test target; this file lives +/// outside the repo on purpose. +/// +/// Every case runs against its own `UserDefaults` suite, so the app's real +/// defaults are never touched and the suite can be thrown away afterwards. +final class BestScoreStoreTests: XCTestCase { + + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUpWithError() throws { + suiteName = "ipp.tests.bestScore.\(UUID().uuidString)" + defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + private func store(key: String = BestScoreStore.defaultKey) -> BestScoreStore { + BestScoreStore(defaults: defaults, key: key) + } + + func testAFreshStoreHasNoBestScore() { + XCTAssertEqual(store().best, 0) + } + + func testTheFirstRealScoreBecomesTheBest() { + let store = self.store() + XCTAssertTrue(store.submit(4)) + XCTAssertEqual(store.best, 4) + } + + func testOnlyAHigherScoreUpdatesTheBest() { + let store = self.store() + store.submit(7) + + XCTAssertFalse(store.submit(3), "a worse round must not overwrite the record") + XCTAssertEqual(store.best, 7) + + XCTAssertFalse(store.submit(7), "matching the record is not beating it") + XCTAssertEqual(store.best, 7) + + XCTAssertTrue(store.submit(8)) + XCTAssertEqual(store.best, 8) + } + + func testAScorelessRoundNeverWrites() { + let store = self.store() + XCTAssertFalse(store.submit(0)) + XCTAssertFalse(store.submit(-2)) + XCTAssertEqual(store.best, 0) + XCTAssertNil(defaults.object(forKey: BestScoreStore.defaultKey)) + } + + /// US2 / gate row 4.3: the best score has to survive the app being killed. + /// A second instance reading the same suite is exactly that — the store + /// caches nothing, so every read comes back from `UserDefaults`. + func testTheBestScorePersistsAcrossInstances() { + store().submit(11) + + let reopened = store() + XCTAssertEqual(reopened.best, 11) + + XCTAssertFalse(reopened.submit(9)) + XCTAssertTrue(reopened.submit(12)) + XCTAssertEqual(store().best, 12) + } + + func testStoresOnDifferentKeysDoNotSeeEachOther() { + let mine = store(key: "ipp.tests.a") + let theirs = store(key: "ipp.tests.b") + mine.submit(5) + XCTAssertEqual(mine.best, 5) + XCTAssertEqual(theirs.best, 0) + } + + func testClearingForgetsTheBest() { + let store = self.store() + store.submit(6) + store.clear() + XCTAssertEqual(store.best, 0) + } + + /// FR-008: the key is namespaced to the game, so nothing else in the app + /// can be clobbered by it. + func testTheDefaultKeyIsNamespacedToTheGame() { + XCTAssertTrue(BestScoreStore.defaultKey.contains("trophyToss")) + XCTAssertTrue(BestScoreStore.defaultKey.hasPrefix("com.nonturing.ipp")) + } +} diff --git a/ios/IPPTests/FloorMapAnimationTests.swift b/ios/IPPTests/FloorMapAnimationTests.swift new file mode 100644 index 0000000..a1bd837 --- /dev/null +++ b/ios/IPPTests/FloorMapAnimationTests.swift @@ -0,0 +1,257 @@ +import XCTest + +@testable import IPP + +/// Phase 5C task 5C.3: the pure maths behind the floor map's shimmer. The +/// entity-level behaviour is `FloorMapTests`; this pins the numbers the owner +/// will ask to tune. +final class FloorMapAnimationTests: XCTestCase { + + private let field = (0..<400).map { FloorMapAnimation.dot(index: $0) } + + // MARK: - Per-dot parameters + + func testADotIsDeterministicForItsIndexAndSeed() { + XCTAssertEqual(FloorMapAnimation.dot(index: 7), FloorMapAnimation.dot(index: 7)) + XCTAssertEqual( + FloorMapAnimation.dot(index: 7, seed: 42), + FloorMapAnimation.dot(index: 7, seed: 42) + ) + } + + func testDifferentDotsAndDifferentSeedsDiffer() { + XCTAssertNotEqual(FloorMapAnimation.dot(index: 7), FloorMapAnimation.dot(index: 8)) + XCTAssertNotEqual( + FloorMapAnimation.dot(index: 7, seed: 1), + FloorMapAnimation.dot(index: 7, seed: 2) + ) + } + + func testADotDependsOnlyOnItsOwnIndexSoInsertingAPinDoesNotReshuffleTheField() { + // The generator is re-seeded per dot rather than run as one stream. + // If it were a stream, dot 300 would change whenever dot 0 changed. + let alone = FloorMapAnimation.dot(index: 300) + let inSequence = (0...300).map { FloorMapAnimation.dot(index: $0) }.last + XCTAssertEqual(alone, inSequence) + } + + func testEveryDotsConstantsAreInsideTheirTuningRanges() { + for (index, dot) in field.enumerated() { + XCTAssertTrue( + (FloorMapAnimation.Tuning.minPulsePeriod...FloorMapAnimation.Tuning.maxPulsePeriod) + .contains(dot.pulsePeriod), "dot \(index)" + ) + XCTAssertTrue( + (FloorMapAnimation.Tuning.minPulseAmplitude...FloorMapAnimation.Tuning.maxPulseAmplitude) + .contains(dot.pulseAmplitude), "dot \(index)" + ) + XCTAssertTrue((0..<(2 * Float.pi)).contains(dot.pulsePhase), "dot \(index)") + XCTAssertTrue( + (FloorMapAnimation.Tuning.minDropoutCycle...FloorMapAnimation.Tuning.maxDropoutCycle) + .contains(dot.dropoutCycle), "dot \(index)" + ) + XCTAssertTrue((0..() + for dot in field { + buckets.insert(Int(dot.pulsePhase / (2 * .pi) * 6)) + } + XCTAssertGreaterThanOrEqual(buckets.count, 6) + } + + // MARK: - Pulse + + func testTheScaleStaysInsideItsAmplitudeAndIsAlwaysPositive() { + for (index, dot) in field.prefix(60).enumerated() { + for time in stride(from: 0.0, through: 30.0, by: 0.05) { + let scale = FloorMapAnimation.scale(dot, at: time) + XCTAssertGreaterThanOrEqual(scale, 1 - dot.pulseAmplitude - 1e-5, "\(index)@\(time)") + XCTAssertLessThanOrEqual(scale, 1 + dot.pulseAmplitude + 1e-5, "\(index)@\(time)") + XCTAssertGreaterThan(scale, 0) + } + } + } + + func testTheScaleActuallyReachesBothEndsOfItsSwing() { + // A pulse that never grows or never shrinks is not a pulse. + let dot = FloorMapAnimation.dot(index: 3) + var lowest = Float.greatestFiniteMagnitude + var highest = -Float.greatestFiniteMagnitude + for time in stride(from: 0.0, through: dot.pulsePeriod, by: 0.01) { + let scale = FloorMapAnimation.scale(dot, at: time) + lowest = min(lowest, scale) + highest = max(highest, scale) + } + XCTAssertEqual(lowest, 1 - dot.pulseAmplitude, accuracy: 0.01) + XCTAssertEqual(highest, 1 + dot.pulseAmplitude, accuracy: 0.01) + } + + func testThePulseRepeatsAfterExactlyOnePeriod() { + let dot = FloorMapAnimation.dot(index: 11) + for time in stride(from: 0.0, through: 2.0, by: 0.13) { + XCTAssertEqual( + FloorMapAnimation.scale(dot, at: time), + FloorMapAnimation.scale(dot, at: time + dot.pulsePeriod), + accuracy: 1e-4 + ) + } + } + + func testAPulseIsGentleEnoughToReadAsBreathingRatherThanStrobing() { + // The owner asked for "slightly animated". Bounds on the tuning so a + // later edit cannot quietly turn the map into a disco floor. + XCTAssertGreaterThanOrEqual(FloorMapAnimation.Tuning.minPulsePeriod, 1.0) + XCTAssertLessThanOrEqual(FloorMapAnimation.Tuning.maxPulseAmplitude, 0.5) + } + + // MARK: - Dropout + + func testADotThatNeverDropsOutIsAlwaysFullyVisible() { + guard let steady = field.first(where: { !$0.dropsOut }) else { + return XCTFail("every dot drops out") + } + for time in stride(from: 0.0, through: 120.0, by: 0.1) { + XCTAssertEqual(FloorMapAnimation.visibility(steady, at: time), 1, "t=\(time)") + } + } + + func testADroppingDotFadesOutHoldsAtNothingAndFadesBack() { + guard let dot = field.first(where: { $0.dropsOut }) else { + return XCTFail("no dot drops out") + } + let fade = min(FloorMapAnimation.Tuning.fadeDuration, dot.dropoutDuration / 2) + + // Just before its turn: fully present. + XCTAssertEqual(FloorMapAnimation.visibility(dot, at: dot.dropoutStart - 0.01), 1, accuracy: 1e-3) + // Mid fade-out: partly there. + let fadingOut = FloorMapAnimation.visibility(dot, at: dot.dropoutStart + fade / 2) + XCTAssertGreaterThan(fadingOut, 0.2) + XCTAssertLessThan(fadingOut, 0.8) + // Middle of the dropout: gone. + XCTAssertEqual( + FloorMapAnimation.visibility(dot, at: dot.dropoutStart + dot.dropoutDuration / 2), + 0, accuracy: 1e-6 + ) + // Mid fade-back: partly there again. + let fadingIn = FloorMapAnimation.visibility( + dot, at: dot.dropoutStart + dot.dropoutDuration - fade / 2 + ) + XCTAssertGreaterThan(fadingIn, 0.2) + XCTAssertLessThan(fadingIn, 0.8) + // After: back for good, until the next cycle. + XCTAssertEqual( + FloorMapAnimation.visibility(dot, at: dot.dropoutStart + dot.dropoutDuration + 0.01), + 1, accuracy: 1e-3 + ) + } + + func testVisibilityIsAlwaysABlendableFraction() { + for dot in field.prefix(80) { + for time in stride(from: -20.0, through: 120.0, by: 0.07) { + let visibility = FloorMapAnimation.visibility(dot, at: time) + XCTAssertGreaterThanOrEqual(visibility, 0) + XCTAssertLessThanOrEqual(visibility, 1) + } + } + } + + func testTheDropoutRepeatsOnItsCycle() { + guard let dot = field.first(where: { $0.dropsOut }) else { + return XCTFail("no dot drops out") + } + for time in stride(from: 0.0, through: 8.0, by: 0.29) { + XCTAssertEqual( + FloorMapAnimation.visibility(dot, at: time), + FloorMapAnimation.visibility(dot, at: time + dot.dropoutCycle), + accuracy: 1e-4 + ) + } + } + + func testADotIsPresentForMostOfItsCycle() { + // "Some can disappear for a few seconds" — occasionally missing, not + // flickering. This is the guard on `minDropoutCycle` against + // `maxDropoutDuration`; the two constants have to be tuned together. + for dot in field.filter(\.dropsOut) { + let present = (dot.dropoutCycle - dot.dropoutDuration) / dot.dropoutCycle + XCTAssertGreaterThan(present, 0.72, "a dot should be missing, not mostly absent") + } + } + + // MARK: - Fade ramp indexing + + func testEveryVisibilityIndexesTheRampAndSpansInvisibleToFull() { + for step in 0...100 { + let level = FloorMapAnimation.fadeLevel(forVisibility: Float(step) / 100) + XCTAssertTrue((0...FloorMapAnimation.Tuning.fadeSteps).contains(level), "\(step)") + } + XCTAssertEqual(FloorMapAnimation.fadeLevel(forVisibility: 0), 0) + XCTAssertEqual( + FloorMapAnimation.fadeLevel(forVisibility: 1), + FloorMapAnimation.Tuning.fadeSteps + ) + } + + func testTheFadeLevelSurvivesTheValuesThatWouldTrapAnIntConversion() { + // `Swift.min`/`max` propagate NaN and `Int(nan)` traps — the crash the + // Phase 5B crawl fade found. An unreadable visibility must fail *on*, + // never off, so a bug cannot silently empty the map. + XCTAssertEqual( + FloorMapAnimation.fadeLevel(forVisibility: .nan), + FloorMapAnimation.Tuning.fadeSteps + ) + XCTAssertEqual(FloorMapAnimation.fadeLevel(forVisibility: -5), 0) + XCTAssertEqual( + FloorMapAnimation.fadeLevel(forVisibility: 5), + FloorMapAnimation.Tuning.fadeSteps + ) + XCTAssertEqual( + FloorMapAnimation.fadeLevel(forVisibility: .infinity), + FloorMapAnimation.Tuning.fadeSteps + ) + } + + // MARK: - Degenerate dots + + func testADotWithNoPeriodOrNoCycleIsStillWellDefined() { + var frozen = FloorMapAnimation.dot(index: 1) + frozen.pulsePeriod = 0 + XCTAssertEqual(FloorMapAnimation.scale(frozen, at: 3), 1) + + var never = FloorMapAnimation.dot(index: 2) + never.dropsOut = true + never.dropoutCycle = 0 + XCTAssertEqual(FloorMapAnimation.visibility(never, at: 3), 1) + + var instant = FloorMapAnimation.dot(index: 3) + instant.dropsOut = true + instant.dropoutDuration = 0 + XCTAssertEqual(FloorMapAnimation.visibility(instant, at: 3), 1) + } + + func testANonFiniteClockFreezesRatherThanCrashes() { + let dot = FloorMapAnimation.dot(index: 5) + for time in [Double.nan, .infinity, -.infinity] { + XCTAssertEqual(FloorMapAnimation.scale(dot, at: time), 1) + XCTAssertEqual(FloorMapAnimation.visibility(dot, at: time), 1) + } + } +} diff --git a/ios/IPPTests/FloorMapProjectionTests.swift b/ios/IPPTests/FloorMapProjectionTests.swift new file mode 100644 index 0000000..7c6240d --- /dev/null +++ b/ios/IPPTests/FloorMapProjectionTests.swift @@ -0,0 +1,283 @@ +import XCTest +import simd + +@testable import IPP + +/// Phase 5C testing item 1: the lat/lng → floor-plane projection, including the +/// degenerate cases that would otherwise reach RealityKit as `NaN` and take the +/// game down with them. +final class FloorMapProjectionTests: XCTestCase { + + private let extent: Float = 0.88 + + // MARK: - Bounding-box fit + + func testAPinSetIsCentredOnThePlate() { + // Two pins on a north–south line: they must straddle the centre. + let pins = [ + GeoPin(latitude: -33.00, longitude: -71.60), + GeoPin(latitude: -33.10, longitude: -71.60), + ] + let fit = FloorMapProjection.fit(pins, extent: extent) + let points = pins.map(fit.project) + + XCTAssertEqual(points[0].x, 0, accuracy: 1e-5) + XCTAssertEqual(points[1].x, 0, accuracy: 1e-5) + XCTAssertEqual(points[0].y + points[1].y, 0, accuracy: 1e-5, "symmetric about the centre") + } + + func testTheDominantAxisFillsTheExtentExactly() { + let pins = [ + GeoPin(latitude: -33.00, longitude: -71.60), + GeoPin(latitude: -33.10, longitude: -71.60), + ] + let fit = FloorMapProjection.fit(pins, extent: extent) + let points = pins.map(fit.project) + + XCTAssertEqual(abs(points[0].y - points[1].y), extent, accuracy: 1e-4) + } + + func testNorthIsAwayFromThePlayer() { + // The podium faces the player along +Z, so a higher latitude has to + // land further away, i.e. at a smaller z. + let north = GeoPin(latitude: -33.00, longitude: -71.60) + let south = GeoPin(latitude: -33.10, longitude: -71.60) + let fit = FloorMapProjection.fit([north, south], extent: extent) + + XCTAssertLessThan(fit.project(north).y, fit.project(south).y) + } + + func testEastIsToThePlayersRight() { + let west = GeoPin(latitude: -33.05, longitude: -71.70) + let east = GeoPin(latitude: -33.05, longitude: -71.50) + let fit = FloorMapProjection.fit([west, east], extent: extent) + + XCTAssertLessThan(fit.project(west).x, fit.project(east).x) + } + + func testTheAspectRatioIsPreservedRatherThanStretchedToFill() { + // A set twice as wide as it is tall must stay twice as wide: the + // shorter axis uses less than the full extent. + let pins = [ + GeoPin(latitude: -33.00, longitude: -71.70), + GeoPin(latitude: -33.00, longitude: -71.50), + GeoPin(latitude: -33.05, longitude: -71.70), + GeoPin(latitude: -33.05, longitude: -71.50), + ] + let fit = FloorMapProjection.fit(pins, extent: extent) + let points = pins.map(fit.project) + let widthSpan = (points.map(\.x).max() ?? 0) - (points.map(\.x).min() ?? 0) + let depthSpan = (points.map(\.y).max() ?? 0) - (points.map(\.y).min() ?? 0) + + XCTAssertEqual(widthSpan, extent, accuracy: 1e-4, "the wide axis fills the plate") + XCTAssertLessThan(depthSpan, extent * 0.9, "the short axis must not be stretched") + XCTAssertGreaterThan(depthSpan, 0) + } + + func testLongitudeIsCompressedByTheCosineOfTheLatitude() { + // At Valparaíso's latitude a degree of longitude is ~0.838 of a degree + // of latitude on the ground; ignoring that would smear the map + // east–west by 19 %. + let pins = [ + GeoPin(latitude: -33.05, longitude: -71.70), + GeoPin(latitude: -33.05, longitude: -71.50), + ] + let fit = FloorMapProjection.fit(pins, extent: extent) + XCTAssertEqual(fit.longitudeScale, cos(-33.05 * .pi / 180), accuracy: 1e-9) + XCTAssertEqual(fit.longitudeScale, 0.838, accuracy: 0.005) + } + + // MARK: - Degenerate inputs + + func testASinglePinSitsDeadCentre() { + let pin = GeoPin(latitude: -33.05, longitude: -71.60) + let fit = FloorMapProjection.fit([pin], extent: extent) + let point = fit.project(pin) + + XCTAssertEqual(point.x, 0, accuracy: 1e-6) + XCTAssertEqual(point.y, 0, accuracy: 1e-6) + XCTAssertEqual(fit.metresPerDegree, 0, "no extent to scale to") + } + + func testEveryPinAtTheSameCoordinateCollapsesToTheCentreWithoutDividingByZero() { + let pin = GeoPin(latitude: -33.05, longitude: -71.60) + let pins = Array(repeating: pin, count: 40) + let fit = FloorMapProjection.fit(pins, extent: extent) + + for point in pins.map(fit.project) { + XCTAssertTrue(point.x.isFinite && point.y.isFinite) + XCTAssertEqual(simd_length(point), 0, accuracy: 1e-6) + } + } + + func testAnEmptyPinListStillProducesAUsableFit() { + let fit = FloorMapProjection.fit([], extent: extent) + let point = fit.project(GeoPin(latitude: -33.05, longitude: -71.60)) + XCTAssertTrue(point.x.isFinite && point.y.isFinite) + } + + func testAZeroExtentPlateProducesNoNaN() { + let fit = FloorMapProjection.fit( + [GeoPin(latitude: -33.0, longitude: -71.5), GeoPin(latitude: -33.1, longitude: -71.6)], + extent: 0 + ) + let point = fit.project(GeoPin(latitude: -33.05, longitude: -71.55)) + XCTAssertTrue(point.x.isFinite && point.y.isFinite) + } + + func testNonFiniteAndOutOfRangeCoordinatesAreRejectedRatherThanProjected() { + let bad = [ + GeoPin(latitude: .nan, longitude: -71.6), + GeoPin(latitude: -33.0, longitude: .nan), + GeoPin(latitude: .infinity, longitude: .infinity), + GeoPin(latitude: 91, longitude: 0), + GeoPin(latitude: 0, longitude: -181), + ] + for pin in bad { + XCTAssertFalse(pin.isUsable, "\(pin)") + } + + // …and one that slips into a fit alongside good pins projects to the + // centre instead of poisoning the plate. + let good = [ + GeoPin(latitude: -33.00, longitude: -71.60), + GeoPin(latitude: -33.10, longitude: -71.50), + ] + let fit = FloorMapProjection.fit(good + bad, extent: extent) + for pin in bad { + let point = fit.project(pin) + XCTAssertTrue(point.x.isFinite && point.y.isFinite, "\(pin)") + XCTAssertEqual(simd_length(point), 0, accuracy: 1e-6) + } + // The good pins still fill the plate: the bad ones did not widen the + // bounding box. + XCTAssertEqual(abs(fit.project(good[0]).y - fit.project(good[1]).y), extent, accuracy: 1e-4) + } + + func testNothingEverLandsOutsideThePlate() { + let pins = [ + GeoPin(latitude: -33.00, longitude: -71.60), + GeoPin(latitude: -33.10, longitude: -71.50), + ] + let fit = FloorMapProjection.fit(pins, extent: extent) + let half = extent / 2 + 1e-4 + + // Sweep well outside the fitted set, including the far side of the + // planet. + for latitude in stride(from: -90.0, through: 90.0, by: 7.5) { + for longitude in stride(from: -180.0, through: 180.0, by: 15.0) { + let point = fit.project(GeoPin(latitude: latitude, longitude: longitude)) + XCTAssertLessThanOrEqual(abs(point.x), half, "\(latitude),\(longitude)") + XCTAssertLessThanOrEqual(abs(point.y), half, "\(latitude),\(longitude)") + } + } + } + + // MARK: - Thinning + + func testASmallPinSetIsNotThinnedAtAll() { + let pins = (0..<10).map { GeoPin(latitude: Double($0), longitude: 0) } + XCTAssertEqual(FloorMapProjection.sample(pins, limit: 260), pins) + } + + func testALargePinSetIsThinnedToExactlyTheLimit() { + let pins = (0..<960).map { GeoPin(latitude: Double($0) / 100, longitude: 0) } + let sampled = FloorMapProjection.sample(pins, limit: 260) + + XCTAssertEqual(sampled.count, 260) + XCTAssertTrue(sampled.allSatisfy { pins.contains($0) }) + } + + func testThinningKeepsTheSpreadRatherThanTakingAPrefix() { + // The seeded data arrives grouped by city, so a prefix would show one + // city. Stride sampling has to reach the end of the list. + let pins = (0..<960).map { GeoPin(latitude: Double($0) / 100, longitude: 0) } + let sampled = FloorMapProjection.sample(pins, limit: 260) + + XCTAssertEqual(sampled.first, pins.first) + XCTAssertGreaterThan(sampled.last!.latitude, pins[900].latitude, "never reached the tail") + // Strictly increasing, i.e. it walks the list once in order. + for (a, b) in zip(sampled, sampled.dropFirst()) { + XCTAssertLessThan(a.latitude, b.latitude) + } + } + + func testThinningToNothingIsAnEmptyMapNotACrash() { + let pins = (0..<50).map { GeoPin(latitude: Double($0), longitude: 0) } + XCTAssertEqual(FloorMapProjection.sample(pins, limit: 0).count, 0) + XCTAssertEqual(FloorMapProjection.sample(pins, limit: -3).count, 0) + XCTAssertEqual(FloorMapProjection.sample([], limit: 260).count, 0) + } + + // MARK: - Density tint + + func testAUniformlySpreadSetGetsAUniformTint() { + let points = (0..<9).map { index in + SIMD2(Float(index % 3) * 0.3, Float(index / 3) * 0.3) + } + let levels = FloorMapProjection.densityLevels(for: points, radius: 0.05, stops: 5) + XCTAssertEqual(Set(levels).count, 1, "nobody has a neighbour, so nobody is hotter") + } + + func testACrowdedNeighbourhoodRunsHotAndALonePinRunsCold() { + var points = (0..<12).map { index in + SIMD2(0.01 * Float(index % 4), 0.01 * Float(index / 4)) + } + let loner = SIMD2(0.45, 0.45) + points.append(loner) + + let levels = FloorMapProjection.densityLevels(for: points, radius: 0.05, stops: 5) + XCTAssertEqual(levels.last, 0, "the isolated pin is the coldest") + XCTAssertEqual(levels.dropLast().max(), 4, "the cluster reaches the top of the ramp") + } + + func testEveryLevelIndexesTheRamp() { + let points = (0..<200).map { index in + SIMD2( + Float(index % 20) * 0.02 - 0.2, + Float(index / 20) * 0.05 - 0.2 + ) + } + for stops in [2, 3, 5, 8] { + let levels = FloorMapProjection.densityLevels(for: points, radius: 0.05, stops: stops) + XCTAssertEqual(levels.count, points.count) + XCTAssertTrue(levels.allSatisfy { (0..(0, 0), SIMD2(0.01, 0)] + XCTAssertEqual(FloorMapProjection.densityLevels(for: [], radius: 0.05, stops: 5), []) + XCTAssertEqual( + FloorMapProjection.densityLevels(for: points, radius: 0.05, stops: 1), + [0, 0] + ) + XCTAssertEqual( + FloorMapProjection.densityLevels(for: points, radius: 0, stops: 5), + [0, 0] + ) + } + + // MARK: - Caption + + func testTheCaptionNamesTheSourceSoTheGateIsDecidableByEye() { + let live = FloorMapProjection.caption(pinCount: 260, isLive: true) + let sample = FloorMapProjection.caption(pinCount: 80, isLive: false) + + XCTAssertTrue(live.contains("en vivo"), live) + XCTAssertTrue(live.contains("260"), live) + XCTAssertTrue(sample.contains("ejemplo"), sample) + XCTAssertTrue(sample.contains("80"), sample) + XCTAssertNotEqual(live, sample) + } + + func testTheCaptionIsGrammaticalSpanishForOneLocation() { + XCTAssertTrue( + FloorMapProjection.caption(pinCount: 1, isLive: true).contains("1 ubicación"), + "singular, not '1 ubicaciones' (FR-009)" + ) + XCTAssertTrue( + FloorMapProjection.caption(pinCount: 0, isLive: false).contains("0 ubicaciones") + ) + } +} diff --git a/ios/IPPTests/FloorMapTests.swift b/ios/IPPTests/FloorMapTests.swift new file mode 100644 index 0000000..4a2ce18 --- /dev/null +++ b/ios/IPPTests/FloorMapTests.swift @@ -0,0 +1,327 @@ +import RealityKit +import XCTest +import simd + +@testable import IPP + +/// Phase 5C tasks 5C.2 and 5C.3: the built floor map. Whether it *looks* right +/// is the owner's gate rows 5C-g1/5C-g3; what is checked here is that it is +/// built, bounded, capped, captioned, animated — and, above all, incapable of +/// touching a ball. +/// +/// **Task 5C.3 reshaped this suite.** The owner removed the plate and border, +/// so the three cases that asserted their geometry are gone; the dot, caption +/// and inertness cases stay, and the animation cases are new. +@MainActor +final class FloorMapTests: XCTestCase { + + private func descendants(of entity: Entity) -> [Entity] { + entity.children.flatMap { [$0] + descendants(of: $0) } + } + + private func dots(of display: FloorMap.Display) -> [Entity] { + descendants(of: display.root).filter { $0.name.hasPrefix(FloorMap.Name.pinPrefix) } + } + + private func sampleData(count: Int, isLive: Bool = true) -> FloorMapData { + let pins = (0.. Int { + ([entity] + descendants(of: entity)) + .filter { $0.components[CollisionComponent.self] != nil } + .count + } + let scene = PodiumBuilder.makeScene() + let before = colliders(scene) + scene.addChild(FloorMap.make(sampleData(count: 200)).root) + XCTAssertEqual(colliders(scene), before) + } + + func testAnimatingTheMapNeverGrowsColliders() { + // The shimmer writes transforms and materials every frame; none of that + // may ever add a collider to the scene. + let display = FloorMap.make(sampleData(count: 120)) + for time in stride(from: 0.0, through: 40.0, by: 0.5) { + FloorMap.update(display, at: time) + } + for entity in [display.root] + descendants(of: display.root) { + XCTAssertNil(entity.components[CollisionComponent.self], entity.name) + XCTAssertNil(entity.components[PhysicsBodyComponent.self], entity.name) + } + } + + // MARK: - The plate is gone (task 5C.3) + + func testThereIsNoPlateOrBorderLeftUnderTheDots() { + // The owner asked for the bounding box to go: the floor itself is the + // map's background now. A leftover slab would mean the removal was + // cosmetic. + let display = FloorMap.make(sampleData(count: 60)) + for name in ["floor_map_plate", "floor_map_frame"] { + XCTAssertNil(display.root.findEntity(named: name), "\(name) survived") + } + // Every model in the subtree is either a dot or the caption's text. + let caption = display.root.findEntity(named: FloorMap.Name.caption) + let captionModels = caption.map { descendants(of: $0) } ?? [] + let models = descendants(of: display.root).compactMap { $0 as? ModelEntity } + for model in models { + let isDot = model.name.hasPrefix(FloorMap.Name.pinPrefix) + let isCaption = captionModels.contains { $0 === model } + XCTAssertTrue(isDot || isCaption, "unexpected geometry: \(model.name)") + } + } + + // MARK: - Geometry + + func testTheFieldSitsAboveTheFloorPlaneSoItCannotZFightIt() { + // PodiumBuilder's invisible collider has its top face at y = 0. + let display = FloorMap.make(sampleData(count: 20)) + XCTAssertGreaterThan(display.root.position.y, 0) + XCTAssertLessThan(display.root.position.y, 0.01, "it is a map on the floor, not a table") + } + + func testTheFieldIsBigEnoughToSurroundThePodiumAndSmallEnoughForADesk() { + let podiumWidth = PodiumBuilder.Metrics.stepWidth * 3 + XCTAssertGreaterThan(FloorMap.Look.side, podiumWidth * 2, "the map has to read as under it") + XCTAssertLessThanOrEqual(FloorMap.Look.side, 1.2) + } + + func testEveryDotLandsInTheFieldWithItsInsetRespected() { + let display = FloorMap.make(sampleData(count: 400)) + let limit = FloorMap.Look.side / 2 - FloorMap.Look.inset + 1e-4 + + XCTAssertFalse(dots(of: display).isEmpty) + for dot in dots(of: display) { + XCTAssertLessThanOrEqual(abs(dot.position.x), limit, dot.name) + XCTAssertLessThanOrEqual(abs(dot.position.z), limit, dot.name) + XCTAssertGreaterThan(dot.position.y, 0, "dots float above the anchor plane") + } + } + + func testTheDotCountIsCappedHoweverManyPinsArrive() { + // The seeded backend returns ~960; the scene must not grow 960 + // entities for a field a metre across. + let display = FloorMap.make(sampleData(count: 960)) + XCTAssertEqual(dots(of: display).count, FloorMap.Look.maxDots) + XCTAssertEqual(display.dots.count, FloorMap.Look.maxDots) + XCTAssertEqual(display.motion.count, FloorMap.Look.maxDots) + XCTAssertEqual(display.density.count, FloorMap.Look.maxDots) + } + + func testASmallPinSetDrawsEveryPin() { + let display = FloorMap.make(sampleData(count: 37)) + XCTAssertEqual(dots(of: display).count, 37) + } + + func testAnEmptyMapIsStillACaptionRatherThanNothing() { + let display = FloorMap.make(FloorMapData.none) + XCTAssertNotNil(display.root.findEntity(named: FloorMap.Name.caption)) + XCTAssertTrue(dots(of: display).isEmpty) + // …and animating an empty field is a no-op, not a crash. + FloorMap.update(display, at: 12.5) + } + + func testASinglePinDrawsOneDotInTheMiddle() { + let display = FloorMap.make( + FloorMapData(pins: [GeoPin(latitude: -33.05, longitude: -71.6)], isLive: true) + ) + let placed = dots(of: display) + XCTAssertEqual(placed.count, 1) + XCTAssertEqual(placed[0].position.x, 0, accuracy: 1e-5) + XCTAssertEqual(placed[0].position.z, 0, accuracy: 1e-5) + } + + // MARK: - Caption + + func testTheCaptionSaysWhereTheDataCameFromAndIsRealGeometry() { + for isLive in [true, false] { + let display = FloorMap.make(sampleData(count: 50, isLive: isLive)) + guard let caption = display.root.findEntity(named: FloorMap.Name.caption) else { + return XCTFail("no caption") + } + let models = descendants(of: caption).compactMap { $0 as? ModelEntity } + XCTAssertEqual(models.count, 1) + XCTAssertGreaterThan(models[0].model?.mesh.bounds.extents.x ?? 0, 0) + } + } + + func testTheCaptionLiesOnTheFieldAndLeansTowardThePlayer() { + let display = FloorMap.make(sampleData(count: 50)) + guard let caption = display.root.findEntity(named: FloorMap.Name.caption) else { + return XCTFail("no caption") + } + // On the near edge, the side the podium was turned toward. + XCTAssertGreaterThan(caption.position.z, FloorMap.Look.side / 2 - FloorMap.Look.inset) + XCTAssertLessThan(caption.position.z, FloorMap.Look.side / 2) + + // Its face points mostly up out of the floor, tipped back at the player. + let facing = caption.orientation.act(SIMD3(0, 0, 1)) + XCTAssertGreaterThan(facing.y, 0.9, "mostly up") + XCTAssertGreaterThan(facing.z, 0, "leaning toward the player, not away") + XCTAssertEqual(facing.x, 0, accuracy: 1e-6) + + // Text runs away from the player, so it reads the right way up. + let textUp = caption.orientation.act(SIMD3(0, 1, 0)) + XCTAssertLessThan(textUp.z, 0) + } + + func testTheCaptionFitsInTheField() { + let model = StandingsDisplay.makeTextModel( + FloorMapProjection.caption(pinCount: 260, isLive: true), + size: FloorMap.Look.captionSize, + material: UnlitMaterial(color: .white) + ) + let bounds = model.visualBounds(relativeTo: nil) + print("caption width \(bounds.extents.x) m, height \(bounds.extents.y) m") + XCTAssertGreaterThan(bounds.extents.x, 0.10, "too small to read at a metre") + XCTAssertLessThan(bounds.extents.x, FloorMap.Look.side, "wider than the field") + } + + // MARK: - Tint ramp + + func testTheRampCrossesEveryTintWithEveryFadeLevel() { + let ramp = FloorMap.densityRamp() + XCTAssertEqual(ramp.count, FloorMap.Look.densityStops) + for row in ramp { + XCTAssertEqual(row.count, FloorMapAnimation.Tuning.fadeSteps + 1) + } + } + + func testTheDensityRampRunsFromTheBrandTealToThePodiumGold() { + XCTAssertEqual(FloorMap.blend(PodiumBuilder.Medal.ball, PodiumBuilder.Medal.gold, 0), + PodiumBuilder.Medal.ball) + XCTAssertEqual(FloorMap.blend(PodiumBuilder.Medal.ball, PodiumBuilder.Medal.gold, 1), + PodiumBuilder.Medal.gold) + } + + func testBlendingClampsRatherThanExtrapolating() { + // Compared component-wise: `blend` always returns an sRGB colour, and + // `UIColor.black`/`.white` are greyscale, so `==` on the objects is + // false even when the colours are identical. + func rgba(_ color: UIColor) -> [CGFloat] { + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return [red, green, blue, alpha] + } + + XCTAssertEqual(rgba(FloorMap.blend(.black, .white, -5)), rgba(.black)) + XCTAssertEqual(rgba(FloorMap.blend(.black, .white, 5)), rgba(.white)) + XCTAssertEqual(rgba(FloorMap.blend(.black, .white, 0.5))[0], 0.5, accuracy: 1e-6) + } + + // MARK: - The field is alive (task 5C.3) + + func testTheFieldIsAlreadyAnimatedTheFrameItIsBuilt() { + // Otherwise the map snaps into motion a frame after the podium lands. + let display = FloorMap.make(sampleData(count: 120)) + XCTAssertFalse(display.levels.contains(-1), "every dot got its opening rung") + XCTAssertTrue(display.dots.contains { $0.scale.x != 1 }, "nothing is pulsing") + } + + func testEveryDotStaysWithinItsPulseBoundsForeverAndKeepsItsPlace() { + let display = FloorMap.make(sampleData(count: 80)) + let origins = display.dots.map(\.position) + + for time in stride(from: 0.0, through: 90.0, by: 0.37) { + FloorMap.update(display, at: time) + for (index, dot) in display.dots.enumerated() { + let amplitude = display.motion[index].pulseAmplitude + XCTAssertGreaterThanOrEqual(dot.scale.x, 1 - amplitude - 1e-5, "t=\(time)") + XCTAssertLessThanOrEqual(dot.scale.x, 1 + amplitude + 1e-5, "t=\(time)") + XCTAssertEqual(dot.scale.x, dot.scale.y, accuracy: 1e-6, "uniform scale only") + // A pulsing dot must not wander off its location. + XCTAssertEqual(dot.position, origins[index], "t=\(time)") + } + } + } + + func testTheMapNeverLooksEmptyHoweverThePhasesLineUp() { + // The dropouts are staggered and only a minority of dots ever drop out; + // if that ever stopped being true the map would visibly gutter. + let display = FloorMap.make(sampleData(count: 260)) + for time in stride(from: 0.0, through: 200.0, by: 0.25) { + FloorMap.update(display, at: time) + let visible = display.dots.filter(\.isEnabled).count + XCTAssertGreaterThan( + Double(visible), Double(display.dots.count) * 0.8, + "only \(visible)/\(display.dots.count) dots visible at t=\(time)" + ) + } + } + + func testSomeDotsDoActuallyVanishAndComeBack() { + // The other half of the previous test: a field that never blinks would + // pass "never empty" trivially. + let display = FloorMap.make(sampleData(count: 260)) + var everHidden = Set() + for time in stride(from: 0.0, through: 60.0, by: 0.25) { + FloorMap.update(display, at: time) + for (index, dot) in display.dots.enumerated() where !dot.isEnabled { + everHidden.insert(index) + } + } + XCTAssertGreaterThan(everHidden.count, 10, "nothing ever blinked out") + + // …and every one of them is visible again at some point. + var stillHidden = everHidden + for time in stride(from: 0.0, through: 60.0, by: 0.25) { + FloorMap.update(display, at: time) + for index in everHidden where display.dots[index].isEnabled { + stillHidden.remove(index) + } + } + XCTAssertTrue(stillHidden.isEmpty, "these never came back: \(stillHidden)") + } + + func testAFullyFadedDotIsDisabledRatherThanDrawnInvisible() { + let display = FloorMap.make(sampleData(count: 260)) + for time in stride(from: 0.0, through: 60.0, by: 0.25) { + FloorMap.update(display, at: time) + for (index, dot) in display.dots.enumerated() { + let level = FloorMapAnimation.fadeLevel( + forVisibility: FloorMapAnimation.visibility(display.motion[index], at: time) + ) + XCTAssertEqual(dot.isEnabled, level > 0, "dot \(index) at t=\(time)") + } + } + } + + func testTheFieldIsDeterministicForASeed() { + // The same map must animate the same way every time it is placed. + let a = FloorMap.make(sampleData(count: 60), seed: 99) + let b = FloorMap.make(sampleData(count: 60), seed: 99) + XCTAssertEqual(a.motion, b.motion) + + let c = FloorMap.make(sampleData(count: 60), seed: 100) + XCTAssertNotEqual(a.motion, c.motion) + } + + func testAnimationIsWellDefinedForAbsurdClocks() { + let display = FloorMap.make(sampleData(count: 40)) + for time in [-5.0, 0.0, .greatestFiniteMagnitude, .infinity, .nan] as [TimeInterval] { + FloorMap.update(display, at: time) + for dot in display.dots { + XCTAssertTrue(dot.scale.x.isFinite, "t=\(time)") + XCTAssertGreaterThan(dot.scale.x, 0, "t=\(time)") + } + } + } +} diff --git a/ios/IPPTests/GameRoundTests.swift b/ios/IPPTests/GameRoundTests.swift new file mode 100644 index 0000000..51cd476 --- /dev/null +++ b/ios/IPPTests/GameRoundTests.swift @@ -0,0 +1,290 @@ +import XCTest + +@testable import IPP + +/// Throwaway harness used during Phase 4 to exercise `GameRound` for real on a +/// Simulator. Phase 6a adds the permanent test target; this file lives outside +/// the repo on purpose. +/// +/// `GameRound` imports nothing but Foundation and reads no clock, so every rule +/// about time can be driven exactly rather than waited for. +final class GameRoundTests: XCTestCase { + + private func round(duration: TimeInterval = 60) -> GameRound { + var rules = GameRound.Rules() + rules.duration = duration + return GameRound(rules: rules) + } + + // MARK: - Starting + + func testANewRoundIsIdleAndAcceptsFreePractice() { + let round = self.round() + XCTAssertEqual(round.state, .idle) + XCTAssertTrue(round.isIdle) + XCTAssertFalse(round.isRunning) + XCTAssertEqual(round.score, 0) + XCTAssertTrue(round.canStart) + // Free practice: balls may be thrown, but nothing counts for a round. + XCTAssertTrue(round.acceptsFlicks) + XCTAssertFalse(round.countsScores) + } + + func testStartingPutsTheFullDurationOnTheClock() { + var round = self.round(duration: 45) + XCTAssertTrue(round.start()) + XCTAssertEqual(round.state, .running(remaining: 45)) + XCTAssertEqual(round.remaining, 45, accuracy: 1e-9) + XCTAssertTrue(round.isRunning) + XCTAssertTrue(round.isTicking) + XCTAssertTrue(round.acceptsFlicks) + XCTAssertTrue(round.countsScores) + } + + func testTheDefaultRoundIsSixtySeconds() { + var round = GameRound() + round.start() + XCTAssertEqual(round.remaining, 60, accuracy: 1e-9) + } + + func testStartingAgainMidRoundIsRefused() { + var round = self.round() + round.start() + round.tick(10) + XCTAssertFalse(round.canStart) + XCTAssertFalse(round.start(), "a running round must not be restarted from under the player") + XCTAssertEqual(round.remaining, 50, accuracy: 1e-9) + } + + // MARK: - Ticking down + + func testTickingConsumesTime() { + var round = self.round(duration: 10) + round.start() + XCTAssertFalse(round.tick(3)) + XCTAssertEqual(round.remaining, 7, accuracy: 1e-9) + XCTAssertFalse(round.tick(3)) + XCTAssertEqual(round.remaining, 4, accuracy: 1e-9) + } + + func testTickingToZeroEndsTheRoundWithItsScore() { + var round = self.round(duration: 2) + round.start() + round.registerScore() + round.registerScore() + round.registerScore() + + XCTAssertFalse(round.tick(1.5)) + XCTAssertTrue(round.tick(0.5), "the tick that empties the clock reports the end") + XCTAssertEqual(round.state, .ended(score: 3)) + XCTAssertEqual(round.finalScore, 3) + XCTAssertTrue(round.hasEnded) + XCTAssertFalse(round.isRunning) + } + + func testTheEndIsReportedExactlyOnce() { + var round = self.round(duration: 1) + round.start() + XCTAssertTrue(round.tick(5), "an overshooting tick still ends the round") + XCTAssertFalse(round.tick(5), "…and never ends it a second time") + XCTAssertFalse(round.tick(5)) + } + + func testTickingDoesNothingBeforeAndAfterARound() { + var round = self.round(duration: 10) + XCTAssertFalse(round.tick(5)) + XCTAssertEqual(round.state, .idle) + + round.start() + round.tick(10) + XCTAssertEqual(round.state, .ended(score: 0)) + XCTAssertFalse(round.tick(5)) + XCTAssertEqual(round.state, .ended(score: 0), "the summary must not change under the player") + } + + func testANonPositiveTickIsIgnored() { + var round = self.round(duration: 10) + round.start() + XCTAssertFalse(round.tick(0)) + XCTAssertFalse(round.tick(-4), "a clock that ran backwards must not hand back time") + XCTAssertEqual(round.remaining, 10, accuracy: 1e-9) + } + + // MARK: - Pausing (edge cases "tracking loss" and "backgrounding") + + func testPausedTimeIsNotChargedToThePlayer() { + var round = self.round(duration: 30) + round.start() + round.tick(5) + + round.setPaused(true, reason: .trackingLimited) + XCTAssertTrue(round.isPaused) + XCTAssertFalse(round.isTicking) + + for _ in 0..<100 { + XCTAssertFalse(round.tick(1)) + } + XCTAssertEqual(round.remaining, 25, accuracy: 1e-9, "100 s of paused time consumed the clock") + + round.setPaused(false, reason: .trackingLimited) + XCTAssertTrue(round.isTicking) + round.tick(5) + XCTAssertEqual(round.remaining, 20, accuracy: 1e-9) + } + + func testAPausedRoundAcceptsNoFlicksAndScoresNothing() { + var round = self.round() + round.start() + round.setPaused(true, reason: .backgrounded) + + XCTAssertFalse(round.acceptsFlicks) + XCTAssertFalse(round.countsScores) + XCTAssertFalse(round.registerScore(), "a ball landing while paused must not score") + XCTAssertEqual(round.score, 0) + } + + func testEveryPauseReasonMustClearBeforeTheClockRestarts() { + var round = self.round(duration: 20) + round.start() + round.setPaused(true, reason: .trackingLimited) + round.setPaused(true, reason: .backgrounded) + + round.setPaused(false, reason: .trackingLimited) + XCTAssertTrue(round.isPaused, "the app is still in the background") + round.tick(5) + XCTAssertEqual(round.remaining, 20, accuracy: 1e-9) + + round.setPaused(false, reason: .backgrounded) + XCTAssertTrue(round.isTicking) + round.tick(5) + XCTAssertEqual(round.remaining, 15, accuracy: 1e-9) + } + + func testPausingIsIdempotent() { + var round = self.round(duration: 20) + round.start() + for _ in 0..<5 { round.setPaused(true, reason: .trackingLimited) } + round.setPaused(false, reason: .trackingLimited) + XCTAssertTrue(round.isTicking, "one clear must undo any number of identical pauses") + } + + func testARoundStartedWhileTrackingIsLostBeginsPaused() { + var round = self.round(duration: 20) + round.setPaused(true, reason: .trackingLimited) + round.start() + XCTAssertTrue(round.isPaused, "starting must not silently clear a live pause reason") + round.tick(5) + XCTAssertEqual(round.remaining, 20, accuracy: 1e-9) + } + + // MARK: - Flick and score gating (FR-007) + + func testScoresOnlyCountWhileARoundIsTicking() { + var round = self.round() + + // idle — free practice, counts for no round + XCTAssertFalse(round.registerScore()) + XCTAssertEqual(round.score, 0) + + round.start() + XCTAssertTrue(round.registerScore()) + XCTAssertTrue(round.registerScore()) + XCTAssertEqual(round.score, 2) + + round.tick(1000) + // ended — the summary is frozen + XCTAssertFalse(round.registerScore()) + XCTAssertEqual(round.finalScore, 2) + } + + /// Phase 5: the round takes the *amount*, because the tier that earned it + /// (+1 for touching the cup, the balance of +10 for landing in it) is + /// `TossController`'s business, not the clock's. + func testTheRoundBanksWhateverAmountItIsHandedAndOnlyWhileTicking() { + var round = self.round() + round.start() + + XCTAssertTrue(round.registerScore(1)) + XCTAssertEqual(round.score, 1) + XCTAssertTrue(round.registerScore(9), "the make's remainder after an absorbed hit") + XCTAssertEqual(round.score, 10, "a made ball is worth ten in total") + XCTAssertTrue(round.registerScore(10), "a clean make with no prior hit") + XCTAssertEqual(round.score, 20) + + // The default is still the single point, so nothing that predates the + // two-tier rule changed meaning. + XCTAssertTrue(round.registerScore()) + XCTAssertEqual(round.score, 21) + + round.setPaused(true, reason: .trackingLimited) + XCTAssertFalse(round.registerScore(10), "a ball landing while paused pays nobody") + XCTAssertEqual(round.score, 21) + } + + func testTheSummaryRefusesFlicksButFreePracticeDoesNot() { + var round = self.round(duration: 1) + XCTAssertTrue(round.acceptsFlicks, "free practice before the first round") + round.start() + XCTAssertTrue(round.acceptsFlicks) + round.tick(1) + XCTAssertFalse(round.acceptsFlicks, "no throwing behind the end-of-round card") + round.reset() + XCTAssertTrue(round.acceptsFlicks, "dismissing the summary returns to free practice") + } + + // MARK: - Replay and reset + + func testPlayingAgainStartsFromAFullClockAndAZeroScore() { + var round = self.round(duration: 10) + round.start() + round.registerScore() + round.tick(10) + XCTAssertEqual(round.finalScore, 1) + + XCTAssertTrue(round.start()) + XCTAssertEqual(round.remaining, 10, accuracy: 1e-9) + XCTAssertEqual(round.score, 0) + XCTAssertTrue(round.isTicking) + } + + func testResetReturnsToIdle() { + var round = self.round(duration: 10) + round.start() + round.registerScore() + round.reset() + XCTAssertEqual(round.state, .idle) + XCTAssertEqual(round.score, 0) + XCTAssertTrue(round.canStart) + } + + // MARK: - Countdown text + + func testCountdownTextRoundsUpSoTheHudNeverShowsZeroEarly() { + XCTAssertEqual(GameRound.countdownText(60), "1:00") + XCTAssertEqual(GameRound.countdownText(59.4), "1:00") + XCTAssertEqual(GameRound.countdownText(59), "0:59") + XCTAssertEqual(GameRound.countdownText(9.2), "0:10") + XCTAssertEqual(GameRound.countdownText(0.1), "0:01") + XCTAssertEqual(GameRound.countdownText(0), "0:00") + XCTAssertEqual(GameRound.countdownText(-3), "0:00", "a finished round never shows a negative clock") + } + + func testCountdownTextTracksTheRound() { + var round = self.round(duration: 60) + round.start() + XCTAssertEqual(round.countdownText, "1:00") + round.tick(15) + XCTAssertEqual(round.countdownText, "0:45") + } + + // MARK: - Rules are the single knob + + func testTheRoundLengthIsASingleConstant() { + var rules = GameRound.Rules() + rules.duration = 30 + var round = GameRound(rules: rules) + round.start() + XCTAssertEqual(round.remaining, 30, accuracy: 1e-9) + XCTAssertTrue(round.tick(30)) + } +} diff --git a/ios/IPPTests/MapPinsServiceTests.swift b/ios/IPPTests/MapPinsServiceTests.swift new file mode 100644 index 0000000..fdb71fb --- /dev/null +++ b/ios/IPPTests/MapPinsServiceTests.swift @@ -0,0 +1,89 @@ +import XCTest + +@testable import IPP + +/// Phase 5C testing item 1: the fallback rule — backend pins versus the offline +/// sample. This is the whole of the decision the game depends on, and it is a +/// pure function so it can be pinned here rather than at the device gate. +final class MapPinsServiceTests: XCTestCase { + + private let fallback = [ + GeoPin(latitude: -33.0, longitude: -72.2), + GeoPin(latitude: -33.1, longitude: -72.3), + ] + private let fetched = [ + GeoPin(latitude: -33.04, longitude: -71.62), + GeoPin(latitude: -33.05, longitude: -71.61), + GeoPin(latitude: -33.06, longitude: -71.60), + ] + + func testBackendPinsAreUsedAndMarkedLive() { + let result = MapPinsService.resolve(fetched: fetched, fallback: fallback) + XCTAssertEqual(result.pins, fetched) + XCTAssertTrue(result.isLive) + } + + func testAnUnreachableBackendFallsBackToTheSample() { + let result = MapPinsService.resolve(fetched: nil, fallback: fallback) + XCTAssertEqual(result.pins, fallback) + XCTAssertFalse(result.isLive) + } + + func testAnEmptyBackendAnswerCountsAsAMissNotAsLiveData() { + // "The backend is up but has no patients yet" is exactly the demo case + // where a blank plate looks broken and the sample looks right. + let result = MapPinsService.resolve(fetched: [], fallback: fallback) + XCTAssertEqual(result.pins, fallback) + XCTAssertFalse(result.isLive) + } + + func testUnusableCoordinatesAreFilteredOutOfBothSides() { + let poisoned = fetched + [GeoPin(latitude: .nan, longitude: 0)] + let result = MapPinsService.resolve(fetched: poisoned, fallback: fallback) + XCTAssertEqual(result.pins, fetched) + XCTAssertTrue(result.isLive) + + // A backend answer made up entirely of junk is a miss. + let allJunk = MapPinsService.resolve( + fetched: [GeoPin(latitude: .infinity, longitude: .nan)], + fallback: fallback + ) + XCTAssertEqual(allJunk.pins, fallback) + XCTAssertFalse(allJunk.isLive) + } + + func testWithNothingAnywhereTheMapIsEmptyRatherThanUndefined() { + let result = MapPinsService.resolve(fetched: nil, fallback: []) + XCTAssertEqual(result.pins, []) + XCTAssertFalse(result.isLive) + XCTAssertEqual(result, FloorMapData.none) + } + + func testTheWireFormatDecodesToPlainCoordinatesAndDropsEverythingElse() throws { + // The backend also sends `id` and `anchorKey`; the game must never see + // them (see GeoPin's doc comment). + let json = """ + {"pins":[ + {"id":"35200ad8-3ca3-5a89-a368-6376f0f82e21", + "latitude":-33.03596037947302, + "longitude":-71.38914117733839, + "anchorKey":"1fc0295a6d78bd1f69a522f8232ac8fab2e390dceea7f5b10941a416131b3178"} + ]} + """ + let decoded = try JSONDecoder().decode(MapPinsResponse.self, from: Data(json.utf8)) + let coordinates = decoded.coordinates + + XCTAssertEqual(coordinates.count, 1) + XCTAssertEqual(coordinates[0].latitude, -33.03596037947302, accuracy: 1e-12) + XCTAssertEqual(coordinates[0].longitude, -71.38914117733839, accuracy: 1e-12) + XCTAssertTrue(coordinates[0].isUsable) + } + + func testAnEmptyPinArrayDecodesRatherThanThrowing() throws { + let decoded = try JSONDecoder().decode( + MapPinsResponse.self, + from: Data(#"{"pins":[]}"#.utf8) + ) + XCTAssertTrue(decoded.coordinates.isEmpty) + } +} diff --git a/ios/IPPTests/PodiumBreathingTests.swift b/ios/IPPTests/PodiumBreathingTests.swift new file mode 100644 index 0000000..28309fc --- /dev/null +++ b/ios/IPPTests/PodiumBreathingTests.swift @@ -0,0 +1,233 @@ +import RealityKit +import XCTest +import simd + +@testable import IPP + +/// Phase 5B task 5B.1 (FR-012): the podium breathes. +/// +/// The height function and the rung ladder are the whole of the feature that +/// can be checked without a camera — whether a *ball* then rests on a moving +/// step is the owner's gate row 5B-g2. +@MainActor +final class PodiumBreathingTests: XCTestCase { + + private let steps = PodiumBuilder.Step.allCases + + // MARK: - The height function + + func testEveryStepStaysInsideItsAmplitudeForever() { + for step in steps { + let range = PodiumBreathing.bounds(for: step) + for tick in stride(from: 0.0, through: 120.0, by: 0.05) { + let height = PodiumBreathing.height(for: step, at: tick) + XCTAssertGreaterThanOrEqual(height, range.lowerBound - 1e-5, "\(step) at \(tick)") + XCTAssertLessThanOrEqual(height, range.upperBound + 1e-5, "\(step) at \(tick)") + } + } + } + + func testTheShortestStepNeverApproachesZeroHeight() { + // A step that shrank to nothing would drop the trophy through the + // table; bronze is the one with the least room. + let bronze = PodiumBreathing.bounds(for: .bronze) + XCTAssertGreaterThan(bronze.lowerBound, 0.02, "the podium must stay a podium") + } + + func testEachStepPassesThroughItsRestingHeight() { + for step in steps { + let range = PodiumBreathing.bounds(for: step) + XCTAssertEqual((range.lowerBound + range.upperBound) / 2, step.height, accuracy: 1e-6) + } + } + + func testTheThreeStepsBreatheOutOfPhase() { + // Same instant, three different points in the cycle: the podium never + // pulses as one block. + let offsets = steps.map { PodiumBreathing.height(for: $0, at: 0) - $0.height } + for (a, b) in [(0, 1), (0, 2), (1, 2)] { + XCTAssertGreaterThan( + abs(offsets[a] - offsets[b]), 0.005, + "\(steps[a]) and \(steps[b]) start too close together" + ) + } + } + + func testTheThreeStepsHaveDifferentPeriods() { + let periods = steps.map { PodiumBreathing.period(for: $0) } + XCTAssertEqual(Set(periods).count, periods.count, "equal periods would lock the steps together") + for period in periods { + XCTAssertGreaterThanOrEqual(period, 3.0, "the breath must stay slow") + XCTAssertLessThanOrEqual(period, 5.5) + } + } + + func testHeightRepeatsAfterExactlyOnePeriod() { + for step in steps { + let period = PodiumBreathing.period(for: step) + for tick in stride(from: 0.0, through: 3.0, by: 0.25) { + XCTAssertEqual( + PodiumBreathing.height(for: step, at: tick), + PodiumBreathing.height(for: step, at: tick + period), + accuracy: 1e-4, + "\(step) at \(tick)" + ) + } + } + } + + func testAFrozenClockFreezesTheHeight() { + // How the pause during a make celebration works: the coordinator stops + // advancing the clock, so the same time gives the same height and the + // breath resumes from the same phase rather than jumping. + for step in steps { + let held = PodiumBreathing.height(for: step, at: 7.5) + XCTAssertEqual(PodiumBreathing.height(for: step, at: 7.5), held) + } + } + + func testDegenerateOscillatorsReturnTheRestingHeight() { + XCTAssertEqual( + PodiumBreathing.height(base: 0.09, amplitude: 0.02, period: 0, phase: 0, at: 3), + 0.09 + ) + XCTAssertEqual( + PodiumBreathing.height(base: 0.09, amplitude: 0, period: 4, phase: 0, at: 3), + 0.09 + ) + } + + // MARK: - The rung ladder + + func testEachLadderSpansExactlyTheStepsBand() { + for step in steps { + guard let ladder = PodiumBreathing.ladder(for: step) else { + return XCTFail("no ladder for \(step)") + } + let range = PodiumBreathing.bounds(for: step) + XCTAssertEqual(ladder.rungs.count, PodiumBreathing.rungCount) + XCTAssertEqual(ladder.rungs.first?.height ?? 0, range.lowerBound, accuracy: 1e-6) + XCTAssertEqual(ladder.rungs.last?.height ?? 0, range.upperBound, accuracy: 1e-6) + for (lower, higher) in zip(ladder.rungs, ladder.rungs.dropFirst()) { + XCTAssertGreaterThan(higher.height, lower.height) + } + } + } + + func testTheQuantisationIsFinerThanTheEyeAndThanTheBall() { + let ballRadius = TossController.Tuning().ballRadius + for step in steps { + guard let ladder = PodiumBreathing.ladder(for: step) else { + return XCTFail("no ladder for \(step)") + } + XCTAssertLessThan(ladder.spacing, 0.004, "a visible step in the animation") + XCTAssertLessThan( + ladder.spacing, ballRadius / 5, + "a rung change must nudge a resting ball, not punch it" + ) + } + } + + func testTheLadderRoundsToTheNearestRungAndClampsAtTheEnds() { + guard let ladder = PodiumBreathing.ladder(for: .gold) else { + return XCTFail("no ladder for gold") + } + let range = PodiumBreathing.bounds(for: .gold) + + XCTAssertEqual(ladder.index(nearest: range.lowerBound), 0) + XCTAssertEqual(ladder.index(nearest: range.upperBound), ladder.rungs.count - 1) + XCTAssertEqual(ladder.index(nearest: range.lowerBound - 10), 0, "clamped below") + XCTAssertEqual(ladder.index(nearest: range.upperBound + 10), ladder.rungs.count - 1) + XCTAssertEqual(ladder.index(nearest: .nan), 0, "and a NaN cannot crash the loop") + + // Halfway between two rungs rounds to one of them, and never further + // than half a rung away from what was asked for. + for target in stride(from: range.lowerBound, through: range.upperBound, by: 0.0005) { + let chosen = ladder.rung(nearest: target) + XCTAssertLessThanOrEqual(abs(chosen.height - target), ladder.spacing / 2 + 1e-5) + } + } + + func testEveryHeightTheFunctionCanAskForIsOnTheLadder() { + for step in steps { + guard let ladder = PodiumBreathing.ladder(for: step) else { + return XCTFail("no ladder for \(step)") + } + for tick in stride(from: 0.0, through: 20.0, by: 0.05) { + let wanted = PodiumBreathing.height(for: step, at: tick) + let index = ladder.index(nearest: wanted) + XCTAssertTrue(ladder.rungs.indices.contains(index), "\(step) at \(tick)") + } + } + } + + func testLaddersAreSharedRatherThanRebuiltPerPlacement() { + // The meshes are the expensive part; two placements must not pay twice. + let first = PodiumBreathing.ladder(for: .silver) + let second = PodiumBreathing.ladder(for: .silver) + XCTAssertEqual(first?.rungs.count, second?.rungs.count) + XCTAssertTrue(first?.rungs.first?.mesh === second?.rungs.first?.mesh) + } + + // MARK: - Applying a rung to a real step + + func testResizingAStepMovesItsMeshAndItsColliderTogether() { + let step = PodiumBuilder.makeStep( + name: PodiumBuilder.Name.goldStep, + color: PodiumBuilder.Medal.gold, + height: PodiumBuilder.Metrics.goldHeight, + x: 0 + ) + guard let ladder = PodiumBreathing.ladder(for: .gold) else { + return XCTFail("no ladder for gold") + } + let tall = ladder.rungs[ladder.rungs.count - 1] + + PodiumBuilder.resize(step, mesh: tall.mesh, shape: tall.shape, height: tall.height) + + XCTAssertEqual(step.position.y, tall.height / 2, accuracy: 1e-6, "still resting on the surface") + XCTAssertTrue(step.model?.mesh === tall.mesh) + XCTAssertEqual(step.collision?.shapes.count, 1) + // The step is never scaled — that is the point of the ladder, since a + // scaled collider is a RealityKit behaviour this cannot verify. + XCTAssertEqual(step.scale, .one) + + let box = step.model?.mesh.bounds.extents ?? .zero + XCTAssertEqual(box.y, tall.height, accuracy: 0.002, "the drawn box is the asked-for height") + } + + func testAStepDrawnAtARungIsCollidableAndStatic() { + let step = PodiumBuilder.makeStep( + name: PodiumBuilder.Name.bronzeStep, + color: PodiumBuilder.Medal.bronze, + height: PodiumBuilder.Metrics.bronzeHeight, + x: 0 + ) + guard let shortest = PodiumBreathing.ladder(for: .bronze)?.rungs.first else { + return XCTFail("no ladder for bronze") + } + PodiumBuilder.resize(step, mesh: shortest.mesh, shape: shortest.shape, height: shortest.height) + + XCTAssertFalse(step.collision?.shapes.isEmpty ?? true) + XCTAssertEqual(step.components[PhysicsBodyComponent.self]?.mode, .static) + } + + // MARK: - What rides the step + + func testTheTrophyStandsOnWhateverHeightItsStepCurrentlyHas() { + for step in steps { + let range = PodiumBreathing.bounds(for: step) + for height in [range.lowerBound, step.height, range.upperBound] { + let place = step.trophyPosition(atHeight: height) + XCTAssertEqual(place.y, height, accuracy: 1e-6) + XCTAssertEqual(place.x, step.x, accuracy: 1e-6) + XCTAssertEqual(place.z, 0, accuracy: 1e-6) + } + } + // And the no-argument form is still the resting height. + XCTAssertEqual( + PodiumBuilder.Step.gold.trophyPosition, + PodiumBuilder.Step.gold.trophyPosition(atHeight: PodiumBuilder.Step.gold.height) + ) + } +} diff --git a/ios/IPPTests/PodiumBuilderTests.swift b/ios/IPPTests/PodiumBuilderTests.swift new file mode 100644 index 0000000..b8c38c6 --- /dev/null +++ b/ios/IPPTests/PodiumBuilderTests.swift @@ -0,0 +1,389 @@ +import RealityKit +import XCTest +import simd + +@testable import IPP + +/// A generator that always yields zero, so a "pick one of the others at +/// random" rule can be shown to hold even when the randomness does not help. +private struct AlwaysZeroGenerator: RandomNumberGenerator { + mutating func next() -> UInt64 { 0 } +} + +/// Throwaway harness used during Phase 2 to run `PodiumBuilder`'s structural +/// assertions for real on a Simulator. Phase 6a adds the permanent test target; +/// this file lives outside the repo on purpose. +@MainActor +final class PodiumBuilderTests: XCTestCase { + + func testSelfCheckReportsNoProblems() { + XCTAssertEqual(PodiumBuilder.selfCheck(), []) + } + + func testSceneContainsThreeStepsCupAndFloor() { + let scene = PodiumBuilder.makeScene() + + for name in [ + PodiumBuilder.Name.goldStep, + PodiumBuilder.Name.silverStep, + PodiumBuilder.Name.bronzeStep, + PodiumBuilder.Name.trophy, + PodiumBuilder.Name.cup, + PodiumBuilder.Name.cupFloor, + PodiumBuilder.Name.floor + ] { + XCTAssertNotNil(scene.findEntity(named: name), "missing \(name)") + } + + let wallSegments = (0.. = [] + for _ in 0..<200 { + seen.insert(PodiumBuilder.nextStep(after: current, using: &generator)) + } + XCTAssertEqual(seen, Set(PodiumBuilder.Step.allCases).subtracting([current])) + } + } + + /// A rigged generator proves the "never the current step" property is + /// structural (the current step is removed from the pool) rather than a + /// lucky draw: even a generator that always picks the first candidate + /// cannot land on the current step. + func testTheStepPickerHoldsWithADegenerateGenerator() { + var generator = AlwaysZeroGenerator() + for current in PodiumBuilder.Step.allCases { + for _ in 0..<10 { + XCTAssertNotEqual(PodiumBuilder.nextStep(after: current, using: &generator), current) + } + } + } + + func testEveryStepPutsTheTrophyOnItsOwnTopFace() { + for step in PodiumBuilder.Step.allCases { + XCTAssertEqual(step.trophyPosition.y, step.height, accuracy: 0.0001) + XCTAssertEqual(step.trophyPosition.x, step.x, accuracy: 0.0001) + XCTAssertEqual(step.trophyPosition.z, 0, accuracy: 0.0001) + } + // The three steps really are three different places to aim at. + let places = PodiumBuilder.Step.allCases.map { $0.trophyPosition } + for (index, place) in places.enumerated() { + for other in places[(index + 1)...] { + XCTAssertGreaterThan(simd_distance(place, other), 0.02, "steps are too close to matter") + } + } + } + + func testStepsRestOnTheAnchorPlane() { + let scene = PodiumBuilder.makeScene() + let expected: [(String, Float)] = [ + (PodiumBuilder.Name.goldStep, PodiumBuilder.Metrics.goldHeight), + (PodiumBuilder.Name.silverStep, PodiumBuilder.Metrics.silverHeight), + (PodiumBuilder.Name.bronzeStep, PodiumBuilder.Metrics.bronzeHeight) + ] + for (name, height) in expected { + guard let step = scene.findEntity(named: name) else { + XCTFail("missing \(name)") + continue + } + XCTAssertEqual(step.position.y, height / 2, accuracy: 0.0001, "\(name)") + } + } + + // MARK: - Flared rim (Phase 4, task 4.0b — Gate 3 row 3.2) + + /// The cup must be a shallow cone, not a tube: wider at the mouth than at + /// the floor, so its rim is a slope with nowhere for a ball to balance. + func testTheCupWidensTowardItsMouth() { + let metrics = PodiumBuilder.Metrics.self + let atFloor = metrics.cupInnerRadius(atHeight: metrics.cupFloorThickness) + let atMouth = metrics.cupInnerRadius(atHeight: metrics.cupRimHeight) + + XCTAssertGreaterThan(metrics.cupWallFlare, 0, "a flat rim is what balls balanced on") + XCTAssertGreaterThan(atMouth, atFloor + 0.005, "the flare is too slight to matter") + XCTAssertGreaterThan(atMouth, metrics.cupInnerRadius) + } + + /// The flare leans the wall's base inward, so check it did not close the + /// cup around the ball. + func testTheFlaredWallStillLetsABallReachTheCupFloor() { + let metrics = PodiumBuilder.Metrics.self + let ballRadius = TossController.Tuning().ballRadius + let restingHeight = metrics.cupFloorThickness + ballRadius + XCTAssertGreaterThan( + metrics.cupInnerRadius(atHeight: restingHeight), + ballRadius + 0.005, + "the ball cannot settle on the cup floor" + ) + } + + /// The rim slope is only a fix if it beats the rim's friction — a ball must + /// slide off it rather than grip. + func testTheRimIsSteeperThanItIsGrippy() { + let metrics = PodiumBuilder.Metrics.self + let frictionAngle = atan(metrics.cupRimFriction) + XCTAssertGreaterThan( + metrics.cupWallFlare, + frictionAngle, + "a ball would still rest on the rim instead of sliding off" + ) + } + + // MARK: - The scoring geometry (Gate 4 DEFECT, SC-002) + + /// The bridge between the built cup and the scoring rule, checked against + /// the real metrics: a ball resting on the cup floor is inside, and a ball + /// pressed against the *outside* of the wall is not — at any height on the + /// wall, the low front included, which is where the owner produced false + /// makes at Gate 4. + func testOnlyABallActuallyInTheCupReadsAsInside() { + let toss = TossController() + let radius = toss.tuning.ballRadius + let metrics = PodiumBuilder.Metrics.self + + let resting = SIMD3(0, metrics.cupFloorThickness + radius, 0) + XCTAssertTrue( + toss.isInsideCup(PodiumBuilder.cupPlacement(ofBallAt: resting), ballRadius: radius), + "a ball sitting on the cup floor must count as a make" + ) + + for step in 0...24 { + let height = Float(step) / 24 * metrics.cupRimHeight + let outward = metrics.cupInnerRadius(atHeight: height) + metrics.cupWallThickness + radius + for angle in stride(from: Float(0), to: 2 * .pi, by: .pi / 6) { + let outside = SIMD3(outward * cos(angle), height, outward * sin(angle)) + XCTAssertFalse( + toss.isInsideCup(PodiumBuilder.cupPlacement(ofBallAt: outside), ballRadius: radius), + "a ball touching the cup's outside at \(height) m read as inside" + ) + } + } + } + + /// A ball perched on the rim is not a make either — Gate 3's rim rescue and + /// Gate 4's make rule must not disagree about the same ball. + func testAPerchedBallIsPerchedAndNotInside() { + let toss = TossController() + let radius = toss.tuning.ballRadius + let metrics = PodiumBuilder.Metrics.self + let centre = SIMD3(metrics.cupRimRingRadius, metrics.cupRimHeight + radius, 0) + let placement = PodiumBuilder.cupPlacement(ofBallAt: centre) + + XCTAssertFalse(toss.isInsideCup(placement, ballRadius: radius)) + XCTAssertTrue( + toss.isPerchedOnRim( + placement, + ballRadius: radius, + cupOuterRadius: metrics.cupRimOuterRadius + ) + ) + } + + /// Every collidable part of the cup is what the +1 tier listens to, so the + /// cup has to actually have collidable parts — and they must be ordinary + /// physics bodies, not sensors. + func testTheCupHasCollidablePartsForTheHitTier() throws { + let scene = PodiumBuilder.makeScene() + let cup = try XCTUnwrap(scene.findEntity(named: PodiumBuilder.Name.cup)) + + var colliders: [Entity] = [] + func walk(_ entity: Entity) { + if entity.components[CollisionComponent.self] != nil { colliders.append(entity) } + entity.children.forEach(walk) + } + walk(cup) + + // Twelve wall segments plus the floor disc. + XCTAssertEqual(colliders.count, PodiumBuilder.Metrics.cupWallSegments + 1) + for collider in colliders { + XCTAssertEqual( + collider.components[CollisionComponent.self]?.mode, + .default, + "\(collider.name) would not produce a physical bounce" + ) + } + } + + /// The entities, not just the arithmetic: every wall segment must actually + /// lean outward, and the ring must stay closed at the mouth where the flare + /// has spread the segments furthest apart. + func testEveryWallSegmentLeansOutward() throws { + let scene = PodiumBuilder.makeScene() + let metrics = PodiumBuilder.Metrics.self + + for index in 0..( + sin(2 * Float.pi * Float(index) / Float(metrics.cupWallSegments)), + 0, + cos(2 * Float.pi * Float(index) / Float(metrics.cupWallSegments)) + ) + let localUp = segment.orientation.act(SIMD3(0, 1, 0)) + XCTAssertEqual(localUp.y, cos(metrics.cupWallFlare), accuracy: 1e-4, "segment \(index)") + XCTAssertEqual( + simd_dot(localUp, outward), + sin(metrics.cupWallFlare), + accuracy: 1e-4, + "segment \(index) leans the wrong way" + ) + } + } + + func testTheWallRingHasNoGapsAtTheMouth() throws { + let scene = PodiumBuilder.makeScene() + let metrics = PodiumBuilder.Metrics.self + let segment = try XCTUnwrap(scene.findEntity(named: "\(PodiumBuilder.Name.cupWall)_0")) + let model = try XCTUnwrap(segment.components[ModelComponent.self]) + let width = model.mesh.bounds.extents.x + let chordAtMouth = 2 * metrics.cupRimRingRadius * sin(.pi / Float(metrics.cupWallSegments)) + XCTAssertGreaterThan(width, chordAtMouth, "neighbouring segments leave a gap at the rim") + } + + // MARK: - Ball (Phase 3) + + func testBallIsADynamicSphereWithCollision() { + let ball = PodiumBuilder.makeBall( + id: 7, + radius: 0.035, + mass: 0.045, + friction: 0.6, + restitution: 0.35 + ) + XCTAssertEqual(ball.name, "\(PodiumBuilder.Name.ballPrefix)7") + XCTAssertNotNil(ball.components[ModelComponent.self], "the ball must be visible") + XCTAssertFalse(ball.components[CollisionComponent.self]?.shapes.isEmpty ?? true) + XCTAssertEqual(ball.components[PhysicsBodyComponent.self]?.mode, .dynamic) + XCTAssertEqual(ball.components[PhysicsBodyComponent.self]?.massProperties.mass, 0.045) + XCTAssertTrue( + ball.components[PhysicsBodyComponent.self]?.isContinuousCollisionDetectionEnabled ?? false, + "a fast ball would tunnel through the 6 mm cup wall without CCD" + ) + XCTAssertNotNil( + ball.components[PhysicsMotionComponent.self], + "the culler reads the ball's velocity from the first frame" + ) + } + + /// The +1 tier rests on the cup wall and the ball being able to see each + /// other's collision filters. Assert it rather than discovering it on + /// device. + func testBallAndCupWallCollisionFiltersSeeEachOther() throws { + let scene = PodiumBuilder.makeScene() + let wall = try XCTUnwrap(scene.findEntity(named: "\(PodiumBuilder.Name.cupWall)_0")) + let wallFilter = try XCTUnwrap(wall.components[CollisionComponent.self]).filter + + let ball = PodiumBuilder.makeBall( + id: 1, + radius: 0.035, + mass: 0.045, + friction: 0.6, + restitution: 0.35 + ) + let ballFilter = try XCTUnwrap(ball.components[CollisionComponent.self]).filter + + XCTAssertNotEqual( + wallFilter.mask.rawValue & ballFilter.group.rawValue, + 0, + "the cup wall cannot see the ball" + ) + XCTAssertNotEqual( + ballFilter.mask.rawValue & wallFilter.group.rawValue, + 0, + "the ball cannot see the cup wall" + ) + } + + func testCylinderMeshIsGeneratedProcedurally() { + let mesh = PodiumBuilder.cylinderMesh(height: 0.02, radius: 0.05, segments: 16) + let parts = Array(mesh.contents.models).flatMap { Array($0.parts) } + XCTAssertFalse(parts.isEmpty, "cylinder mesh has no parts") + // 4n + 2 vertices and 12n indices for n = 16. + let positions = parts.reduce(0) { $0 + $1.positions.count } + let indices = parts.reduce(0) { $0 + ($1.triangleIndices?.count ?? 0) } + XCTAssertEqual(positions, 4 * 16 + 2) + XCTAssertEqual(indices, 12 * 16) + XCTAssertTrue(parts.allSatisfy { $0.normals != nil }, "cylinder mesh has no normals") + } +} diff --git a/ios/IPPTests/StandingsDisplayTests.swift b/ios/IPPTests/StandingsDisplayTests.swift new file mode 100644 index 0000000..8debf00 --- /dev/null +++ b/ios/IPPTests/StandingsDisplayTests.swift @@ -0,0 +1,187 @@ +import RealityKit +import XCTest +import simd + +@testable import IPP + +/// Phase 5B task 5B.3 (FR-013): the podium name labels. Whether they *look* +/// right is the owner's gate row; what is checked here is that they are built, +/// positioned, tinted and — above all — incapable of touching a ball. +/// +/// **Phase 5C trimmed this suite.** The Star Wars crawl of places #4+ was +/// removed from the game by owner decision (the space under the podium is now +/// `FloorMap`), so the thirteen crawl cases went with it. What remains is the +/// label half, unchanged, plus one case asserting the crawl really is gone +/// rather than merely unused. +@MainActor +final class StandingsDisplayTests: XCTestCase { + + private func placedScene() -> (scene: Entity, display: StandingsDisplay.Display) { + let scene = PodiumBuilder.makeScene() + let display = StandingsDisplay.attach(to: scene, standings: SyntheticStandings.standings()) + return (scene, display) + } + + private func descendants(of entity: Entity) -> [Entity] { + entity.children.flatMap { [$0] + descendants(of: $0) } + } + + // MARK: - What gets built + + func testThePodiumGetsExactlyThreeLabelsAndNothingElse() { + let (_, display) = placedScene() + + XCTAssertEqual(display.labels.count, 3) + XCTAssertEqual(display.labels.map(\.step), [.gold, .silver, .bronze]) + } + + func testTheCrawlIsGoneFromThePlacedScene() { + // Phase 5C removed it. A leftover crawl root would mean the deletion + // was cosmetic and the text is still marching under the floor map. + let (scene, _) = placedScene() + for name in ["standings_crawl", "crawl_line_0", "crawl_line_1"] { + XCTAssertNil(scene.findEntity(named: name), "the crawl survived: \(name)") + } + } + + func testLabelsHangOffTheStepsContainerSoTheyInheritThePodiumsPlacement() { + let (scene, _) = placedScene() + guard let labelRoot = scene.findEntity(named: StandingsDisplay.Name.labels) else { + return XCTFail("no label root") + } + XCTAssertEqual(labelRoot.parent?.name, PodiumBuilder.Name.steps) + } + + func testEveryPieceOfSceneryIsInertNoCollisionNoPhysics() { + // FR-013: "purely decorative". Nothing here may ever touch a ball. + let (scene, _) = placedScene() + guard let root = scene.findEntity(named: StandingsDisplay.Name.labels) else { + return XCTFail("no label root") + } + for entity in [root] + descendants(of: root) { + XCTAssertNil(entity.components[CollisionComponent.self], entity.name) + XCTAssertNil(entity.components[PhysicsBodyComponent.self], entity.name) + } + } + + func testTheSceneryAddsNoCollidersToThePodiumAtAll() { + // Stronger version of the above: the placed scene must have exactly the + // colliders `PodiumBuilder` puts there, with or without the standings. + func colliders(_ entity: Entity) -> Int { + ([entity] + descendants(of: entity)) + .filter { $0.components[CollisionComponent.self] != nil } + .count + } + let bare = PodiumBuilder.makeScene() + let before = colliders(bare) + _ = StandingsDisplay.attach(to: bare, standings: SyntheticStandings.standings()) + XCTAssertEqual(colliders(bare), before) + } + + func testEachLabelCarriesItsNameAndItsScoreAsTwoLinesOfRealGeometry() { + let (_, display) = placedScene() + for label in display.labels { + let models = descendants(of: label.entity).compactMap { $0 as? ModelEntity } + XCTAssertEqual(models.count, 2, "a name and a score") + for model in models { + XCTAssertGreaterThan( + model.model?.mesh.bounds.extents.x ?? 0, 0, + "the text mesh is empty" + ) + } + // Name above, score below. + let heights = models.map(\.position.y).sorted() + XCTAssertLessThan(heights[0], heights[1]) + } + } + + func testLabelsAreTintedWithTheirStepsMedalColour() { + XCTAssertEqual(StandingsDisplay.tint(for: .gold), PodiumBuilder.Medal.gold) + XCTAssertEqual(StandingsDisplay.tint(for: .silver), PodiumBuilder.Medal.silver) + XCTAssertEqual(StandingsDisplay.tint(for: .bronze), PodiumBuilder.Medal.bronze) + } + + func testATextModelIsCentredOnItsOwnOrigin() { + // Otherwise every billboard rotation would swing the label around its + // left edge. + let model = StandingsDisplay.makeTextModel( + "Dra. Ficticia", + size: StandingsDisplay.Look.nameSize, + material: UnlitMaterial(color: PodiumBuilder.Medal.gold) + ) + let bounds = model.visualBounds(relativeTo: model.parent) + XCTAssertEqual(bounds.center.x, 0, accuracy: 0.002) + XCTAssertEqual(bounds.center.y, 0, accuracy: 0.002) + // …and scaled to roughly the requested em size, not left at design size. + XCTAssertLessThan(bounds.extents.y, StandingsDisplay.Look.nameSize * 2) + } + + func testTextIsBuiltAtAHumanScale() { + // The em size is in metres, so a wrong constant does not fail to + // compile — it puts a three-metre name across the room. These bounds + // are the "does this fit on a podium" check. + let standings = SyntheticStandings.standings() + let white = UnlitMaterial(color: .white) + + let name = StandingsDisplay.makeTextModel( + standings[0].shortName, + size: StandingsDisplay.Look.nameSize, + material: white + ) + let nameBounds = name.visualBounds(relativeTo: nil) + print("label width \(nameBounds.extents.x) m, height \(nameBounds.extents.y) m") + XCTAssertGreaterThan(nameBounds.extents.x, 0.03) + XCTAssertLessThan(nameBounds.extents.x, 0.20, "wider than the whole podium") + XCTAssertGreaterThan(nameBounds.extents.y, 0.005, "too small to read at a metre") + XCTAssertLessThan(nameBounds.extents.y, 0.025) + } + + // MARK: - Riding a breathing step (FR-012 × FR-013) + + func testALabelIsSeatedAboveAndInFrontOfItsStepsCurrentTopFace() { + let label = Entity() + for step in PodiumBuilder.Step.allCases { + let range = PodiumBreathing.bounds(for: step) + for height in [range.lowerBound, step.height, range.upperBound] { + StandingsDisplay.seat(label, on: step, height: height) + XCTAssertEqual(label.position.x, step.x, accuracy: 1e-6) + XCTAssertGreaterThan(label.position.y, height, "the label floats above the step") + XCTAssertEqual( + label.position.y - height, + StandingsDisplay.Look.labelLift, + accuracy: 1e-6 + ) + XCTAssertGreaterThan( + label.position.z, PodiumBuilder.Metrics.stepDepth / 2, + "in front of the step, so it never fights the trophy for the same air" + ) + } + } + } + + // MARK: - Billboarding + + func testBillboardingTurnsALabelsFaceTowardTheCamera() { + let label = Entity() + label.position = [0, 0.2, 0] + for angle in stride(from: Float(0), through: 350, by: 25) { + let radians = angle * .pi / 180 + let camera = SIMD3(2 * sin(radians), 0.4, 2 * cos(radians)) + StandingsDisplay.billboard(label, toward: camera) + + let facing = label.orientation.act(SIMD3(0, 0, 1)) + let toCamera = simd_normalize(SIMD3(camera.x, 0, camera.z) - [0, 0, 0]) + XCTAssertEqual(facing.x, toCamera.x, accuracy: 1e-4, "\(angle)°") + XCTAssertEqual(facing.z, toCamera.z, accuracy: 1e-4, "\(angle)°") + XCTAssertEqual(facing.y, 0, accuracy: 1e-5, "yaw only — text must stay upright") + } + } + + func testBillboardingIgnoresACameraDirectlyAboveTheLabel() { + let label = Entity() + label.position = [0, 0.2, 0] + let before = label.orientation + StandingsDisplay.billboard(label, toward: [0, 3, 0]) + XCTAssertEqual(label.orientation.vector, before.vector) + } +} diff --git a/ios/IPPTests/SyntheticMapPinsTests.swift b/ios/IPPTests/SyntheticMapPinsTests.swift new file mode 100644 index 0000000..11a16bb --- /dev/null +++ b/ios/IPPTests/SyntheticMapPinsTests.swift @@ -0,0 +1,98 @@ +import XCTest + +@testable import IPP + +/// Phase 5C: the floor map's offline sample. It has to be deterministic (so the +/// map is the same every time), usable (so the projection never has to throw +/// any of it away) and obviously not real. +final class SyntheticMapPinsTests: XCTestCase { + + func testEveryPinIsUsable() { + let pins = SyntheticMapPins.pins() + XCTAssertFalse(pins.isEmpty) + for pin in pins { + XCTAssertTrue(pin.isUsable, "\(pin)") + } + } + + func testTheSampleIsAsBigAsItsClustersSayAndSmallerThanTheDotCap() { + let expected = SyntheticMapPins.clusters.reduce(0) { $0 + $1.count } + XCTAssertEqual(SyntheticMapPins.pins().count, expected) + XCTAssertLessThanOrEqual(expected, FloorMap.Look.maxDots, "the sample is never thinned") + XCTAssertGreaterThan(expected, 40, "too few dots and it does not read as a map") + } + + func testTheSameSeedAlwaysGivesTheSameMap() { + XCTAssertEqual(SyntheticMapPins.pins(), SyntheticMapPins.pins()) + XCTAssertEqual(SyntheticMapPins.pins(seed: 7), SyntheticMapPins.pins(seed: 7)) + } + + func testDifferentSeedsGiveDifferentMaps() { + XCTAssertNotEqual(SyntheticMapPins.pins(seed: 1), SyntheticMapPins.pins(seed: 2)) + } + + func testThePinsStayNearTheirClusterCentres() { + // Box–Muller with an unclamped u1 would occasionally throw a pin across + // the Pacific and blow up the bounding-box fit; 6σ is a generous bound + // that a broken generator would still fail. + var index = 0 + let pins = SyntheticMapPins.pins() + for cluster in SyntheticMapPins.clusters { + for _ in 0.. TossController.Swipe { + TossController.Swipe(translation: SIMD2(sideways, -up), duration: duration) + } + + private var tuning: TossController.Tuning { TossController.Tuning() } + + // MARK: - Speed clamping + + func testZeroSwipeStillProducesAClampedForwardImpulse() { + let controller = TossController() + let flat = swipe() + + XCTAssertEqual(controller.launchSpeed(for: flat), tuning.minLaunchSpeed, accuracy: 1e-5) + + let velocity = controller.launchVelocity(for: flat, camera: camera) + XCTAssertEqual(simd_length(velocity), tuning.minLaunchSpeed, accuracy: 1e-4) + XCTAssertLessThan(velocity.z, 0, "a zero swipe must still travel away from the player") + XCTAssertGreaterThan(velocity.y, 0, "every throw is lofted") + XCTAssertEqual(velocity.x, 0, accuracy: 1e-5, "a straight swipe must not drift sideways") + } + + func testSlowShortSwipeClampsToTheMinimumSpeed() { + let controller = TossController() + // 30 pt over half a second — 60 pt/s, far below `slowFlick`. + XCTAssertEqual( + controller.launchSpeed(for: swipe(up: 30, duration: 0.5)), + tuning.minLaunchSpeed, + accuracy: 1e-5 + ) + } + + func testFastLongSwipeClampsToTheMaximumSpeed() { + let controller = TossController() + // 600 pt in 80 ms — 7500 pt/s, far above `fastFlick`. + XCTAssertEqual( + controller.launchSpeed(for: swipe(up: 600, duration: 0.08)), + tuning.maxLaunchSpeed, + accuracy: 1e-5 + ) + } + + func testSpeedRisesWithFlickSpeedBetweenTheClamps() { + let controller = TossController() + let gentle = controller.launchSpeed(for: swipe(up: 120, duration: 0.20)) // 600 pt/s + let firm = controller.launchSpeed(for: swipe(up: 200, duration: 0.15)) // ~1333 pt/s + let hard = controller.launchSpeed(for: swipe(up: 260, duration: 0.12)) // ~2167 pt/s + + XCTAssertLessThan(gentle, firm) + XCTAssertLessThan(firm, hard) + for speed in [gentle, firm, hard] { + XCTAssertGreaterThanOrEqual(speed, tuning.minLaunchSpeed) + XCTAssertLessThanOrEqual(speed, tuning.maxLaunchSpeed) + } + } + + func testAnImplausiblyBriefSwipeCannotDivideItsWayPastTheMaximum() { + let controller = TossController() + XCTAssertEqual( + controller.launchSpeed(for: swipe(up: 200, duration: 0)), + tuning.maxLaunchSpeed, + accuracy: 1e-5 + ) + } + + // MARK: - Direction + + func testEveryThrowIsLoftedByTheTunedArc() { + let controller = TossController() + let velocity = controller.launchVelocity(for: swipe(up: 200), camera: camera) + // Camera aims along −Z, so the loft shows up as up-over-forward. + XCTAssertEqual(velocity.y / -velocity.z, tuning.arc, accuracy: 1e-4) + } + + func testSidewaysSwipeDeflectsTowardTheSwipeWithoutAddingPower() { + let controller = TossController() + let right = controller.launchVelocity(for: swipe(up: 120, sideways: 300), camera: camera) + let left = controller.launchVelocity(for: swipe(up: 120, sideways: -300), camera: camera) + let straight = controller.launchVelocity(for: swipe(up: 120), camera: camera) + + XCTAssertGreaterThan(right.x, 0, "swiping right must throw right") + XCTAssertLessThan(left.x, 0, "swiping left must throw left") + XCTAssertEqual(right.x, -left.x, accuracy: 1e-5, "deflection must be symmetric") + + // Power comes from the upward component alone, so all three are the + // same speed in different directions. + XCTAssertEqual(simd_length(right), simd_length(straight), accuracy: 1e-4) + XCTAssertEqual(simd_length(left), simd_length(straight), accuracy: 1e-4) + } + + func testPurelySidewaysSwipeIsClampedToTheMinimumSpeedAndStillAimsForward() { + let controller = TossController() + let velocity = controller.launchVelocity(for: swipe(sideways: 400), camera: camera) + + XCTAssertEqual(simd_length(velocity), tuning.minLaunchSpeed, accuracy: 1e-4) + XCTAssertLessThan(velocity.z, 0) + XCTAssertGreaterThan(velocity.x, 0) + } + + func testLateralDeflectionIsCapped() { + let controller = TossController() + XCTAssertEqual( + controller.lateralDeflection(for: swipe(up: 100, sideways: 5000)), + tuning.maxLateral, + accuracy: 1e-5 + ) + XCTAssertEqual( + controller.lateralDeflection(for: swipe(up: 100, sideways: -5000)), + -tuning.maxLateral, + accuracy: 1e-5 + ) + } + + func testDirectionFollowsTheCameraRatherThanTheWorldAxes() { + let controller = TossController() + // Player turned 90° to face +X. + let turned = TossController.CameraBasis( + position: [0, 1, 0], + forward: [1, 0, 0], + right: [0, 0, 1] + ) + let velocity = controller.launchVelocity(for: swipe(up: 200), camera: turned) + XCTAssertGreaterThan(velocity.x, 0, "the throw must follow the camera's aim") + XCTAssertEqual(velocity.z, 0, accuracy: 1e-5) + XCTAssertGreaterThan(velocity.y, 0) + } + + func testCameraBasisIsReadFromAnARKitStyleTransform() { + // ARKit camera transform, in its own landscape-right axes: +x right, + // +y up, +z backward. + var transform = matrix_identity_float4x4 + transform.columns.3 = SIMD4(0.5, 1.2, -0.3, 1) + let basis = TossController.CameraBasis(transform: transform, orientation: .landscapeRight) + + XCTAssertEqual(basis.position, SIMD3(0.5, 1.2, -0.3)) + XCTAssertEqual(basis.forward, SIMD3(0, 0, -1)) + XCTAssertEqual(basis.right, SIMD3(1, 0, 0)) + } + + // MARK: - The sideways axis (Q4, Phase 5B task 5B.0) + // + // Gate 5 row 5-g5: "I only see straight ball launches even with diagonal + // swipes". ARKit hands out a camera transform in landscape-right axes + // whatever the device is doing, and IPP is portrait-locked, so the old + // `columns.0` reading was steering along the phone's long axis. + + /// The ARKit camera transform of a phone held **in portrait**, back camera + /// looking along −Z, tilted `pitch` radians downward at the table. + /// + /// In landscape-right axes that means `columns.0` (ARKit's "right") runs + /// down the phone's long axis and `columns.1` (ARKit's "up") runs across + /// the screen — which is the whole of Q4, in two columns. + private func portraitCameraTransform( + pitch: Float = 0, + position: SIMD3 = [0, 1.3, 0] + ) -> simd_float4x4 { + let c = cos(pitch) + let s = sin(pitch) + var transform = matrix_identity_float4x4 + transform.columns.0 = SIMD4(0, -c, s, 0) + transform.columns.1 = SIMD4(1, 0, 0, 0) + transform.columns.2 = SIMD4(0, s, c, 0) + transform.columns.3 = SIMD4(position, 1) + return transform + } + + func testPortraitBasisReadsRightAcrossTheScreenNotAlongThePhone() { + let pitch: Float = 0.35 + let transform = portraitCameraTransform(pitch: pitch) + + // The bug, stated as an assertion: ARKit's first column is very nearly + // world-vertical for this pose, so reading it as "right" steers the + // throw up and down. + let landscapeColumn = SIMD3( + transform.columns.0.x, transform.columns.0.y, transform.columns.0.z + ) + XCTAssertGreaterThan(abs(landscapeColumn.y), 0.9) + + let basis = TossController.CameraBasis(transform: transform) + XCTAssertEqual(basis.right.x, 1, accuracy: 1e-5, "portrait right is across the screen") + XCTAssertEqual(basis.right.y, 0, accuracy: 1e-5) + XCTAssertEqual(basis.right.z, 0, accuracy: 1e-5) + XCTAssertEqual(basis.forward.y, -sin(pitch), accuracy: 1e-5, "and the aim still points down") + XCTAssertEqual(basis.forward.z, -cos(pitch), accuracy: 1e-5) + } + + func testTheFourOrientationsAreQuarterTurnsOfEachOther() { + var transform = matrix_identity_float4x4 + transform.columns.0 = SIMD4(1, 0, 0, 0) + transform.columns.1 = SIMD4(0, 1, 0, 0) + + func right(_ orientation: TossController.ScreenOrientation) -> SIMD3 { + TossController.CameraBasis(transform: transform, orientation: orientation).right + } + + XCTAssertEqual(right(.landscapeRight), SIMD3(1, 0, 0)) + XCTAssertEqual(right(.landscapeLeft), SIMD3(-1, 0, 0)) + XCTAssertEqual(right(.portrait), SIMD3(0, 1, 0)) + XCTAssertEqual(right(.portraitUpsideDown), SIMD3(0, -1, 0)) + } + + func testPortraitIsTheDefaultOrientationBecauseTheAppIsPortraitLocked() { + let transform = portraitCameraTransform(pitch: 0.2) + XCTAssertEqual( + TossController.CameraBasis(transform: transform).right, + TossController.CameraBasis(transform: transform, orientation: .portrait).right + ) + } + + func testSideAxisIsHorizontalAndAcrossTheAimForEveryPose() { + for pitchDegrees in stride(from: Float(-40), through: 60, by: 10) { + for yawDegrees in stride(from: Float(0), through: 315, by: 45) { + let pitch = pitchDegrees * .pi / 180 + let yaw = yawDegrees * .pi / 180 + let turn = simd_float4x4(simd_quatf(angle: yaw, axis: [0, 1, 0])) + let transform = turn * portraitCameraTransform(pitch: pitch) + let basis = TossController.CameraBasis(transform: transform) + let side = basis.sideAxis + let label = "pitch \(pitchDegrees)° yaw \(yawDegrees)°" + + XCTAssertEqual(simd_length(side), 1, accuracy: 1e-4, label) + XCTAssertEqual( + simd_dot(side, TossController.worldUp), 0, accuracy: 1e-4, + "\(label): steering must be orthogonal to gravity" + ) + XCTAssertEqual( + simd_dot(side, basis.forward), 0, accuracy: 1e-4, + "\(label): steering must be orthogonal to the aim" + ) + } + } + } + + func testDiagonalFlickVeersSidewaysInsteadOfChangingHeight() { + let controller = TossController() + // Looking 20° down at a podium on a table, portrait, as at Gate 5. + let basis = TossController.CameraBasis(transform: portraitCameraTransform(pitch: 0.35)) + let side = basis.sideAxis + + let straight = controller.launchVelocity(for: swipe(up: 200), camera: basis) + let diagonal = controller.launchVelocity(for: swipe(up: 200, sideways: 260), camera: basis) + let mirrored = controller.launchVelocity(for: swipe(up: 200, sideways: -260), camera: basis) + let speed = controller.launchSpeed(for: swipe(up: 200)) + + // A straight flick has no sideways component at all… + XCTAssertEqual(simd_dot(straight, side), 0, accuracy: 1e-4) + // …and a diagonal one has a big one, in the direction of the swipe. + XCTAssertGreaterThan( + simd_dot(diagonal, side), 0.25 * speed, + "an up-and-right flick must actually veer right" + ) + XCTAssertEqual( + simd_dot(mirrored, side), -simd_dot(diagonal, side), accuracy: 1e-4, + "and up-and-left must mirror it" + ) + XCTAssertGreaterThan(diagonal.x, 0.25 * speed, "which here is world +x") + XCTAssertLessThan(mirrored.x, -0.25 * speed) + + // The height of the throw is what must *not* move: the deflection is + // horizontal, so the only change in the vertical component is the small + // one that comes from re-normalising a longer heading vector. + XCTAssertEqual( + diagonal.y, straight.y, + accuracy: 0.1 * abs(straight.y), + "steering must not trade itself for loft — that was the Q4 bug" + ) + XCTAssertEqual(simd_length(diagonal), simd_length(straight), accuracy: 1e-4) + } + + func testTheOldLandscapeReadingSteeredAlongAQuiteDifferentAxis() { + // A regression witness rather than a rule: with the same pose read as + // landscape-right — what the code did until Phase 5B — the steering + // axis is (nearly) perpendicular to the screen's real right-hand axis, + // so a rightward swipe pushed the ball somewhere else entirely. + let transform = portraitCameraTransform(pitch: 0.35) + let corrected = TossController.CameraBasis(transform: transform).sideAxis + let old = TossController.CameraBasis(transform: transform, orientation: .landscapeRight).sideAxis + + XCTAssertEqual(abs(simd_dot(corrected, old)), 0, accuracy: 1e-4) + } + + func testSideAxisFallsBackToTheAimWhenTheScreenAxisIsVertical() { + // A phone rolled fully onto its side would hand over a vertical + // "right"; there is nothing horizontal to recover from it, so the axis + // comes from the aim instead. + let rolled = TossController.CameraBasis( + position: [0, 1, 0], + forward: [0, 0, -1], + right: [0, 1, 0] + ) + XCTAssertEqual(rolled.sideAxis.x, 1, accuracy: 1e-5) + XCTAssertEqual(rolled.sideAxis.y, 0, accuracy: 1e-5) + XCTAssertEqual(rolled.sideAxis.z, 0, accuracy: 1e-5) + } + + func testSideAxisIsDefinedEvenForAnEntirelyDegenerateBasis() { + let broken = TossController.CameraBasis(position: .zero, forward: .zero, right: .zero) + let side = broken.sideAxis + XCTAssertTrue(side.x.isFinite && side.y.isFinite && side.z.isFinite) + XCTAssertEqual(simd_length(side), 1, accuracy: 1e-5) + } + + func testStraightDownAimStillHasADefinedSideAxis() { + let overhead = TossController.CameraBasis( + position: [0, 1, 0], + forward: [0, -1, 0], + right: [0, 1, 0] + ) + let side = overhead.sideAxis + XCTAssertTrue(side.x.isFinite && side.y.isFinite && side.z.isFinite) + XCTAssertEqual(simd_length(side), 1, accuracy: 1e-5) + XCTAssertEqual(simd_dot(side, TossController.worldUp), 0, accuracy: 1e-5) + } + + func testDegenerateCameraBasisDoesNotProduceNaN() { + let controller = TossController() + let broken = TossController.CameraBasis(position: .zero, forward: .zero, right: .zero) + let velocity = controller.launchVelocity(for: swipe(up: 200), camera: broken) + + XCTAssertTrue(velocity.x.isFinite && velocity.y.isFinite && velocity.z.isFinite) + XCTAssertEqual(simd_length(velocity), controller.launchSpeed(for: swipe(up: 200)), accuracy: 1e-4) + } + + // MARK: - Impulse + + func testImpulseIsVelocityScaledByBallMass() { + let controller = TossController() + let gesture = swipe(up: 220, sideways: 60) + let velocity = controller.launchVelocity(for: gesture, camera: camera) + let impulse = controller.impulse(for: gesture, camera: camera) + + XCTAssertEqual(impulse.x, velocity.x * tuning.ballMass, accuracy: 1e-6) + XCTAssertEqual(impulse.y, velocity.y * tuning.ballMass, accuracy: 1e-6) + XCTAssertEqual(impulse.z, velocity.z * tuning.ballMass, accuracy: 1e-6) + } + + func testLaunchOriginSitsInFrontOfAndBelowTheCamera() { + let controller = TossController() + let origin = controller.launchOrigin(camera: camera) + + XCTAssertEqual(origin.z, camera.position.z - tuning.spawnForwardOffset, accuracy: 1e-5) + XCTAssertEqual(origin.y, camera.position.y - tuning.spawnDownOffset, accuracy: 1e-5) + } + + // MARK: - Gesture gate + + func testOnlyAnUpwardFlickCountsAsAToss() { + let controller = TossController() + XCTAssertTrue(controller.isToss(swipe(up: tuning.minimumUpwardTravel))) + XCTAssertFalse(controller.isToss(swipe(up: tuning.minimumUpwardTravel - 1))) + XCTAssertFalse(controller.isToss(swipe(sideways: 400)), "a sideways drag is aiming, not a toss") + XCTAssertFalse(controller.isToss(swipe(up: -300)), "a downward drag is not a toss") + } + + func testARejectedGestureDoesNotConsumeTheRateLimit() { + var controller = TossController() + XCTAssertEqual(controller.flick(swipe(up: 10), camera: camera, at: 0), .rejected(.notAToss)) + // The failed gesture must not have started the 0.3 s clock. + guard case .launched = controller.flick(swipe(up: 200), camera: camera, at: 0.01) else { + return XCTFail("a real toss right after a non-toss must still launch") + } + } + + // MARK: - Rate limiting (FR-006) + + func testLaunchesAreRateLimitedToTheMinimumInterval() { + var controller = TossController() + let gesture = swipe(up: 200) + + guard case .launched = controller.flick(gesture, camera: camera, at: 10) else { + return XCTFail("the first toss must launch") + } + XCTAssertEqual( + controller.flick(gesture, camera: camera, at: 10 + tuning.minimumLaunchInterval - 0.01), + .rejected(.tooSoon) + ) + guard case .launched = controller.flick( + gesture, + camera: camera, + at: 10 + tuning.minimumLaunchInterval + ) else { + return XCTFail("a toss at exactly the interval must launch") + } + XCTAssertEqual(controller.liveBallCount, 2, "a rejected toss must not create a ball") + } + + // MARK: - Live-ball cap (FR-006, SC-006) + + func testLiveBallsAreCappedAndTheCapFreesUpOnRetirement() { + var controller = TossController() + let gesture = swipe(up: 200) + var launched: [TossController.BallID] = [] + + for step in 0.. = [] + for step in 0..<20 { + let outcome = controller.flick(swipe(up: 200), camera: camera, at: Double(step)) + if case .launched(let launch) = outcome { + XCTAssertTrue(seen.insert(launch.ball).inserted, "id \(launch.ball) was reused") + controller.retire(launch.ball) + } + } + XCTAssertEqual(seen.count, 20) + } + + // MARK: - Two-tier scoring (FR-005 amended at Gate 4, SC-002) + + /// Launches a ball and hands back its id. + private func launchBall( + _ controller: inout TossController, + at now: TimeInterval = 0 + ) -> TossController.BallID { + guard case .launched(let launch) = controller.flick(swipe(up: 200), camera: camera, at: now) else { + XCTFail("expected a launch") + return 0 + } + return launch.ball + } + + func testTouchingTheCupPaysOnePointExactlyOnce() { + var controller = TossController() + let ball = launchBall(&controller) + + XCTAssertFalse(controller.hasHit(ball)) + XCTAssertEqual( + controller.registerHit(ball), + TossController.Award(ball: ball, tier: .hit, points: tuning.hitPoints) + ) + XCTAssertTrue(controller.hasHit(ball)) + XCTAssertTrue(controller.hasScored(ball)) + + // A thrown ball rattles round twelve wall segments; only the first one + // is worth anything. + XCTAssertNil(controller.registerHit(ball)) + XCTAssertNil(controller.registerHit(ball)) + } + + func testLandingInsidePaysTenPointsExactlyOnce() { + var controller = TossController() + let ball = launchBall(&controller) + + XCTAssertFalse(controller.hasMade(ball)) + XCTAssertEqual( + controller.registerMake(ball), + TossController.Award(ball: ball, tier: .make, points: tuning.makePoints) + ) + XCTAssertTrue(controller.hasMade(ball)) + XCTAssertNil(controller.registerMake(ball), "a ball cannot be made twice") + } + + /// The heart of the owner's rule: a made ball is worth ten in total, not + /// eleven. The hit is absorbed, whichever tier arrives first. + func testTheMakeAbsorbsTheHitSoAMadeBallIsWorthTenInTotal() { + var controller = TossController() + + // Hit first — the usual order: the ball clips the rim or the cup floor + // on its way to settling. + let hitFirst = launchBall(&controller, at: 0) + let hit = controller.registerHit(hitFirst) + let make = controller.registerMake(hitFirst) + XCTAssertEqual(hit?.points, tuning.hitPoints) + XCTAssertEqual(make?.points, tuning.makePoints - tuning.hitPoints) + XCTAssertEqual((hit?.points ?? 0) + (make?.points ?? 0), tuning.makePoints) + + // Make first — a clean drop through the mouth, credited before the + // contact event is processed. Same total, and the hit pays nothing. + let makeFirst = launchBall(&controller, at: 1) + let cleanMake = controller.registerMake(makeFirst) + XCTAssertEqual(cleanMake?.points, tuning.makePoints) + XCTAssertNil(controller.registerHit(makeFirst), "a made ball cannot also collect the hit") + } + + func testTheTwoTiersHaveTheOwnersValues() { + XCTAssertEqual(tuning.hitPoints, 1) + XCTAssertEqual(tuning.makePoints, 10) + } + + func testBallsScoreIndependentlyOfEachOther() { + var controller = TossController() + var ids: [TossController.BallID] = [] + for step in 0..<3 { + ids.append(launchBall(&controller, at: Double(step))) + } + XCTAssertEqual(ids.count, 3) + + XCTAssertEqual(ids.compactMap { controller.registerHit($0) }.count, 3) + XCTAssertEqual(ids.compactMap { controller.registerHit($0) }.count, 0) + + // One of them goes in; the other two keep their single point. + XCTAssertEqual(controller.registerMake(ids[1])?.points, tuning.makePoints - tuning.hitPoints) + XCTAssertFalse(controller.hasMade(ids[0])) + XCTAssertFalse(controller.hasMade(ids[2])) + XCTAssertTrue(controller.hasHit(ids[0])) + } + + func testAnUnknownOrRetiredBallCannotScoreEitherTier() { + var controller = TossController() + XCTAssertNil(controller.registerHit(999), "a ball that was never launched cannot score") + XCTAssertNil(controller.registerMake(999)) + + var controller2 = TossController() + let ball = launchBall(&controller2) + controller2.retire(ball) + XCTAssertNil(controller2.registerHit(ball), "a culled ball cannot score late") + XCTAssertNil(controller2.registerMake(ball)) + XCTAssertFalse(controller2.hasScored(ball)) + } + + /// Ids are never reused, but retiring a ball must still wipe its tiers, or + /// a stale set would grow for the life of the session. + func testRetiringABallForgetsItsTiers() { + var controller = TossController() + let ball = launchBall(&controller) + _ = controller.registerHit(ball) + _ = controller.registerMake(ball) + XCTAssertTrue(controller.hasScored(ball)) + + controller.retire(ball) + XCTAssertFalse(controller.hasHit(ball)) + XCTAssertFalse(controller.hasMade(ball)) + } + + func testRetireAllClearsEveryBallAndTheRateLimit() { + var controller = TossController() + for step in 0..<3 { + _ = controller.flick(swipe(up: 200), camera: camera, at: Double(step)) + } + XCTAssertEqual(controller.liveBallCount, 3) + + controller.retireAll() + XCTAssertEqual(controller.liveBallCount, 0) + guard case .launched = controller.flick(swipe(up: 200), camera: camera, at: 2.0) else { + return XCTFail("relocating the podium must reset the rate limit too") + } + } + + // MARK: - Culling (FR-006) + + func testAFreshMovingBallIsNotCulled() { + let controller = TossController() + XCTAssertNil(controller.cullReason(age: 0.5, restingFor: 0, heightAboveAnchor: 0.4)) + } + + func testABallAtRestIsCulledAfterTheRestWindow() { + let controller = TossController() + XCTAssertNil(controller.cullReason(age: 2, restingFor: 0.9, heightAboveAnchor: 0.1)) + XCTAssertEqual( + controller.cullReason(age: 2, restingFor: tuning.restDuration, heightAboveAnchor: 0.1), + .atRest + ) + } + + func testAnOldBallIsCulledEvenWhileMoving() { + let controller = TossController() + XCTAssertEqual( + controller.cullReason(age: tuning.maximumAge, restingFor: 0, heightAboveAnchor: 0.1), + .expired + ) + } + + func testABallBelowTheAnchorPlaneIsCulledImmediately() { + let controller = TossController() + XCTAssertNil(controller.cullReason(age: 0.1, restingFor: 0, heightAboveAnchor: -0.2)) + XCTAssertEqual( + controller.cullReason(age: 0.1, restingFor: 0, heightAboveAnchor: tuning.minimumHeight - 0.01), + .outOfBounds + ) + } + + func testRestingTimeAccumulatesWhileStillAndResetsOnMovement() { + let controller = TossController() + var resting: TimeInterval = 0 + for _ in 0..<3 { + resting = controller.restingDuration(previous: resting, speed: 0.01, delta: 0.2) + } + XCTAssertEqual(resting, 0.6, accuracy: 1e-6) + + resting = controller.restingDuration(previous: resting, speed: 1.5, delta: 0.2) + XCTAssertEqual(resting, 0, "a ball that moves again is not at rest") + } + + // MARK: - Power range (Phase 4, task 4.0a — Gate 3 rows 3.1/3.7) + + /// The complaint was reach: the player had to walk the phone closer. The + /// fix is a materially higher ceiling, not a nudge. + func testTheHardestFlickIsMuchStrongerThanPhase3s() { + let controller = TossController() + XCTAssertGreaterThanOrEqual( + controller.launchSpeed(for: swipe(up: 600, duration: 0.08)), + 6.5, + "a hard flick must reach the cup from ~2 m without the player moving" + ) + XCTAssertGreaterThan(tuning.maxLaunchSpeed, 4.5, "Phase 3's ceiling was the problem") + } + + /// …and the price of that ceiling must not be paid by ordinary throws: + /// a middling flick still has to land in the 0.5–1.0 m band, which needs + /// roughly 2.4–3.5 m/s. + func testMidStrengthFlicksStayInTheSweetSpot() { + let controller = TossController() + let mid = controller.launchSpeed(for: swipe(up: 180, duration: 0.15)) // 1200 pt/s + XCTAssertGreaterThan(mid, 2.4) + XCTAssertLessThan(mid, 3.5) + + let easy = controller.launchSpeed(for: swipe(up: 150, duration: 0.15)) // 1000 pt/s + XCTAssertGreaterThan(easy, 2.0) + XCTAssertLessThan(easy, 3.0) + } + + /// The shaped curve must still be a curve, not a staircase: speed rises + /// with flick speed everywhere between the clamps. + func testTheShapedCurveIsMonotonicAcrossTheWholeRange() { + let controller = TossController() + var previous: Float = 0 + for flick in stride(from: Float(300), through: 2600, by: 50) { + let speed = controller.launchSpeed(for: swipe(up: flick * 0.1, duration: 0.1)) + XCTAssertGreaterThanOrEqual(speed, previous, "speed dipped at \(flick) pt/s") + XCTAssertGreaterThanOrEqual(speed, tuning.minLaunchSpeed) + XCTAssertLessThanOrEqual(speed, tuning.maxLaunchSpeed) + previous = speed + } + XCTAssertEqual(previous, tuning.maxLaunchSpeed, accuracy: 1e-5) + } + + /// `powerCurve` is the knob that re-spread the range; at 1 it must collapse + /// back to Phase 3's straight line, so the change is provably a re-shaping + /// and not a different formula. + func testAPowerCurveOfOneIsTheOldLinearMapping() { + var linear = TossController.Tuning() + linear.powerCurve = 1 + let controller = TossController(tuning: linear) + + let flick: Float = 1200 + let t = (flick - linear.slowFlick) / (linear.fastFlick - linear.slowFlick) + let expected = linear.minLaunchSpeed + t * (linear.maxLaunchSpeed - linear.minLaunchSpeed) + XCTAssertEqual( + controller.launchSpeed(for: swipe(up: flick * 0.1, duration: 0.1)), + expected, + accuracy: 1e-4 + ) + } + + func testTheCurveShapeSagsBelowTheStraightLine() { + var linear = TossController.Tuning() + linear.powerCurve = 1 + let gesture = swipe(up: 120, duration: 0.1) // 1200 pt/s + + let shaped = TossController().launchSpeed(for: gesture) + let straight = TossController(tuning: linear).launchSpeed(for: gesture) + XCTAssertLessThan(shaped, straight, "the curve exists to hold the middle down") + } + + // MARK: - Rim rescue (Phase 4, task 4.0b — Gate 3 row 3.2) + + private var ballRadius: Float { tuning.ballRadius } + /// The real geometry the AR side measures against. + @MainActor + private var rimOuterRadius: Float { PodiumBuilder.Metrics.cupRimOuterRadius } + + /// A ball balanced on the rim sits a full radius above it. + @MainActor + func testABallBalancedOnTheRimIsDetected() { + let controller = TossController() + let perched = TossController.CupPlacement( + heightAboveRim: ballRadius, + radialDistance: PodiumBuilder.Metrics.cupRimRingRadius + ) + XCTAssertTrue( + controller.isPerchedOnRim(perched, ballRadius: ballRadius, cupOuterRadius: rimOuterRadius) + ) + } + + /// A ball that actually went in sits *below* the rim, and must be left + /// alone — it has scored and the ordinary rest-cull should remove it. + @MainActor + func testABallRestingInsideTheCupIsNotTreatedAsPerched() { + let controller = TossController() + // Centre = cup floor thickness + radius, measured against the rim. + let inside = TossController.CupPlacement( + heightAboveRim: PodiumBuilder.Metrics.cupFloorThickness + ballRadius + - PodiumBuilder.Metrics.cupRimHeight, + radialDistance: 0 + ) + XCTAssertLessThan(inside.heightAboveRim, 0, "a ball in the cup is below the rim") + XCTAssertFalse( + controller.isPerchedOnRim(inside, ballRadius: ballRadius, cupOuterRadius: rimOuterRadius) + ) + } + + @MainActor + func testABallRestingElsewhereInTheSceneIsNotTreatedAsPerched() { + let controller = TossController() + // Right height, but way off to the side — on a step, or on the table. + let besideTheCup = TossController.CupPlacement( + heightAboveRim: ballRadius, + radialDistance: rimOuterRadius + ballRadius + 0.01 + ) + XCTAssertFalse( + controller.isPerchedOnRim(besideTheCup, ballRadius: ballRadius, cupOuterRadius: rimOuterRadius) + ) + + // Right place, but well below the mouth — under the cup, on the step. + let belowTheCup = TossController.CupPlacement(heightAboveRim: -0.12, radialDistance: 0.01) + XCTAssertFalse( + controller.isPerchedOnRim(belowTheCup, ballRadius: ballRadius, cupOuterRadius: rimOuterRadius) + ) + } + + /// The two cases have to be separated with margin on both sides, or a ball + /// in the cup gets shoved out of it. + @MainActor + func testTheRimThresholdSitsBetweenTheTwoRestingHeights() { + let controller = TossController() + let insideHeight = PodiumBuilder.Metrics.cupFloorThickness + ballRadius + - PodiumBuilder.Metrics.cupRimHeight + let perchedHeight = ballRadius + let threshold = -ballRadius * tuning.rimGraceFraction + + XCTAssertLessThan(insideHeight, threshold - 0.005, "too little margin for a ball in the cup") + XCTAssertGreaterThan(perchedHeight, threshold + 0.005, "too little margin for a ball on the rim") + _ = controller + } + + func testTheNudgeIsAHorizontalShoveWithADownwardBias() { + let controller = TossController() + for azimuth in stride(from: Float(0), to: 2 * .pi, by: 0.4) { + let velocity = controller.rimNudgeVelocity(azimuth: azimuth) + let horizontal = simd_length(SIMD2(velocity.x, velocity.z)) + XCTAssertEqual(horizontal, tuning.rimNudgeSpeed, accuracy: 1e-5) + XCTAssertLessThan(velocity.y, 0, "the shove must commit the ball to falling") + XCTAssertEqual( + velocity.y, + -tuning.rimNudgeSpeed * tuning.rimNudgeDownwardBias, + accuracy: 1e-6 + ) + } + } + + /// Opposite azimuths must mirror, so a random direction is as likely to + /// drop the ball into the cup as out of it — which is what Gate 3 asked for. + func testOppositeNudgesMirrorEachOther() { + let controller = TossController() + let right = controller.rimNudgeVelocity(azimuth: 0) + let left = controller.rimNudgeVelocity(azimuth: .pi) + XCTAssertEqual(right.x, -left.x, accuracy: 1e-5) + XCTAssertEqual(right.y, left.y, accuracy: 1e-6) + } + + func testTheNudgeImpulseIsTheNudgeVelocityTimesMass() { + let controller = TossController() + let velocity = controller.rimNudgeVelocity(azimuth: 1.1) + let impulse = controller.rimNudgeImpulse(azimuth: 1.1) + XCTAssertEqual(impulse.x, velocity.x * tuning.ballMass, accuracy: 1e-8) + XCTAssertEqual(impulse.y, velocity.y * tuning.ballMass, accuracy: 1e-8) + XCTAssertEqual(impulse.z, velocity.z * tuning.ballMass, accuracy: 1e-8) + } + + /// The rescue is bounded: a ball cannot be nudged forever. + func testNudgesAreCapped() { + let controller = TossController() + for count in 0.. TossController.CupPlacement { + TossController.CupPlacement( + heightAboveRim: -depthBelowRim, + radialDistance: radial, + heightAboveCupFloor: aboveFloor, + interiorRadius: interior + ) + } + + func testABallSittingInTheCupIsInside() { + let controller = TossController() + XCTAssertTrue(controller.isInsideCup(insidePlacement(), ballRadius: ballRadius)) + } + + /// The defect, as a unit test: a ball touching the cup's outer wall has its + /// centre a full radius *beyond* the wall — ~9 cm from the axis where the + /// rule allows ~2 — so it can never be inside, at any height, however hard + /// it is thrown at the front of the cup. + func testABallAgainstTheOutsideOfTheWallIsNeverInside() { + let controller = TossController() + for depth in stride(from: Float(0.001), through: 0.06, by: 0.005) { + let placement = insidePlacement( + depthBelowRim: depth, + radial: 0.051 + 0.006 + ballRadius, + aboveFloor: 0.065 - depth, + interior: 0.051 + ) + XCTAssertFalse( + controller.isInsideCup(placement, ballRadius: ballRadius), + "outer-wall contact \(depth) m below the rim read as inside" + ) + } + } + + /// The lower front of the cup specifically — the position the owner scored + /// from at Gate 4. Low on the wall, outside it, and the interior is + /// narrowest down there. + func testTheLowFrontOfTheCupIsNeverInside() { + let controller = TossController() + let lowFront = insidePlacement( + depthBelowRim: 0.055, + radial: 0.041 + 0.006 + ballRadius, + aboveFloor: 0.004, + interior: 0.041 + ) + XCTAssertFalse(controller.isInsideCup(lowFront, ballRadius: ballRadius)) + } + + func testABallBelowTheCupIsNotInside() { + let controller = TossController() + // On the stem or the step: right under the axis, but below the floor. + let underneath = insidePlacement(radial: 0, aboveFloor: -0.02) + XCTAssertFalse(controller.isInsideCup(underneath, ballRadius: ballRadius)) + } + + func testABallAtOrAboveTheRimIsNotInside() { + let controller = TossController() + let atTheRim = insidePlacement(depthBelowRim: 0, aboveFloor: 0.065, interior: 0.054) + XCTAssertFalse(controller.isInsideCup(atTheRim, ballRadius: ballRadius)) + + let perched = insidePlacement(depthBelowRim: -ballRadius, aboveFloor: 0.10, interior: 0.054) + XCTAssertFalse(controller.isInsideCup(perched, ballRadius: ballRadius)) + } + + /// Only the *whole* ball counts as in. A ball whose centre is inside but + /// whose body still sticks out through the wall line is on its way in or + /// out, not landed. + func testTheWholeBallHasToFitWithinTheWall() { + let controller = TossController() + let interior: Float = 0.051 + let justFits = insidePlacement(radial: interior - ballRadius, interior: interior) + XCTAssertTrue(controller.isInsideCup(justFits, ballRadius: ballRadius)) + + let stickingOut = insidePlacement( + radial: interior - ballRadius + tuning.insideRadialTolerance + 0.002, + interior: interior + ) + XCTAssertFalse(controller.isInsideCup(stickingOut, ballRadius: ballRadius)) + } + + /// Inside and perched must be mutually exclusive, or the rim rescue would + /// shove a ball that has just scored. + func testInsideAndPerchedAreMutuallyExclusive() { + let controller = TossController() + for depth in stride(from: Float(-0.05), through: 0.06, by: 0.002) { + let placement = insidePlacement(depthBelowRim: depth, radial: 0.005) + let inside = controller.isInsideCup(placement, ballRadius: ballRadius) + let perched = controller.isPerchedOnRim( + placement, + ballRadius: ballRadius, + cupOuterRadius: 0.06 + ) + XCTAssertFalse(inside && perched, "a ball at \(depth) is both inside and perched") + } + } + + // MARK: - The dwell that turns "inside" into "landed" + + func testContainmentAccumulatesWhileInsideAndResetsOnLeaving() { + let controller = TossController() + var contained: TimeInterval = 0 + for _ in 0..<3 { + contained = controller.containedDuration(previous: contained, isInside: true, delta: 1 / 60) + } + XCTAssertEqual(contained, 3.0 / 60, accuracy: 1e-9) + + contained = controller.containedDuration(previous: contained, isInside: false, delta: 1 / 60) + XCTAssertEqual(contained, 0, "a ball that leaves the cup starts again") + } + + /// A ball crossing the cup at speed cannot dwell: the region where it + /// counts as inside is a few centimetres across, so it is gone within a + /// frame or two — which is exactly the tunnelling case the defect fix has + /// to reject. + func testAFlyThroughNeverSettlesButARestingBallDoes() throws { + let controller = TossController() + var contained: TimeInterval = 0 + // Two frames inside, then out — a ball punched through the wall. + for isInside in [true, true, false] { + contained = controller.containedDuration( + previous: contained, + isInside: isInside, + delta: 1 / 60 + ) + XCTAssertFalse(controller.hasSettledInside(containedFor: contained)) + } + + // A ball that stays put crosses the threshold within a frame of the + // nominal dwell and stays across it. (Which side of the exact boundary + // frame 6 lands on is at the mercy of accumulating 1/60 in binary; the + // behaviour either side of it is not.) + var settledAt: Int? + for frame in 1...30 { + contained = controller.containedDuration(previous: contained, isInside: true, delta: 1 / 60) + let settled = controller.hasSettledInside(containedFor: contained) + if settled, settledAt == nil { settledAt = frame } + + let elapsed = Double(frame) / 60 + if elapsed < tuning.insideDwell - 1.0 / 60 { + XCTAssertFalse(settled, "frame \(frame) settled too early") + } + if elapsed > tuning.insideDwell + 1.0 / 60 { + XCTAssertTrue(settled, "frame \(frame) has not settled yet") + } + } + let crossing = try XCTUnwrap(settledAt, "a ball resting in the cup never settled") + XCTAssertEqual(Double(crossing) / 60, tuning.insideDwell, accuracy: 1.0 / 60) + } + + func testTheDwellIsShortEnoughToFeelInstantAndLongEnoughToFilter() { + XCTAssertGreaterThanOrEqual(tuning.insideDwell, 3.0 / 60, "one stray frame must not score") + XCTAssertLessThanOrEqual(tuning.insideDwell, 0.25, "the player would notice the delay") + } + + // MARK: - Touch-anchored spawn (Phase 5, task 5.0c — FR-004 amended) + + /// The camera's screen basis for these tests: aiming along −Z, +X to the + /// right of the screen and +Y up it. + private func ray(right: Float, up: Float) -> TossController.TouchRay { + TossController.TouchRay( + origin: camera.position, + direction: simd_normalize(SIMD3(right, up, -1)) + ) + } + + private func spawnOffset(_ touch: TossController.TouchRay?) -> SIMD3 { + TossController().launchOrigin(camera: camera, touch: touch) - camera.position + } + + func testATouchInTheMiddleSpawnsOnTheAimAxis() { + let offset = spawnOffset(ray(right: 0, up: 0)) + XCTAssertEqual(offset.x, 0, accuracy: 1e-5) + XCTAssertEqual(offset.y, 0, accuracy: 1e-5) + XCTAssertEqual(offset.z, -tuning.spawnForwardOffset, accuracy: 1e-5) + } + + /// Each corner has to put the ball on its own side of the aim, and all four + /// at the same depth — the finger moves the spawn sideways, never nearer. + func testEachCornerSpawnsOnItsOwnSideAtTheSameDepth() { + let corners: [(name: String, right: Float, up: Float)] = [ + ("top-left", -0.5, 0.8), + ("top-right", 0.5, 0.8), + ("bottom-left", -0.5, -0.8), + ("bottom-right", 0.5, -0.8) + ] + for corner in corners { + let offset = spawnOffset(ray(right: corner.right, up: corner.up)) + XCTAssertEqual( + offset.z, + -tuning.spawnForwardOffset, + accuracy: 1e-5, + "\(corner.name) changed the spawn depth" + ) + XCTAssertEqual( + offset.x.sign, + corner.right.sign, + "\(corner.name) spawned on the wrong side" + ) + XCTAssertEqual(offset.y.sign, corner.up.sign, "\(corner.name) spawned at the wrong height") + XCTAssertGreaterThan( + simd_length(SIMD2(offset.x, offset.y)), + 0.05, + "\(corner.name) barely moved — the spawn does not follow the finger" + ) + } + // Opposite corners mirror each other exactly. + let topLeft = spawnOffset(ray(right: -0.5, up: 0.8)) + let bottomRight = spawnOffset(ray(right: 0.5, up: -0.8)) + XCTAssertEqual(topLeft.x, -bottomRight.x, accuracy: 1e-5) + XCTAssertEqual(topLeft.y, -bottomRight.y, accuracy: 1e-5) + } + + func testTheSidewaysOffsetIsClamped() { + let offset = spawnOffset(ray(right: 3, up: 0)) + XCTAssertEqual( + simd_length(SIMD2(offset.x, offset.y)), + tuning.maxSpawnLateral, + accuracy: 1e-5 + ) + XCTAssertEqual(offset.z, -tuning.spawnForwardOffset, accuracy: 1e-5) + } + + /// A ray that points behind the player — a bad projection, or a touch + /// outside the frustum — must still spawn the ball in front of them. + func testARayPointingBackwardsStillSpawnsInFrontOfTheCamera() { + let backwards = TossController.TouchRay(origin: camera.position, direction: [0, 0, 1]) + let offset = spawnOffset(backwards) + XCTAssertLessThan(offset.z, 0, "the ball spawned behind the player") + XCTAssertGreaterThanOrEqual(-offset.z, tuning.minSpawnDepth - 1e-5) + } + + func testEverySpawnStaysInsideItsDepthAndOffsetBounds() { + for right in stride(from: Float(-6), through: 6, by: 0.5) { + for up in stride(from: Float(-6), through: 6, by: 0.5) { + let offset = spawnOffset(ray(right: right, up: up)) + let depth = -offset.z + XCTAssertGreaterThanOrEqual(depth, tuning.minSpawnDepth - 1e-4, "(\(right), \(up))") + XCTAssertLessThanOrEqual(depth, tuning.maxSpawnDepth + 1e-4, "(\(right), \(up))") + XCTAssertLessThanOrEqual( + simd_length(SIMD2(offset.x, offset.y)), + tuning.maxSpawnLateral + 1e-4, + "(\(right), \(up))" + ) + XCTAssertTrue(offset.x.isFinite && offset.y.isFinite && offset.z.isFinite) + } + } + } + + func testADegenerateRayFallsBackToTheAimAxis() { + let broken = TossController.TouchRay(origin: camera.position, direction: .zero) + let offset = spawnOffset(broken) + XCTAssertEqual(offset.x, 0, accuracy: 1e-5) + XCTAssertEqual(offset.y, 0, accuracy: 1e-5) + XCTAssertEqual(offset.z, -tuning.spawnForwardOffset, accuracy: 1e-5) + } + + /// No touch at all keeps the pre-Gate-4 spawn, so nothing regresses if the + /// AR view cannot project the point. + func testNoTouchKeepsTheFixedSpawn() { + let controller = TossController() + XCTAssertEqual( + controller.launchOrigin(camera: camera, touch: nil), + controller.launchOrigin(camera: camera) + ) + } + + /// …and a real flick actually carries the touch through to the launch. + func testAFlickSpawnsAtTheTouchPoint() { + var controller = TossController() + let touch = ray(right: 0.4, up: -0.7) + guard case .launched(let launch) = controller.flick( + swipe(up: 200), + camera: camera, + at: 0, + touch: touch + ) else { + return XCTFail("expected a launch") + } + XCTAssertEqual(launch.origin, controller.launchOrigin(camera: camera, touch: touch)) + XCTAssertNotEqual(launch.origin, controller.launchOrigin(camera: camera)) + // The aim and the power are untouched by where the finger started. + XCTAssertEqual( + launch.velocity, + controller.launchVelocity(for: swipe(up: 200), camera: camera) + ) + } + + // MARK: - Tuning is the single knob for Gate 3 + + func testTuningOverridesFlowThroughToTheLaunch() { + var tweaked = TossController.Tuning() + tweaked.minLaunchSpeed = 5 + tweaked.maxLaunchSpeed = 5 + tweaked.ballMass = 1 + var controller = TossController(tuning: tweaked) + + guard case .launched(let launch) = controller.flick(swipe(up: 200), camera: camera, at: 0) else { + return XCTFail("expected a launch") + } + XCTAssertEqual(simd_length(launch.velocity), 5, accuracy: 1e-4) + XCTAssertEqual(simd_length(launch.impulse), 5, accuracy: 1e-4) + } +} diff --git a/ios/project.yml b/ios/project.yml index fd5f357..b545380 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -28,3 +28,34 @@ targets: ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor CODE_SIGN_STYLE: Automatic + + # Unit tests for the pure, off-device logic: the AR mini-game's rulebook + # (toss physics, rounds, best score, podium/floor-map geometry) and the + # backend locator / map-pin decoding. Hosted by the IPP app, so the suites + # read the app's real Info.plist. No test needs a running backend. + # Run: xcodebuild test -scheme IPP -destination 'platform=iOS Simulator,name=iPhone 17 Pro' + IPPTests: + type: bundle.unit-test + platform: iOS + sources: + - path: IPPTests + dependencies: + - target: IPP + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.nonturing.ipp.tests + GENERATE_INFOPLIST_FILE: YES + CODE_SIGN_STYLE: Automatic + +schemes: + IPP: + build: + targets: + IPP: all + IPPTests: [test] + run: + config: Debug + test: + config: Debug + targets: + - IPPTests From 55fbee25c548b8a0adef3f513e1d3773e0e8fda4 Mon Sep 17 00:00:00 2001 From: Eddie Date: Mon, 24 Aug 2026 22:13:24 -0400 Subject: [PATCH 10/10] docs: bring README intro, AR rationale, and demo section up to date with camera AR - intro + pairing paragraph now name both AR modes (location-based + ARKit/RealityKit) - 'Why GPS + AR' gains the third, camera-based surface consuming the same GPS data - demo section and game section link the published camera-AR video --- README.md | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index acd5512..49ab5c9 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,24 @@ IPP is a location-based, on-chain-verifiable clinical-records app for Chilean doctors and medical staff (Spanish UI). It is built as a **prototype / -demonstrator** of a reusable template - GPS + location-based AR capture on iOS, -backed by a Bun service on Neon Postgres, with each record's hash anchored to +demonstrator** of a reusable template - GPS + AR on iOS (location-based AR at +capture, plus a camera-based ARKit/RealityKit scene), backed by a Bun service on +Neon Postgres, with each record's hash anchored to **Cardano** (through the **EffectStream** packages) so the data can be cryptographically verified later. The app pairs **GPS** (where each patient lives) with **location-based AR** -(live population context augmenting what the clinician sees as they work) and a +(live population context augmenting what the clinician sees as they work), +**camera-based AR** (an ARKit/RealityKit scene that anchors a podium to the real +world and renders the geolocated records as a living data map on the floor - +see [Tiro al Trofeo](#tiro-al-trofeo---camera-based-ar-mini-game)) and a **gamified** contribution layer that makes the dataset grow. ## What this is - **An iOS app** (SwiftUI) for capturing a ~70-question women's-health intake - form across four sections. + form across four sections, plus a camera-based AR mini-game on the + leaderboard (ARKit/RealityKit). - **A web dashboard** (Vite + React + Leaflet) for population maps, filters, feedback, and on-chain verification - also embedded inside the iOS app. - **A Bun + Fastify backend** on Neon Postgres, with a swappable chain adapter. @@ -27,7 +32,7 @@ The app pairs **GPS** (where each patient lives) with **location-based AR** GPS is the backbone: every patient has an address that geocodes to a latitude/longitude. That location unlocks **augmented reality anchored to place** - augmenting what the clinician sees about the physical world in front -of them, in two places: +of them, in three places: 1. **At capture.** As you enter a value, the field shows the population context for it - the **local** (the patient's own locality), **país** (country), and @@ -36,11 +41,17 @@ of them, in two places: 2. **On the map.** Doctors draw **notes and named areas** over the filtered population layer, turning patterns into plans - e.g. *"many patients in this zone need X, assign a specialist and schedule exams here."* - -> This is **location-based AR**: the augmentation is anchored to physical place -> through GPS rather than to a camera feed. The reality being augmented is the -> clinician's view of the patient and population in front of them, keyed to -> where the patient actually lives. +3. **Through the camera.** The + [Tiro al Trofeo](#tiro-al-trofeo---camera-based-ar-mini-game) scene uses + ARKit/RealityKit world tracking to anchor a virtual podium onto the real + surface in front of the user and projects the anonymized record locations as + a living data map on the actual floor around it. + +> The first two are **location-based AR** - the augmentation is anchored to +> physical place through GPS rather than to a camera feed, and the reality being +> augmented is the clinician's view of the patient and population in front of +> them, keyed to where the patient actually lives. The third is **camera-based +> AR** consuming the same GPS data through the phone's camera. **Why we prioritized it:** women's-health and pelvic-floor risk cluster geographically. Location-aware context at the point of capture (and on the map) @@ -159,8 +170,8 @@ already draws in gold/silver/bronze ([ios/IPP/Game/](ios/IPP/Game/), opened from [LeaderboardView.swift](ios/IPP/Views/LeaderboardView.swift)). -> **Screenshot / GIF placeholder** - a short capture of a round (place the -> podium → flick → make → summary) will be added here. +> **Video:** - a full round on +> device: place the podium → flick → make → summary. - **Place it.** Scan a desk or the floor, tap a detected horizontal plane, and a procedural podium appears - three steps in the leaderboard's exact medal @@ -431,7 +442,11 @@ deterministically from the account seed, sending `X-IPP-PubKey` / ## Demo - video & screenshots -> A screen recording of the end-to-end flow (capture → location-based AR stats → save → +> **Camera-AR demo (published):** - +> a single on-device take: leaderboard → Jugar → plane detection → podium +> anchored on the real table → floor data map → physics toss gameplay. +> +> A screen recording of the end-to-end clinical flow (capture → location-based AR stats → save → > on-chain verify → population map → gamified leaderboard) and screenshots will > be added here / in the [EffectStream blog post](https://effectstream.github.io/docs/blog/ipp-clinical-records-cardano).