Spaces:
Running
Running
Download core.js from CyberMax-tools/latchkite: direct link, hf CLI and curl.
- Browser
- Download file 13 kB
-
https://huggingface.co/spaces/CyberMax-tools/latchkite/resolve/main/core.js
- Command line
-
hf download hf://spaces/CyberMax-tools/latchkite/core.js
-
curl -L -o core.js https://huggingface.co/spaces/CyberMax-tools/latchkite/resolve/main/core.js
13 kB
| // 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; | |
| } | |