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)}
+
+
+
+ {/* 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)}
+
+
+ {/* Near figures the shuttle has passed: they now occlude it. */}
+ {nearOverShuttle.map(spriteView)}
+
{/* Orbit / pinch surface (dock and header sit above and win touches) */}