glenans-sailing-sim / src /steerConfig.ts
tfrere's picture
tfrere HF Staff
feat: painterly sea, refined tiller, tabbed finish screen + top-5 leaderboard
acf9613
Raw
History Blame Contribute Delete
8.38 kB
// Static configuration for the "learn to steer" game: palette, tuning knobs,
// rig geometry, telltale model and the pure telltale-strand builder. Everything
// here is stateless (no game/closure state), so it lives outside the component.
import { NOGO } from "./physics";
export const COLORS = {
water: "#d9e9ec",
waterDeep: "#a8ccd6",
crest: "#f2fafb",
ink: "#163642",
inkSoft: "#3f5f6b",
port: "#c4452f",
stbd: "#2e7d5b",
luff: "#dd8a26",
hull: "#f6f1e7",
// Rig accent: a Glénans-style red for the sails (and the rudder, which shares
// the same tone). A hair deeper than the port-buoy red so the two don't blur.
sail: "#b8382a",
};
export const MAXSPD = 6.6; // kn at best polar (J/80 broad-reach peak, ~8 kn TWS)
// Sail-trim efficiency: luffing / over-sheeted sails lose drive, so a combined
// luff factor (main-dominant, plus a smaller headsail share) scales the speed
// target down (0 = full and drawing, 1 = flogging). This is what makes "paré à
// empanner" without gybing - the main hauled amidships on a run - actually cost
// speed, and what makes pinching upwind bite. 0 = disabled, 1 = a full flog
// kills all drive. On a settled beat/reach the sails sit at ~0 luff, so a
// well-trimmed boat pays no penalty.
export const LUFF_DRIVE_PENALTY = 0.8;
export const TWS = 8; // true wind speed, same arbitrary units as boat speed
export const BOAT = 2.1; // visual scale of the boat (hull + rig)
export const WAKE_STEP = 7; // distance (world px) between recorded wake points
export const WAKE_MAX = 90; // max wake points -> overall wake length
export const WAKE_LIFE_MS = 2200; // how long a wake point lives, so it fades when slow
// Mark rounding uses the real racing rule (RRS 28 "string rule") with NO maximum
// radius - round as wide or tight as you like. The geometry lives in
// steerPhysics.roundingProgress.
// Touching a mark is a foul (RRS 31). We don't force a penalty turn; instead the
// hit kills most of the boat's way and jolts the screen - the lost speed IS the
// penalty. BUOY_HIT_R is how close (world px) the boat centre must come to a buoy
// to count as a touch; BUOY_HIT_KEEP is the fraction of speed left after a hit.
export const BUOY_HIT_R = 24;
export const BUOY_HIT_KEEP = 0.3;
export const POP_MS = 560; // duration of the "validated" pop animation on a rounded buoy
export const POP_SCALE = 0.85; // extra scale at the peak of the pop (1 + this)
export const LAPS = 2; // windward-leeward loops before the finish (2-4 is a normal race)
// Rig geometry, in unscaled local units (x = starboard, y = aft, -y = bow).
export const MAST_Y = -2.3; // mainsail tack / boom pivot (rig stretched with the hull)
export const BOOM_LEN = 16.2; // mainsail foot length (top-down, trimmed a touch shorter)
export const JIB_TACK_Y = -23; // headsail tack at the forestay (rig stretched with hull)
export const JIB_LEN = 12.5; // headsail foot length (top-down, trimmed a touch shorter)
export const RIG_AOA = 16; // target angle of attack held by the sheets (deg)
// Headsail blanketing deep downwind: past ~150deg off the wind the mainsail
// steals the jib's air, so instead of just fluttering the headsail DEVENTS -
// it goes limp and its clew collapses inboard toward the forestay. `START`..
// `FULL` is the off-wind range (deg) over which it builds, and `RATE` how fast
// it sets in over time (per s) so the collapse happens "after a moment", not
// instantly. It clears quickly the instant you head back up.
export const JIB_BLANKET_START = 150; // off-wind angle where the main starts to shadow the jib
export const JIB_BLANKET_FULL = 173; // by here (near dead run) the jib is fully starved
export const JIB_BLANKET_RATE = 0.9; // build-in speed (per s); low = sets in gradually
// "Paré à empanner": the mainsheet is hauled in toward the centreline as the
// crew gets ready, so the boom crosses under control instead of slamming across
// from right out. This is the boom angle (deg off dead-aft) it's trimmed to.
export const GYBE_CENTER_DEG = 12;
// Telltale (penons) driven by the REAL angle of attack of the jib, read against
// a sheet trim that LAGS the helm (the trim is never instant). Both strands react
// to steering, and settle back once the sheet catches up:
// - head up faster than the sheet trims in -> AoA drops -> WINDWARD lifts;
// - bear away faster than it eases -> AoA climbs -> LEEWARD stalls (the sheet is
// momentarily over-trimmed for the new, lower course);
// - settled on any beat -> AoA ~ RIG_AOA -> both stream;
// - near the no-go (sheet on its stop) the windward stays lifted; deep downwind
// (push mode) both go slack.
export const TT_TRIM_RATE = 0.8; // how fast the trim chases the helm (per s); lower = laggier
// Both strands break on the SAME lag gap (jibTarget - trimLagDeg, in sheet deg),
// so they're equally sensitive: negative gap (headed up) lifts the windward,
// positive gap (bore away) stalls the leeward.
export const TT_LAG_DEAD = 1.5; // gap tolerated before a strand starts to break (deg)
export const TT_LAG_SPAN = 9; // extra gap over which it fully breaks (deg)
// The windward strand also breaks when you point ABOVE the beat you're working:
// past this off-wind limit (heading up toward the no-go) it lifts, regardless of
// trim - it's the "you've overstood the close-hauled" cue.
export const TT_PINCH_LIMIT = NOGO + 6; // ~49deg: the working close-hauled limit
// Deep downwind the sail is in push mode: the flow separates and both go slack.
export const TT_RUN_START = 94; // right at the beam reach: the flow lets go here
export const TT_RUN_FULL = 104; // by the largue both strands are fully slack (sharp drop)
// Helm & steering feel - every knob is here so it can be tuned in one place.
// The helm and the boat are COUPLED through the tiller: the boat's heading comes
// only from the rudder angle (`tiller`), and the rig's self-steering moment
// (`sailHelm`) loads the free tiller toward a rest position, so the barre (both
// the rudder on the boat and the widget below) visibly reacts to the force. A
// held pad drives the tiller across and it stays where it's left; let go and the
// flow torque takes over. `sailHelm` is signed and driven by rig pressure:
// WEATHER helm (rounds up toward the wind) at close-hauled/reach, fading then
// REVERSING into LEE helm (bears away, the other way) deep downwind.
export const HELM = {
rate: 2.7, // how fast a held pad/key drives the tiller across (per s)
load: 2.2, // how fast the flow torque loads the free tiller toward its rest
rudderTurn: 78, // heading change at full helm (deg/s), scaled by speed
rudderFloor: 0.4, // rudder authority with no way on (0..1 of full)
weather: 9, // max weather helm (deg/s): round-up toward the wind, peaks abeam
lee: 5, // max lee helm (deg/s): bear-away the OTHER way deep downwind
leeStart: 135, // off-wind angle (deg) where lee helm starts to build in
powerCap: 1.4, // clamp on the rig-pressure proxy (apparent > TWS upwind)
// Hull directional stability: the keel/hull resist yaw, so the boat TRACKS
// (disturbances decay) instead of spinning off any imbalance. At speed the
// steady turn rate for full helm is ~ rudderTurn / yawDamp.
yawDamp: 1.9, // directional stability (steady-turn divisor, grows with speed)
yawInertia: 3.5, // yaw response rate (per s): higher = snappier, less lag
};
export interface Boat {
x: number;
y: number;
hdg: number;
spd: number;
}
// Build a telltale as a thin ribbon of cloth: a centreline that leaves the luff
// at `angleDeg` (0 = streaming aft, <0 lifts up, >0 droops down) and ripples
// along its length. The free tip flaps most (anchored end stays put), so a
// broken/limp strand waves like real fabric instead of a rigid rotated curve.
export function buildStrand(
angleDeg: number,
amp: number,
phase: number,
len = 30,
) {
const ang = (angleDeg * Math.PI) / 180;
const dx = Math.cos(ang);
const dy = Math.sin(ang);
const px = -Math.sin(ang); // unit perpendicular, for the ripple offset
const py = Math.cos(ang);
let d = "M0 0";
const N = 7;
for (let i = 1; i <= N; i++) {
const t = i / N;
const dist = t * len;
const rip = amp * Math.sin(t * 4.4 + phase) * t;
const x = dx * dist + px * rip;
const y = dy * dist + py * rip;
d += ` L${x.toFixed(1)} ${y.toFixed(1)}`;
}
return d;
}