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