diff --git a/.maestro/06-tilt-3d-drill.yaml b/.maestro/06-tilt-3d-drill.yaml index f63939b..8446eff 100644 --- a/.maestro/06-tilt-3d-drill.yaml +++ b/.maestro/06-tilt-3d-drill.yaml @@ -1,5 +1,6 @@ -# CUF: load a vault drill, tilt into the 3D world view, start playback and -# pan-rotate the camera while the rally is flying. +# CUF: dress both players in mascots, load a vault drill, tilt into the 3D +# world view — mascots render as full figures (far side faces the camera, +# near side shows its back) — then start playback and pan-rotate mid-rally. appId: com.haritabhgupta.badmintoncourtsimulator --- - launchApp: @@ -7,6 +8,15 @@ appId: com.haritabhgupta.badmintoncourtsimulator # fresh installs open on the first-run tour - assertVisible: "Skip tour" - tapOn: "Skip tour" +# mascots for both singles players: Fox far court (P1), Koala near court (P3) +- tapOn: "Customize" +- assertVisible: "INCLUDED" +- tapOn: "Fox" +- assertVisible: "Fox · Player 1" +- tapOn: "P3" +- tapOn: "Koala" +- assertVisible: "Koala · Player 3" +- tapOn: "Close settings" - tapOn: "Drills" - tapOn: "Drill Vault" - assertVisible: "Four-Corner Shadow" @@ -16,6 +26,15 @@ appId: com.haritabhgupta.badmintoncourtsimulator - tapOn: "3D" # the hint chip only renders once the court has tilted in - assertVisible: "drag to rotate and tilt" +# slow yaw sweep on the standing figures: Koala's back near, Fox facing far +- swipe: + start: 30%, 50% + end: 70%, 50% + duration: 1200 +- swipe: + start: 70%, 50% + end: 30%, 50% + duration: 1200 - tapOn: "Play" # 3D playback advances through the steps on its own - extendedWaitUntil: diff --git a/components/BadmintonCourt.tsx b/components/BadmintonCourt.tsx index 5763227..db62b3a 100644 --- a/components/BadmintonCourt.tsx +++ b/components/BadmintonCourt.tsx @@ -331,6 +331,8 @@ export default function BadmintonCourt() { ...courtPointFromScreen(pos.x + c.size / 2, pos.y + c.size / 2, linesRect), color: c.color, size: c.size, + look: effectiveLooks[id], + leftHanded: c.isLeftHanded, }; }; const shuttleHalf = customizations.Shuttle.size / 2; @@ -345,7 +347,7 @@ export default function BadmintonCourt() { linesRect ), })); - }, [customizations, getStepsSnapshot, isDoubles, linesRect]); + }, [customizations, effectiveLooks, getStepsSnapshot, isDoubles, linesRect]); const advance3D = useCallback(() => { if (canRedo) redo(); diff --git a/components/Court3DView.tsx b/components/Court3DView.tsx index 394dc7b..5b2faf0 100644 --- a/components/Court3DView.tsx +++ b/components/Court3DView.tsx @@ -14,7 +14,8 @@ import Svg, { Stop, } from 'react-native-svg'; import { LINE_UNITS, LinesRect } from './CourtSvg'; -import { CourtTheme, courtThemeById } from '../constants/customization'; +import { MascotView, MASCOT_ASPECT } from './mascots'; +import { CourtTheme, courtThemeById, isMascot, LookId, MascotId } from '../constants/customization'; import { palette, radii, sora } from '../constants/theme'; import { arcPath, @@ -41,6 +42,9 @@ import { export interface Pin3D extends CourtPoint { color: string; size: number; + /** Player look; mascot looks render as full figures instead of pins. */ + look?: LookId; + leftHanded?: boolean; } export interface Step3D { @@ -211,21 +215,87 @@ export function Court3DView({ const sw = shuttleSize * clamp(sp3[2], 0.8, 1.3); const squash = 1 - 0.58 * b; // ground ellipses foreshorten with the tilt - // Player pins glide between the previous and current step with the flight - const pins = cur.players.map((p, i) => { + // Players glide between the previous and current step with the flight. + // Mascot looks become full figures (billboarded art); other looks keep pins. + const players = cur.players.map((p, i) => { const from = prev?.players[i] ?? p; const t = prev ? tc : 1; const x = from.x + (p.x - from.x) * t; const z = from.z + (p.z - from.z) * t; const f = project(x, z, 0); - const w = p.size * clamp(f[2], 0.8, 1.3); - const stemH = Math.max(0, 13 * b * f[2]); - // Flat: disc centered on its court spot; tilted: disc stands on a stem. - const discCy = f[1] - stemH - w / 2 + (w / 2 + 3) * (1 - b); - const glyph = w * 0.44; - return { key: i, color: p.color, fx: f[0], fy: f[1], w, stemH, discCy, glyph }; + return { p, i, z, f }; }); + const pins = players + .filter(({ p }) => !(p.look && isMascot(p.look))) + .map(({ p, i, f }) => { + const w = p.size * clamp(f[2], 0.8, 1.3); + const stemH = Math.max(0, 13 * b * f[2]); + // Flat: disc centered on its court spot; tilted: disc stands on a stem. + const discCy = f[1] - stemH - w / 2 + (w / 2 + 3) * (1 - b); + const glyph = w * 0.44; + return { key: i, color: p.color, fx: f[0], fy: f[1], w, stemH, discCy, glyph }; + }); + + // The camera looks from the high-z end, so near-court figures (z past the + // net) show their backs — same orientation rule the flat court uses. + const sprites = players + .filter(({ p }) => p.look && isMascot(p.look)) + .map(({ p, i, z, f }) => { + const facingAway = z > NET_Y; + const w = p.size * clamp(f[2], 0.8, 1.3) * (1 + 0.45 * b); + const h = w * MASCOT_ASPECT; + // b=0: centered on its court spot (matches the flat court exactly); + // b=1: feet planted on the contact shadow. + const top = f[1] - h / 2 - (h / 2 - w * 0.08) * b; + return { + key: i, + mascot: p.look as MascotId, + band: p.color, + facingAway, + flipped: facingAway ? !!p.leftHanded : !p.leftHanded, + left: f[0] - w / 2, + top, + w, + h, + fx: f[0], + fy: f[1], + zc: z, + near: facingAway, + }; + }); + const byDepth = (a: { zc: number }, c: { zc: number }) => a.zc - c.zc; + const farSprites = sprites.filter((s) => !s.near).sort(byDepth); + const nearSprites = sprites.filter((s) => s.near).sort(byDepth); + // Near figures sandwich the shuttle by depth: a shuttle between a player + // and the net sits beyond him from the camera, so his body occludes it at + // the hit; once it passes behind him it draws over him instead. + const shuttleZNow = shuttleCourt[1]; + const nearBehindShuttle = nearSprites.filter((s) => s.zc < shuttleZNow); + const nearOverShuttle = nearSprites.filter((s) => s.zc >= shuttleZNow); + + const spriteShadow = (s: (typeof sprites)[number]) => ( + + ); + + const spriteView = (s: (typeof sprites)[number]) => ( + + + + ); + const playerTrails = showPlayerTrails && prev ? cur.players @@ -278,16 +348,6 @@ export function Court3DView({ opacity={0.85 * b} /> )} - - - {playerTrails.map((t) => ( ))} + {farSprites.map(spriteShadow)} + + + {/* Far-court mascots stand behind the net (each carries its own Svg + root so per-mascot gradient ids can never collide). */} + {farSprites.map(spriteView)} + + + + + + + {nearSprites.map(spriteShadow)} {shot && showShuttleTrail && land && ( <> @@ -388,6 +468,13 @@ export function Court3DView({ ))} + + + {/* Near-court mascots stand in front of the net, backs to the camera; + those closer to the net than the shuttle render under it. */} + {nearBehindShuttle.map(spriteView)} + + {/* Shuttle chip */} + {/* Near figures the shuttle has passed: they now occlude it. */} + {nearOverShuttle.map(spriteView)} + {/* Orbit / pinch surface (dock and header sit above and win touches) */}