Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions .maestro/06-tilt-3d-drill.yaml
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
# 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:
clearState: true
# 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"
Expand All @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion components/BadmintonCourt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -345,7 +347,7 @@ export default function BadmintonCourt() {
linesRect
),
}));
}, [customizations, getStepsSnapshot, isDoubles, linesRect]);
}, [customizations, effectiveLooks, getStepsSnapshot, isDoubles, linesRect]);

const advance3D = useCallback(() => {
if (canRedo) redo();
Expand Down
128 changes: 109 additions & 19 deletions components/Court3DView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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]) => (
<Ellipse
key={`msh-${s.key}`}
cx={s.fx}
cy={s.fy}
rx={(s.w * 0.62) / 2}
ry={(s.w * 0.24) / 2}
fill="rgba(0,0,0,0.33)"
opacity={0.25 + 0.75 * b}
/>
);

const spriteView = (s: (typeof sprites)[number]) => (
<View
key={`mascot-${s.key}`}
pointerEvents="none"
style={{ position: 'absolute', left: s.left, top: s.top }}
>
<MascotView mascot={s.mascot} band={s.band} width={s.w} flipped={s.flipped} facingAway={s.facingAway} />
</View>
);

const playerTrails =
showPlayerTrails && prev
? cur.players
Expand Down Expand Up @@ -278,16 +348,6 @@ export function Court3DView({
opacity={0.85 * b}
/>
)}
<Polygon points={netPts} fill="rgba(255,255,255,0.14)" />
<Path d={seg(nt1, nt2)} stroke="#FFFFFF" strokeOpacity={0.95} strokeWidth={2.6} strokeLinecap="round" fill="none" />
<Path
d={`${seg(nb1, nt1)} ${seg(nb2, nt2)}`}
stroke="#FFFFFF"
strokeOpacity={0.7}
strokeWidth={3}
strokeLinecap="round"
fill="none"
/>

{playerTrails.map((t) => (
<Line
Expand All @@ -303,6 +363,26 @@ export function Court3DView({
strokeLinecap="round"
/>
))}
{farSprites.map(spriteShadow)}
</Svg>

{/* Far-court mascots stand behind the net (each carries its own Svg
root so per-mascot gradient ids can never collide). */}
{farSprites.map(spriteView)}

<Svg width={width} height={height} pointerEvents="none" style={StyleSheet.absoluteFill}>
<Polygon points={netPts} fill="rgba(255,255,255,0.14)" />
<Path d={seg(nt1, nt2)} stroke="#FFFFFF" strokeOpacity={0.95} strokeWidth={2.6} strokeLinecap="round" fill="none" />
<Path
d={`${seg(nb1, nt1)} ${seg(nb2, nt2)}`}
stroke="#FFFFFF"
strokeOpacity={0.7}
strokeWidth={3}
strokeLinecap="round"
fill="none"
/>

{nearSprites.map(spriteShadow)}

{shot && showShuttleTrail && land && (
<>
Expand Down Expand Up @@ -388,6 +468,13 @@ export function Court3DView({
</G>
))}

</Svg>

{/* 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)}

<Svg width={width} height={height} pointerEvents="none" style={StyleSheet.absoluteFill}>
{/* Shuttle chip */}
<Circle cx={sp3[0]} cy={sp3[1]} r={sw / 2} fill="#FFFFFF" />
<G
Expand All @@ -404,6 +491,9 @@ export function Court3DView({
</G>
</Svg>

{/* Near figures the shuttle has passed: they now occlude it. */}
{nearOverShuttle.map(spriteView)}

{/* Orbit / pinch surface (dock and header sit above and win touches) */}
<View
style={StyleSheet.absoluteFill}
Expand Down
10 changes: 9 additions & 1 deletion scripts/record-cufs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,15 @@ rc=0
for flow in .maestro/*.yaml; do
name=$(basename "$flow" .yaml)
adb shell "screenrecord --bit-rate 4000000 --time-limit 170 /sdcard/$name.mp4" &
maestro test "$flow" || rc=1
if ! maestro test "$flow"; then
# One retry per flow: maestro's inputText over gRPC flakes on CI
# emulators (DEADLINE_EXCEEDED); a genuine regression fails twice.
adb shell pkill -INT screenrecord || true
sleep 3
echo "::warning::flow $name failed once, retrying"
adb shell "screenrecord --bit-rate 4000000 --time-limit 170 /sdcard/$name.mp4" &
maestro test "$flow" || rc=1
fi
adb shell pkill -INT screenrecord || true
sleep 3
adb pull "/sdcard/$name.mp4" "cuf-videos/$name.mp4" || true
Expand Down
Loading