Spaces:
Sleeping
Sleeping
| 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 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> | |
| ); | |
| } | |