File size: 15,301 Bytes
8608517
 
 
 
 
 
 
2c331a6
8608517
2c331a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8608517
 
2c331a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8608517
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2c331a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8608517
 
 
2c331a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8608517
 
 
2c331a6
 
 
8608517
2c331a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8608517
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2c331a6
8608517
 
 
 
2c331a6
8608517
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2c331a6
 
 
 
8608517
 
 
2c331a6
 
 
8608517
 
 
2c331a6
 
 
8608517
2c331a6
 
 
 
 
8608517
 
 
 
 
 
 
 
2c331a6
 
 
 
 
 
 
 
 
 
 
 
 
 
8608517
 
2c331a6
 
 
 
 
 
 
 
 
 
 
8608517
 
 
2c331a6
 
 
 
 
 
 
 
 
8608517
 
 
 
2c331a6
 
 
 
8608517
 
 
 
 
 
 
 
 
 
 
 
2c331a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
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>
  );
}