File size: 13,036 Bytes
b234058
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f12281a
 
 
 
 
 
b234058
f12281a
 
 
 
 
 
 
 
 
 
b234058
f12281a
 
 
 
 
 
 
b234058
f12281a
 
 
 
 
b234058
f12281a
b234058
 
 
f12281a
 
b234058
f12281a
 
 
b234058
 
 
 
 
f12281a
b234058
 
f12281a
b234058
 
 
 
 
 
f12281a
 
b234058
 
 
f12281a
b234058
 
 
 
 
 
 
 
 
 
 
 
 
f12281a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
// Latchkite core: deterministic simulation (no DOM). Your kite whirls round a peg on its line; one tap lets go and it
// flies straight along its swing. Reach the next peg's catch ring and the line latches on. Same pegs for everyone each
// UTC day (seed = the date). Fixed 60 Hz ticks, so a run is the same on every device. (c) 2026 CyberMax.

export const HZ = 60;
export const W = 100;               // world width in units; y grows upward
export const FRAY = HZ * 3;         // a line frays and snaps after 3 s on one peg: no camping
export const FLY_MAX = HZ * 2;      // a kite that flies 2 s without a catch is lost
const TAU = Math.PI * 2;

export function rng(seed) {
  let h = 0x811c9dc5;
  for (let i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = Math.imul(h, 0x01000193); }
  let a = h >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** Catch-ring radius of peg i: generous early, tighter later (never below 4.6 units). */
export const catchR = (i) => Math.max(4.6, 8 - i * 0.12);
/** Spin speed in radians per second after `score` catches. */
export const spin = (score) => Math.min(4.6, 2.5 + score * 0.07);
/** Flight speed in units per second. */
export const flySpeed = (score) => Math.min(70, 42 + score * 0.9);

/** The peg course for a seed: peg 0 sits at the bottom middle, each next peg higher, left or right. */
export function course(seed, n = 400) {
  const r = rng(`latchkite:${seed}`);
  const pegs = [{ x: 50, y: 0 }];
  for (let i = 1; i < n; i++) {
    const p = pegs[i - 1];
    const gap = 20 + r() * 7 + Math.min(i, 40) * 0.22;
    let dx = (12 + r() * 24) * (r() < 0.5 ? -1 : 1);
    if (p.x + dx < 14 || p.x + dx > 86) dx = -dx;
    pegs.push({ x: Math.max(14, Math.min(86, p.x + dx)), y: p.y + gap, r: catchR(i) });
  }
  pegs[0].r = catchR(0);
  return pegs;
}

/**
 * A run: step(tap) once per tick; returns 'latch', 'win', 'dead' or ''. With no `cfg` this is the daily/practice run
 * (unchanged since launch). A level passes `cfg` (see levelCfg): its own pegs, spin, speed, fray, wind, drifting pegs,
 * flip pegs, snap pegs and storm clouds, and a goal peg (the last one) that ends the run with 'win'.
 */
export function makeSim(seed, cfg) {
  const r0 = rng(`latchkite-dir:${seed}`)();
  return mk(cfg ? cfg.pegs : course(seed), r0 < 0.5 ? 1 : -1, cfg || null);
}

function mk(pegs, dir, L) {
  const spinOf = L ? (sc) => Math.min(L.spin[1], L.spin[0] + sc * L.spin[2]) : spin;
  const flyOf = L ? (sc) => Math.min(L.fly[1], L.fly[0] + sc * L.fly[2]) : flySpeed;
  const fray = L ? L.fray : FRAY;
  const wind = L ? L.wind || 0 : 0;
  const clouds = L ? L.clouds || [] : [];
  const goal = L ? pegs.length - 1 : Infinity;
  const s = {
    pegs, peg: 0, a: -Math.PI / 2, len: 10, dir, score: 0, t: 0, held: 0, cfg: L, goal, clouds, wind,
    flying: false, fx: 0, fy: 0, vx: 0, vy: 0, flown: 0, dead: false, won: false, why: '',
    /** x of peg j now (drifting pegs slide side to side; other pegs stay put). */
    px(j) { const q = this.pegs[j]; return q.ax ? q.x + q.ax * Math.sin(q.ph + (this.t * q.w) / HZ) : q.x; },
    /** Ticks this peg lets you hold on before the line snaps. */
    limit(j = this.peg) { return L && this.pegs[j].snap ? L.snapT : fray; },
    get x() { return this.flying ? this.fx : this.px(this.peg) + Math.cos(this.a) * this.len; },
    get y() { return this.flying ? this.fy : this.pegs[this.peg].y + Math.sin(this.a) * this.len; },
    clone() {
      const c = mk(this.pegs, this.dir, L);
      for (const k of ['peg', 'a', 'len', 'score', 't', 'held', 'flying', 'fx', 'fy', 'vx', 'vy', 'flown', 'dead', 'won', 'why']) c[k] = this[k];
      return c;
    },
    step(tap) {
      if (this.won) return 'win';
      if (this.dead) return 'dead';
      this.t++;
      if (!this.flying) {
        this.a = (this.a + this.dir * spinOf(this.score) / HZ) % TAU;
        if (++this.held >= this.limit()) { this.dead = true; this.why = this.pegs[this.peg].snap ? 'snap' : 'fray'; this.flying = true; this.fx = this.x; this.fy = this.y; return 'dead'; }
        if (tap) {
          const px = this.px(this.peg), p = this.pegs[this.peg];
          this.fx = px + Math.cos(this.a) * this.len; this.fy = p.y + Math.sin(this.a) * this.len;
          const v = flyOf(this.score);
          this.vx = -Math.sin(this.a) * this.dir * v; this.vy = Math.cos(this.a) * this.dir * v;
          this.flying = true; this.flown = 0;
        }
        return '';
      }
      if (wind) this.vx += wind / HZ;
      this.fx += this.vx / HZ; this.fy += this.vy / HZ; this.flown++;
      for (let j = this.peg + 1; j <= this.peg + 3 && j < this.pegs.length; j++) {
        const q = this.pegs[j], dx = this.fx - this.px(j), dy = this.fy - q.y, d = Math.hypot(dx, dy);
        if (d <= q.r) {
          this.score += j - this.peg;            // skipping a peg counts double
          this.peg = j; this.flying = false; this.held = 0;
          this.len = Math.max(5, Math.min(11, d < 5 ? 8 : d));
          this.a = Math.atan2(dy, dx);
          this.dir = dx * this.vy - dy * this.vx >= 0 ? 1 : -1; // keep turning the way the kite was moving
          if (q.flip) this.dir = -this.dir;                    // flip peg: the swing turns the other way
          if (j >= this.goal) { this.won = true; return 'win'; }
          return 'latch';
        }
      }
      for (const c of clouds) if (Math.hypot(this.fx - c.x, this.fy - c.y) < c.r) { this.dead = true; this.why = 'storm'; return 'dead'; }
      const low = this.pegs[this.peg].y - 30;
      if (this.flown >= FLY_MAX || this.fx < -6 || this.fx > W + 6 || this.fy < low) { this.dead = true; this.why = 'lost'; return 'dead'; }
      return '';
    },
  };
  return s;
}

/** 10-block bar for share text: one block per 3 catches. */
export function bar(n) {
  const full = Math.min(10, Math.floor(n / 3));
  return '🟦'.repeat(full) + '⬜'.repeat(10 - full);
}

// ------------------------------------------------------------------ 30 levels (own seed space: `latchkite:lv:<n>`)
// Each level is a finite course: catch `pegs` pegs and latch onto the goal peg at the top. A new twist about every
// 5 levels (wind, drifting pegs, flip pegs, snap pegs, storm clouds) and a challenge level every 10th. Stars are for
// time: the level builder flies a bot through every course and only keeps a course the bot clears while every
// release it uses is a window of at least `win` ticks wide, so every level is beatable by a person, not just a robot.
const D = { pegs: 10, r: 8.5, r2: 7.5, spin: [2.2, 3.0, 0.05], fly: [40, 54, 0.6], gap: [19, 6], dx: [10, 20], fray: 3, snapT: 1.7 };
const lv = (name, o) => ({ ...D, name, ...o });
export const LEVELS = [
  lv('First Breeze', { pegs: 8, r: 10, r2: 9.5, spin: [1.9, 2.1, 0.02], fly: [36, 40, 0.3], gap: [17, 4], dx: [8, 14] }),
  lv('Updraft', { pegs: 8, r: 9.5, r2: 8.5, spin: [2.0, 2.4, 0.04] }),
  lv('Zigzag', { pegs: 10, r: 9, r2: 8, spin: [2.1, 2.6, 0.05], dx: [14, 18] }),
  lv('Climb', { pegs: 12, r: 8.5, r2: 7.5, spin: [2.2, 2.8, 0.05], gap: [20, 6] }),
  lv('Crosswind', { pegs: 10, r: 9, r2: 8, wind: 22, twist: 'Wind bends your flight' }),
  lv('Gusty', { pegs: 12, wind: -26 }),
  lv('Quickspin', { pegs: 12, spin: [2.6, 3.2, 0.05] }),
  lv('Long Haul', { pegs: 16, r: 8, r2: 7, spin: [2.3, 3.0, 0.05], wind: 18 }),
  lv('Headwind', { pegs: 14, r: 8.2, r2: 7.2, wind: -32 }),
  lv('Gale Ridge', { pegs: 20, r: 8, r2: 6.6, spin: [2.4, 3.3, 0.05], wind: 30, fray: 2.5, boss: true }),
  lv('Drift Isles', { pegs: 10, r: 9, r2: 8, drift: 6, twist: 'Pegs drift side to side' }),
  lv('Sway', { pegs: 12, drift: 8 }),
  lv('Swing Low', { pegs: 12, drift: 8, spin: [2.5, 3.2, 0.05] }),
  lv('Drift and Gust', { pegs: 14, drift: 8, wind: 20 }),
  lv('Flip Pegs', { pegs: 12, flip: 0.45, twist: 'Violet pegs flip your spin' }),
  lv('Mirror', { pegs: 14, flip: 0.5, drift: 6 }),
  lv('Twister', { pegs: 14, flip: 0.5, wind: -22 }),
  lv('Tight Rings', { pegs: 16, r: 7.5, r2: 6.6, drift: 7, flip: 0.3 }),
  lv('Sky Maze', { pegs: 18, drift: 9, flip: 0.4, wind: 18 }),
  lv('Tempest', { pegs: 22, r: 7.8, r2: 6.4, drift: 9, flip: 0.4, wind: 26, fray: 2.5, boss: true }),
  lv('Snap Pegs', { pegs: 12, snap: 0.4, twist: 'Red pegs snap fast: let go quick' }),
  lv('Hot Lines', { pegs: 14, snap: 0.5, drift: 6 }),
  lv('Snap Flip', { pegs: 14, snap: 0.4, flip: 0.4 }),
  lv('Rush', { pegs: 16, snap: 0.5, wind: 22, spin: [2.5, 3.3, 0.05] }),
  lv('Storm Clouds', { pegs: 12, gap: [23, 6], clouds: 5, twist: 'Dark clouds swallow kites' }),
  lv('Thunder Run', { pegs: 14, gap: [23, 6], clouds: 7, wind: 20 }),
  lv('Squall', { pegs: 16, gap: [23, 6], clouds: 7, drift: 7 }),
  lv('Lightning', { pegs: 16, gap: [23, 6], clouds: 8, snap: 0.4, flip: 0.3 }),
  lv('Eye of the Storm', { pegs: 18, gap: [23, 6], clouds: 9, drift: 8, flip: 0.4, wind: -22 }),
  lv('Storm Crown', { pegs: 26, gap: [23, 6], r: 7.6, r2: 6.2, clouds: 10, drift: 8, flip: 0.4, snap: 0.35, wind: 26, fray: 2.4, boss: true }),
];
/** Narrowest release window (ticks) the level bot may use: 100 ms early on, 67 ms in the last world. */
export const winOf = (n) => (n <= 10 ? 6 : n <= 20 ? 5 : 4);

function levelPegs(L, n, attempt) {
  const r = rng(`latchkite:lv:${n}:${attempt}`);
  const pegs = [{ x: 50, y: 0, r: L.r }];
  for (let i = 1; i <= L.pegs; i++) {
    const p = pegs[i - 1];
    const gap = L.gap[0] + r() * L.gap[1];
    let dx = (L.dx[0] + r() * L.dx[1]) * (r() < 0.5 ? -1 : 1);
    if (p.x + dx < 16 || p.x + dx > 84) dx = -dx;
    const q = { x: Math.max(16, Math.min(84, p.x + dx)), y: p.y + gap, r: +(L.r + ((L.r2 - L.r) * i) / L.pegs).toFixed(2) };
    if (L.drift && r() < 0.75) { q.ax = L.drift * (0.6 + 0.4 * r()); q.w = 0.9 + 0.8 * r(); q.ph = r() * TAU; q.x = Math.max(14 + q.ax, Math.min(86 - q.ax, q.x)); }
    if (i < L.pegs && L.flip && r() < L.flip) q.flip = 1;
    if (i < L.pegs && L.snap && r() < L.snap) q.snap = 1;
    pegs.push(q);
  }
  const clouds = [];
  for (let k = 0, tries = 0; k < (L.clouds || 0) && tries < 400; tries++) {
    const i = 1 + Math.floor(r() * (L.pegs - 1)), a = pegs[i], b = pegs[i + 1];
    const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2, side = r() < 0.5 ? -1 : 1, off = 7 + r() * 9;
    const c = { x: mx + side * off, y: my + (r() - 0.5) * 4, r: 3 + r() * 1.8 };
    if (c.x < 8 || c.x > 92) continue;
    if (pegs.some((q) => Math.hypot(q.x - c.x, q.y - c.y) < (q.ax || 0) * 0.5 + 9 + c.r)) continue; // clear of the catch rings
    if (clouds.some((o) => Math.hypot(o.x - c.x, o.y - c.y) < o.r + c.r + 4)) continue;
    clouds.push(c); k++;
  }
  return { pegs, clouds };
}

/**
 * The bot: on each peg it waits until letting go would land on a forward peg on `win` ticks in a row, then lets go
 * on the first of them. Returns the finished sim (won or dead); sim.taps lists the ticks it let go on.
 */
export function levelBot(sim, win = 5, react = 4) {
  const s = sim;
  s.taps = [];
  const catches = (c) => { const d = c.clone(); d.step(true); for (let i = 0; i < FLY_MAX + 2; i++) { const e = d.step(false); if (e === 'latch' || e === 'win') return true; if (e === 'dead') return false; } return false; };
  while (!s.dead && !s.won && s.t < HZ * 300) {
    if (s.flying) { s.step(false); continue; }
    const look = s.clone(), ok = [];
    const lim = s.limit() - s.held;
    for (let k = 0; k < lim; k++) { ok.push(k >= react && catches(look)); look.step(false); }
    let at = -1;
    for (let k = 0; k + win <= ok.length && at < 0; k++) if (ok.slice(k, k + win).every(Boolean)) at = k;
    if (at < 0) { while (!s.dead) s.step(false); break; }
    for (let k = 0; k < at; k++) s.step(false);
    s.taps.push(s.t);
    s.step(true);
  }
  return s;
}

const cache = {};
/** The level config (pegs, clouds, physics, par ticks). Deterministic; the first course the bot clears is kept. */
export function levelCfg(n) {
  if (cache[n]) return cache[n];
  const L = LEVELS[n - 1];
  for (let attempt = 0; attempt < 60; attempt++) {
    const { pegs, clouds } = levelPegs(L, n, attempt);
    const cfg = { ...L, n, pegs, clouds, fray: Math.round(L.fray * HZ), snapT: Math.round(L.snapT * HZ) };
    const b = levelBot(makeSim(`L${n}`, cfg), winOf(n));
    if (!b.won) continue;
    cfg.par = b.t; cfg.attempt = attempt;
    cfg.stars = [9999, +(b.t / HZ * 1.75 + 1.5).toFixed(1), +(b.t / HZ * 1.25 + 0.5).toFixed(1)];
    return (cache[n] = cfg);
  }
  throw new Error(`latchkite level ${n}: no fair course`);
}
/** A fresh run of level n. */
export const makeLevelSim = (n) => makeSim(`L${n}`, levelCfg(n));
/** Seconds (one decimal) of a finished level run. */
export const secs = (ticks) => Math.round((ticks / HZ) * 10) / 10;
/** Stars for a cleared level time in seconds: 1 for clearing, 2 and 3 for beating the level's time marks. */
export function levelStars(n, sec) {
  const t = levelCfg(n).stars;
  return sec <= t[2] ? 3 : sec <= t[1] ? 2 : 1;
}