glenans-sailing-sim / src /Tutorial.tsx
tfrere's picture
tfrere HF Staff
feat: centralize top HUD banners, richer tutorial, and UX polish
8608517
Raw
History Blame Contribute Delete
15.3 kB
import {
useEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import { LAPS } from "./steerConfig";
import { polar } from "./physics";
// First-launch tutorial: a tiny 3-slide carousel shown once (after the player
// hits "Larguez les amarres") over the frozen sea. Skipping or finishing marks
// it seen so it never shows again. Illustrations reuse the game's real UI
// components (helm pads + tiller, crew-call buttons, HUD metrics) so what the
// player sees here is exactly what they'll drive.
// Slide 1 - the helm: the two steering cards with the wooden tiller nestled in
// their scoop, rebuilt from the live .ctrl markup (positioning overridden by
// .tt-helm).
function HelmArt() {
return (
<div className="tt-helm" aria-hidden="true">
<div className="pad padl">
<div className="padcard lofer">
<div className="padbtn lofer">
<div className="padeff lofer">Lofer</div>
</div>
</div>
</div>
<div className="tiller">
<div className="tillerdial">
<svg className="tillersvg" viewBox="0 0 48 64">
<g transform="translate(24 32) rotate(-90) scale(0.078) translate(-100 -92)">
<image href="/barre.svg" width="874" height="185" />
</g>
</svg>
</div>
</div>
<div className="pad padr">
<div className="padcard abattre">
<div className="padbtn abattre">
<div className="padeff abattre">Abattre</div>
</div>
</div>
</div>
</div>
);
}
// Slide 2 - points of sail: a compact replica of the game's easy-mode "allure
// ring" (same angles, labels and dashed grey style as steerRender.allureRing).
// Wind blows from the top; 0deg is head-to-wind (no-go), 180deg is dead run.
const ALLURES: [string, number][] = [
["Près", 52],
["Bon plein", 69],
["Travers", 89],
["Largue", 115],
["Grand largue", 140],
];
const ALLURE_DIVS = [43, 60, 78, 100, 130, 150];
// Wedge half-angles, straight from physics.ts (NOGO / GYBE).
const NOGO = 43;
const GYBE = 30;
function AllureArt() {
const cx = 140;
const cy = 104;
const R = 82;
const rDiv = 30; // inner radius where sector dividers start
const rLab = R + 9; // labels: their INNER edge sits on this circle (even spacing)
const pt = (aDeg: number, r: number, s = 1) => {
const a = (aDeg * Math.PI) / 180;
return [cx + s * r * Math.sin(a), cy - r * Math.cos(a)] as const;
};
// Pie slice symmetric about the vertical axis: `edge` is the off-wind angle of
// each side. Upwind (edge=NOGO) sweeps through the top; downwind
// (edge=180-GYBE) sweeps through the bottom.
const wedgeTop = (edge: number) => {
const l = pt(edge, R, -1);
const r = pt(edge, R, 1);
return `M ${cx} ${cy} L ${l[0]} ${l[1]} A ${R} ${R} 0 0 1 ${r[0]} ${r[1]} Z`;
};
const wedgeBottom = (edge: number) => {
const l = pt(edge, R, -1);
const r = pt(edge, R, 1);
return `M ${cx} ${cy} L ${r[0]} ${r[1]} A ${R} ${R} 0 0 1 ${l[0]} ${l[1]} Z`;
};
// Relative-speed polar: the boat's actual target speed at each allure, straight
// from physics.polar(). Radius at angle a = polar(a) * rSpeed, mirrored on both
// tacks -> a closed blob that's fat where the boat is fast (reach), pinched to
// nothing in the no-go, and only medium dead downwind.
const rSpeed = 66; // max speed radius, kept inside the ring (R = 82)
const speedD = (() => {
const step = 4;
const seg: string[] = [];
for (let a = 0; a <= 180; a += step) {
const [x, y] = pt(a, polar(a) * rSpeed, 1);
seg.push(`${x.toFixed(1)} ${y.toFixed(1)}`);
}
for (let a = 180; a >= 0; a -= step) {
const [x, y] = pt(a, polar(a) * rSpeed, -1);
seg.push(`${x.toFixed(1)} ${y.toFixed(1)}`);
}
return "M " + seg.join(" L ") + " Z";
})();
return (
<svg className="tt-allure" viewBox="0 0 280 216" aria-hidden="true">
<defs>
{/* Same radial fade as the game's drawWedge: melts out toward the rim. */}
<radialGradient
id="tt-wedge"
gradientUnits="userSpaceOnUse"
cx={cx}
cy={cy}
r={R}
>
<stop offset="0" stopColor="rgba(221,138,38,0)" />
<stop offset="0.4" stopColor="rgba(221,138,38,0.05)" />
<stop offset="1" stopColor="rgba(221,138,38,0.34)" />
</radialGradient>
</defs>
{/* relative-speed polar (under the zone wedges so orange never muddies it):
the green area shows how fast the boat sails at each allure */}
<path className="al-speed" d={speedD} />
{/* upwind no-go zone (dashed edges) */}
<path className="al-wedge al-nogo" d={wedgeTop(NOGO)} />
{/* downwind gybe / vent-arrière zone (soft, no dashed edges) */}
<path className="al-wedge" d={wedgeBottom(180 - GYBE)} />
{/* dashed ring */}
<circle className="al-ring" cx={cx} cy={cy} r={R} />
{/* radial sector dividers, both tacks */}
{ALLURE_DIVS.flatMap((d) =>
[1, -1].map((s) => {
const a = pt(d, rDiv, s);
const b = pt(d, R, s);
return (
<line
key={`${d}:${s}`}
className="al-div"
x1={a[0]}
y1={a[1]}
x2={b[0]}
y2={b[1]}
/>
);
})
)}
{/* wind blowing down INSIDE the ring, toward the boat */}
<line className="al-wind" x1={cx} y1={cy - R + 16} x2={cx} y2={cy - 44} />
<path className="al-windfill" d={`M ${cx} ${cy - 36} l -6 -10 l 12 0 z`} />
{/* boat at the centre: the game's cream hull + red sails, pointing up */}
<g transform={`translate(${cx} ${cy}) scale(0.62)`}>
<path
className="al-hull"
d="M0 -25.3 Q11 -4.6 8 18.4 Q0 25.3 -8 18.4 Q-11 -4.6 0 -25.3 Z"
/>
<path className="al-sail" d="M0 -22 Q9 -13 3 -5 Q0 -7 0 -22 Z" />
<path className="al-sail" d="M0 -2 Q12 9 4 20 Q0 18 0 -2 Z" />
<circle className="al-mast" cx="0" cy="-2.3" r="1.8" />
</g>
{/* labels: top/bottom centred, sides aligned by their inner edge so every
allure reads at the same distance from the ring. */}
<text className="al-lab" x={cx} y={cy - R - 9} textAnchor="middle">
Vent debout
</text>
<text className="al-lab" x={cx} y={cy + R + 16} textAnchor="middle">
Vent arrière
</text>
{ALLURES.flatMap(([txt, a]) =>
[1, -1].map((s) => {
const p = pt(a, rLab, s);
return (
<text
key={`${txt}:${s}`}
className="al-lab"
x={p[0]}
y={p[1]}
textAnchor={s === 1 ? "start" : "end"}
>
{txt}
</text>
);
})
)}
</svg>
);
}
// Slide 3 - the crew calls: the two "Paré à ..." buttons, straight from the live
// .annbtn markup. They "self-click" in turn (see .tt-calls animation) to mime the
// announce ritual; the danger of skipping it lives in the slide text.
function CallsArt() {
return (
<div className="tt-calls" aria-hidden="true">
<div className="annbtn" style={{ "--prep": 0.62 } as CSSProperties}>
<span className="annlab">
<span className="anneye">Paré à</span>
<b className="annverb">Virer</b>
</span>
</div>
<div className="annbtn">
<span className="annlab">
<span className="anneye">Paré à</span>
<b className="annverb">Empanner</b>
</span>
</div>
</div>
);
}
// Slide 4 - the bearing: a static replica of the game's off-screen mark pointer
// (steerRender.drawOffscreenArrow) - a red arrow aimed at the next buoy, its
// distance readout, and the small curved glyph telling you which side to round.
function BearingArt() {
return (
<svg className="tt-bearing" viewBox="0 0 280 216" aria-hidden="true">
{/* faint bearing guide from the boat out to the buoy */}
<line className="ga-guide" x1="84" y1="150" x2="206" y2="66" />
{/* your boat, lower-left, pointing up toward the objective */}
<g transform="translate(70 168) rotate(50) scale(0.58)">
<path
className="al-hull"
d="M0 -25.3 Q11 -4.6 8 18.4 Q0 25.3 -8 18.4 Q-11 -4.6 0 -25.3 Z"
/>
<path className="al-sail" d="M0 -22 Q9 -13 3 -5 Q0 -7 0 -22 Z" />
<path className="al-sail" d="M0 -2 Q12 9 4 20 Q0 18 0 -2 Z" />
<circle className="al-mast" cx="0" cy="-2.3" r="1.8" />
</g>
{/* the red pointer: readout (rounding glyph + distance) then the arrow, all
on the exact bearing to the buoy, lifted with a soft drop-shadow */}
<g className="ga-pointer">
<g transform="translate(104 116)">
<path
className="ga-glyph"
d="M0 -5.6 A 5.6 5.6 0 1 1 -5.4 -1.5"
fill="none"
/>
<path className="ga-glyphhead" d="M-5.4 -1.5 l -3.1 -0.9 l 1.7 2.9 z" />
</g>
<text className="ga-dist" x="116" y="116">
120&#8202;m
</text>
<g transform="translate(170 92) rotate(50)">
<path className="ga-arrow" d="M0 -12 L6.4 4 L-6.4 4 Z" />
</g>
</g>
{/* the next buoy the arrow points at */}
<g transform="translate(214 58) scale(1.5)">
<path
className="ga-buoy"
d="M-6 5 Q-8 -3 0 -8 Q8 -3 6 5 Q0 9 -6 5 Z"
/>
<path className="ga-band" d="M-5.5 0.5 Q0 2.5 5.5 0.5" fill="none" />
<line className="ga-mast" x1="0" y1="-8" x2="0" y2="-21" />
<path className="ga-pennant" d="M-1.6 -21 Q4 -15 3 -9 Q-0.5 -14 -0.4 -21 Z" />
</g>
</svg>
);
}
// Slide 5 - the objective: the HUD metric trio the player chases (laps, buoys,
// time), reusing the live .metric / .eyebrow / .speed styles.
const GOAL_BUOYS = 2; // buoys per lap on the demo windward-leeward
const GOAL_SECS_PER_BUOY = 4; // demo pace: a buoy is rounded every few seconds
const GOAL_LOOP = LAPS * GOAL_BUOYS * GOAL_SECS_PER_BUOY; // full "race" before it replays
function GoalArt() {
// A tiny looping "race": the clock ticks up every second while the buoy and
// lap counters climb like a real run (buoy 1/2 -> 2/2, then next lap), then it
// resets so the counters are always visibly moving.
const [t, setT] = useState(0);
useEffect(() => {
const id = window.setInterval(() => setT((s) => (s + 1) % GOAL_LOOP), 1000);
return () => window.clearInterval(id);
}, []);
const buoyIndex = Math.floor(t / GOAL_SECS_PER_BUOY); // 0..(LAPS*GOAL_BUOYS-1)
const lap = Math.floor(buoyIndex / GOAL_BUOYS) + 1;
const buoy = (buoyIndex % GOAL_BUOYS) + 1;
const time = `${Math.floor(t / 60)}:${(t % 60).toString().padStart(2, "0")}`;
// Bounce the Tour / Bouées counters on each increment, exactly like the live
// HUD: retrigger the game's `pop` animation (remove class -> reflow -> add).
// Seed the "previous" refs with the initial values so the counters DON'T pop on
// arrival - only on the first real change and onward.
const lapRef = useRef<HTMLDivElement>(null);
const buoyRef = useRef<HTMLDivElement>(null);
const prevLap = useRef(lap);
const prevBuoy = useRef(buoy);
useEffect(() => {
if (prevLap.current === lap) return;
prevLap.current = lap;
bounce(lapRef.current);
}, [lap]);
useEffect(() => {
if (prevBuoy.current === buoy) return;
prevBuoy.current = buoy;
bounce(buoyRef.current);
}, [buoy]);
return (
<div className="tt-goal" aria-hidden="true">
<div className="metric">
<div className="eyebrow">Tour</div>
<div className="speed" ref={lapRef}>
{lap}/{LAPS}
</div>
</div>
<div className="metric">
<div className="eyebrow">Bouées</div>
<div className="speed" ref={buoyRef}>
{buoy}/{GOAL_BUOYS}
</div>
</div>
<div className="metric">
<div className="eyebrow">Temps</div>
<div className="speed">{time}</div>
</div>
</div>
);
}
// Retrigger the `pop` bounce on a counter (mirrors the game's reflow trick).
function bounce(el: HTMLElement | null) {
if (!el) return;
el.classList.remove("pop");
void el.offsetWidth;
el.classList.add("pop");
}
interface Slide {
key: string;
title: string;
body: ReactNode;
art: ReactNode;
}
const SLIDES: Slide[] = [
{
key: "barre",
title: "La barre",
body: (
<>
Maintiens <b>Lofer</b> (vers le vent) ou <b>Abattre</b> (loin du vent)
pour faire tourner le bateau. Les <b>écoutes</b> (les cordages qui
règlent les voiles) s'ajustent seules, avec un{" "}
<b>léger temps de retard</b> - comme un vrai équipage.
</>
),
art: <HelmArt />,
},
{
key: "allures",
title: "Les allures",
body: (
<>
Ton <b>allure</b>, c'est ton angle au vent - et c'est lui qui commande
ta <b>vitesse</b> (la <b>zone verte</b>) : le bateau file au <b>largue</b>,
cale <b>face au vent</b> (<b>zone interdite</b>) où il faut{" "}
<b>tirer des bords</b> en zigzag pour remonter.
</>
),
art: <AllureArt />,
},
{
key: "manoeuvres",
title: "Les manœuvres",
body: (
<>
Avant de traverser le lit du vent, préviens l'équipage :{" "}
<b>Paré à virer</b> (près du vent) ou <b>Paré à empanner</b> (vent
arrière). Sans annonce, la voile <b>claque violemment</b> d'un bord à
l'autre - <b>manœuvre sauvage</b> qui te fait <b>perdre ta vitesse</b>.
</>
),
art: <CallsArt />,
},
{
key: "cap",
title: "Le cap",
body: (
<>
Une <b>flèche rouge</b> pointe toujours vers ta <b>prochaine bouée</b> et
t'indique la <b>distance</b> qui reste. Le petit <b>arc courbe</b> montre
de quel <b>côté la contourner</b>.
</>
),
art: <BearingArt />,
},
{
key: "but",
title: "Le but",
body: (
<>
Boucle le parcours <b>au plus vite</b> en passant chaque bouée{" "}
<b>du bon côté</b>.
</>
),
art: <GoalArt />,
},
];
export default function Tutorial({ onClose }: { onClose: () => void }) {
const [i, setI] = useState(0);
const last = i === SLIDES.length - 1;
const slide = SLIDES[i];
return (
<div className="tuto">
<div className="tutocard" key={slide.key}>
<div className="tutoart">{slide.art}</div>
<div className="tutotitle">{slide.title}</div>
<div className="tutobody">{slide.body}</div>
<div className="tutodots" aria-hidden="true">
{SLIDES.map((s, n) => (
<span key={s.key} className={"tutodot" + (n === i ? " on" : "")} />
))}
</div>
<div className="tutonav">
<button type="button" className="tutoskip" onClick={onClose}>
Passer
</button>
<button
type="button"
className="tutonext"
onClick={() => (last ? onClose() : setI(i + 1))}
>
{last ? "C'est parti !" : "Suivant"}
</button>
</div>
</div>
</div>
);
}