Tiro al Trofeo — camera-based AR podium mini-game (+ LAN backend probe, floor data map) - #14
Open
acedward wants to merge 10 commits into
Open
Tiro al Trofeo — camera-based AR podium mini-game (+ LAN backend probe, floor data map)#14acedward wants to merge 10 commits into
acedward wants to merge 10 commits into
Conversation
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.
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.
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.
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.
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.
…tandings 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.
…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.
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.
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.
…ith 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
Tiro al Trofeo, a camera-based AR mini-game reachable from the leaderboard's new "Jugar" row — IPP's first ARKit/RealityKit surface. It is a game and nothing else: it reads no clinical record, awards no leaderboard points, and writes nothing but one integer.
MeshDescriptors, becausegenerateCylinderis iOS 18+ and the app targets 17.0.ARView.ray(through:)), aimed where the phone points, with power from flick speed. Fully simulated: gravity, bounce, continuous collision detection, rate limiting and a live-ball cap.UserDefaults(the game's only persistence). The round clock pauses on tracking loss and on backgrounding. Free practice before "Comenzar" throws balls but can never touch a round's score or clock.The backend base URL is now resolved at launch instead of read straight from
Info.plist.localhostis the phone itself, so a device build could never useBackendURLas-is. At launch the app asks each host in the newInfo.plistarrayBackendCandidatesfor/health(1.5 s each, in order) and points every client — login, patients, field stats, schema, leaderboard, map pins — at the first that answers, falling back toBackendURLwhen none does (ios/IPP/Services/BackendLocator.swift). In the Simulator the candidate list is skipped entirely andBackendURLis used exactly as before. Pointing a phone at your own Mac is a plist edit, not a source change; an empty or missing array means "only ever useBackendURL".Requests now await that resolution rather than racing it (
AppEnvironment.backendReady()), so nothing can leave with a half-resolved URL — previously a tap into Ranking within the probe window could send one doomed request tolocalhostand self-heal on refresh. The cost, stated plainly: when nothing is reachable, the first request pays the probe once (bounded ≤ 5 s, asserted by a test that computes the bound from the plist's own candidate count) instead of failing instantly; every later request is unaffected.This is not a breaking change. It is additive: no API changes, no schema changes, no behaviour change in the Simulator, and the fallback is the previous behaviour.
Testing
270 unit tests, 0 failures, in a new permanent
IPPTeststarget (ios/IPPTests/, wired into theIPPscheme's test action so the command below works on a fresh checkout):Every suite is offline and deterministic — no test needs a running backend. Coverage is the logic that does not need a camera: the toss rulebook (speed curve, aim basis, spawn clamps, culling, the rim rescue, both scoring tiers), the round state machine, best-score persistence, podium/cup/breathing geometry against the real built entities, the floor-map projection and animation, the backend locator's ordering and plist parsing, and map-pin decoding. The launch-race fix is tested by blocking the probe on an actor gate and asserting the negative — a request that has still not completed after 20 scheduler hops — rather than hoping a race comes out right.
ARKit does not run in the Simulator, so the AR behaviour was verified by the owner on a physical iPhone through a human device gate at the end of every phase (Gates 2, 3, 4, 5, 5B and 5C — placement stability, physics, scoring reliability including deliberate exterior-hit attempts, round/timer/best-score behaviour, tracking-loss and backgrounding, the LAN backend over Wi-Fi, and the live-vs-offline floor map). Each gate's findings were carried forward as tasks in the next phase. The final end-to-end regression pass was waived by the owner in favour of opening this PR.
Scope and safety
ios/only, plusREADME.md. Nobackend/,web/orcardano/changes.project.ymlstill carries onlyCODE_SIGN_STYLE: Automatic— noDEVELOPMENT_TEAM, no team ID anywhere in the tree. Build for a device with your own team (⌘Rafter picking it in Signing & Capabilities, orxcodebuild DEVELOPMENT_TEAM=<yours> -allowProvisioningUpdates)..env, no credentials. The only new configuration values are the two private-range LAN hosts inBackendCandidates, which exist so a demo phone can find a demo Mac.ios/IPP/Game/contains noURLSession, noAppEnvironment, no URL literal — the floor map's anonymized pins are fetched by the app layer (GET /api/v1/map-pins, read-only, public) and handed to the game as plain coordinates, with a seeded synthetic array substituted when the backend is unreachable. The game cannot tell the difference and stays fully playable offline; the caption reads "Datos en vivo · N ubicaciones" or "Datos de ejemplo · N ubicaciones" accordingly.NSCameraUsageDescription) and local networking (NSLocalNetworkUsageDescription), both in Spanish, both explained in the plist strings.Docs
README.mdgains a section for the mini-game (with a screenshot/GIF placeholder), the launch-time host probe, thexcodebuild testline, and a corrected "intentionally not done" bullet — the old "there is no ARKit/RealityKit overlay" line is no longer true and now reads that the clinical AR remains GPS-anchored while the mini-game is the one camera-based piece.Follow-up (not in this PR)
Standing up the backend against a local Postgres surfaced three pre-existing defects, unrelated to this feature. They are now documented in
README.md("Running against a local Postgres instead of Neon") but deliberately not fixed here, since one of them touches the production Neon path and none of them belongs in an iOS feature PR:backend/src/db.tshardcodesssl: "require"as a postgres.js client option, which overrides anysslmodeinDATABASE_URLand never falls back to plaintext — so a stockdocker run postgres:16fails the handshake.backend/scripts/seed-cities.tsPOSTs/api/v1/patientsunsigned and gets401 missing auth headerson every row; it predates the signed-request auth.backend/scripts/seed-year.tsstill INSERTs the legacy plaintextpasscodecolumn, whichinitSchemano longer creates.Suggested follow-up issue: "Local-dev path: backend cannot use a plain local Postgres, and both seed scripts are broken."