Spaces:
Running
Running
Download core.js from CyberMax-tools/whirlane: direct link, hf CLI and curl.
- Browser
- Download file 15.9 kB
-
https://huggingface.co/spaces/CyberMax-tools/whirlane/resolve/main/core.js
- Command line
-
hf download hf://spaces/CyberMax-tools/whirlane/core.js
-
curl -L -o core.js https://huggingface.co/spaces/CyberMax-tools/whirlane/resolve/main/core.js
15.9 kB
| // Whirlane core: deterministic simulation (no DOM). The browser runs it to play; the CyberMax toys Worker runs the | |
| // SAME file to replay a submitted run (seed + tap ticks) and verify the score before it goes on the leaderboard. | |
| // Only + - * / and comparisons on numbers (no Math.sin/cos), so every JS engine gives the same result. | |
| // (c) 2026 CyberMax. All rights reserved. | |
| export const HZ = 60; // fixed ticks per second | |
| export const SWITCH = 9; // ticks a lane hop takes; mid-hop the spark touches BOTH lanes | |
| export const MAX_TICKS = HZ * 600; // a run can't last more than 10 minutes | |
| export const PAD = 0.05; // spark size in radians | |
| const TAU = 6.283185307179586; | |
| 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; | |
| }; | |
| } | |
| /** Angular speed (radians per tick) after t ticks: starts calm, ramps up, caps. */ | |
| export function speed(t) { | |
| const w = 0.034 + t * 0.0000105; | |
| return w < 0.072 ? w : 0.072; | |
| } | |
| /** | |
| * The course: blades on the inner (0) or outer (1) lane, in absolute angle along the orbit. Generated lazily from the | |
| * seed, so the course is endless and identical for everyone with the same seed. A blade on the other lane always | |
| * leaves a gap long enough to hop at top speed, so every course can be survived with perfect timing. | |
| */ | |
| export function course(seed) { | |
| const r = rng(`whirlane:${seed}`); | |
| const list = []; | |
| let pos = 2.4, lane = r() < 0.5 ? 0 : 1, n = 0; | |
| const hop = 0.072 * SWITCH + 2 * PAD + 0.12; // radians the spark needs to hop at top speed, with a margin | |
| function more(upTo) { | |
| while (pos < upTo) { | |
| const d = n < 60 ? n / 60 : 1; // difficulty 0..1 over the first 60 blades | |
| const len = 0.34 + r() * (0.28 + 0.22 * d); // blade length grows a little | |
| const nextLane = r() < 0.62 + 0.2 * d ? 1 - lane : lane; // more forced hops as it gets harder | |
| let gap = (1.55 - 0.75 * d) * (0.8 + 0.4 * r()); | |
| if (nextLane !== lane && gap < hop) gap = hop; | |
| const b = { i: n, lane: nextLane, a0: pos, a1: pos + len }; | |
| if (r() < 0.12 * d && n > 8) { b.twin = true; } // twin blade: a short one on the other lane right after | |
| list.push(b); | |
| if (b.twin) list.push({ i: n, lane: 1 - nextLane, a0: b.a1 + hop, a1: b.a1 + hop + 0.22, small: true }); | |
| pos = (b.twin ? b.a1 + hop + 0.22 : b.a1) + gap; | |
| lane = b.twin ? 1 - nextLane : nextLane; | |
| n++; | |
| } | |
| } | |
| return { list, more }; | |
| } | |
| /** A running game. step(tap) advances one tick; returns 'hit' when the spark touches a blade. */ | |
| export function makeSim(seed) { | |
| const c = course(seed); | |
| const s = { t: 0, a: 0, lane: 1, to: 1, hop: 0, score: 0, dead: false, next: 0, course: c.list }; | |
| c.more(TAU * 2); | |
| s.step = (tap) => { | |
| if (s.dead) return 'dead'; | |
| if (tap && s.hop === 0) { s.to = 1 - s.lane; s.hop = SWITCH; } | |
| if (s.hop > 0) { s.hop--; if (s.hop === 0) s.lane = s.to; } | |
| s.a += speed(s.t); | |
| s.t++; | |
| if (c.list.length === 0 || c.list[c.list.length - 1].a0 < s.a + TAU * 1.5) c.more(s.a + TAU * 2); | |
| const hopping = s.hop > 0; | |
| let ev = ''; | |
| for (let k = s.next; k < c.list.length; k++) { | |
| const b = c.list[k]; | |
| if (b.a0 > s.a + PAD) break; | |
| if (b.a1 < s.a - PAD) { if (k === s.next) { s.next++; if (!b.small) { s.score++; ev = 'pass'; } } continue; } | |
| if (hopping || b.lane === s.lane) { s.dead = true; return 'hit'; } | |
| } | |
| if (s.t >= MAX_TICKS) { s.dead = true; return 'timeout'; } | |
| return ev; | |
| }; | |
| return s; | |
| } | |
| /** | |
| * Replay a run: taps = increasing tick numbers at which the player tapped. Returns { score, ticks, ok }. | |
| * ok is false when the tap list is malformed (not increasing, out of range, too long). | |
| */ | |
| export function simulate(seed, taps) { | |
| if (!Array.isArray(taps) || taps.length > 20000) return { score: 0, ticks: 0, ok: false }; | |
| for (let i = 0; i < taps.length; i++) { | |
| const v = taps[i]; | |
| if (!Number.isInteger(v) || v < 0 || v >= MAX_TICKS || (i && v <= taps[i - 1])) return { score: 0, ticks: 0, ok: false }; | |
| } | |
| const s = makeSim(seed); | |
| let k = 0; | |
| while (!s.dead) { | |
| const tap = k < taps.length && taps[k] === s.t; | |
| if (tap) k++; | |
| s.step(tap); | |
| } | |
| return { score: s.score, ticks: s.t, ok: true }; | |
| } | |
| /** Wordle-style bar: one block per 5 blades (max 10), colour by tier. */ | |
| export function bar(score) { | |
| const blocks = Math.min(10, Math.ceil(score / 5)); | |
| const tile = score >= 40 ? 'πͺ' : score >= 25 ? 'π§' : score >= 10 ? 'π¨' : 'π©'; | |
| return tile.repeat(blocks) + 'β¬'.repeat(10 - blocks); | |
| } | |
| // ------------------------------------------------------------------ levels (separate from the daily: own seeds, own runner) | |
| // The daily course, makeSim() and simulate() above stay exactly as they were (the toys Worker replays them). Levels use | |
| // their own seed space (`whirlane:lv:<n>`), a finite course with a finish line, shields and gems. | |
| export const SHIELDS = 2; // hits a normal level forgives (each hit costs a star); challenge levels have none | |
| export const FLIP = 2.3; // radians ahead of the spark where a purple blade jumps to the other orbit | |
| export const RUSH = 1.35; // speed factor on gold rush arcs | |
| export const FOG = 1.75; // how far ahead blades show on night levels (drawing only; the sim is the same) | |
| export const PULSE = 80; // ticks per grow-and-shrink cycle of a pulse blade | |
| export const STAR_HITS = [2, 1, 0]; // stars by hits taken (lower is better): 0 hits 3 stars, 1 hit 2, 2 hits 1 | |
| const LV = (name, goal, w0, cap, gap, swap, o = {}) => ({ name, goal, w0, cap, gap, swap, gems: Math.ceil(goal * 0.4), ...o }); | |
| /** 30 hand-tuned levels: goal = blades to pass, w0..cap = spark speed (radians per tick), gap = room between blades, | |
| * swap = chance the next blade sits on the other orbit; twin/rush/flip/pulse = share of blades with that twist. */ | |
| export const LEVELS = [ | |
| LV('First spin', 10, 0.030, 0.034, 1.8, 0.5), LV('Two orbits', 12, 0.032, 0.037, 1.7, 0.6), LV('Quick hops', 14, 0.034, 0.040, 1.6, 0.65), | |
| LV('Spin up', 16, 0.036, 0.044, 1.5, 0.7), LV('Orbit run', 18, 0.038, 0.047, 1.45, 0.72), | |
| LV('Twin blades', 16, 0.037, 0.045, 1.6, 0.65, { twin: 0.3, twist: 'Twin blades: a short one waits on the other orbit' }), | |
| LV('Double edge', 18, 0.039, 0.048, 1.55, 0.7, { twin: 0.3 }), LV('Sharp turns', 20, 0.041, 0.050, 1.5, 0.72, { twin: 0.35 }), | |
| LV('Blade storm', 22, 0.043, 0.053, 1.45, 0.75, { twin: 0.35 }), | |
| LV('Twin trial', 30, 0.044, 0.055, 1.45, 0.75, { twin: 0.4, boss: true }), | |
| LV('Gold rush', 18, 0.040, 0.048, 1.6, 0.7, { twin: 0.2, rush: 0.4, twist: 'Gold arcs: the spark rushes faster' }), | |
| LV('Fast lane', 20, 0.041, 0.050, 1.55, 0.72, { twin: 0.25, rush: 0.45 }), LV('Slipstream', 22, 0.043, 0.052, 1.5, 0.74, { twin: 0.25, rush: 0.5 }), | |
| LV('Overdrive', 24, 0.045, 0.054, 1.45, 0.75, { twin: 0.3, rush: 0.5 }), LV('Afterburn', 26, 0.046, 0.056, 1.45, 0.76, { twin: 0.3, rush: 0.55 }), | |
| LV('Flip side', 18, 0.041, 0.049, 1.6, 0.7, { flip: 0.25, twist: 'Purple blades jump orbit as you near' }), | |
| LV('Switchback', 20, 0.042, 0.050, 1.55, 0.72, { flip: 0.3, twin: 0.2 }), LV('Mirror maze', 22, 0.043, 0.052, 1.5, 0.74, { flip: 0.3, rush: 0.35 }), | |
| LV('Trickster', 24, 0.045, 0.054, 1.45, 0.75, { flip: 0.35, twin: 0.25, rush: 0.35 }), | |
| LV('Flip trial', 34, 0.046, 0.056, 1.45, 0.76, { flip: 0.35, twin: 0.3, rush: 0.4, boss: true }), | |
| LV('Heartbeat', 20, 0.043, 0.052, 1.55, 0.72, { pulse: 0.5, twist: 'Pulse blades grow and shrink' }), | |
| LV('Breathing ring', 22, 0.045, 0.054, 1.5, 0.74, { pulse: 0.5, twin: 0.25 }), LV('Tidal', 24, 0.046, 0.056, 1.48, 0.75, { pulse: 0.45, flip: 0.25 }), | |
| LV('Pulse rush', 26, 0.047, 0.058, 1.45, 0.76, { pulse: 0.5, rush: 0.4 }), LV('Thrum', 28, 0.048, 0.060, 1.42, 0.77, { pulse: 0.4, twin: 0.3, flip: 0.25 }), | |
| LV('Night orbit', 22, 0.046, 0.056, 1.5, 0.74, { fog: true, twin: 0.25, twist: 'Night: blades appear late' }), | |
| LV('Blackout', 24, 0.047, 0.058, 1.48, 0.75, { fog: true, flip: 0.25, twin: 0.2 }), LV('Eclipse', 26, 0.048, 0.060, 1.45, 0.76, { fog: true, pulse: 0.4, rush: 0.35 }), | |
| LV('Dark star', 28, 0.049, 0.062, 1.42, 0.77, { fog: true, twin: 0.3, flip: 0.3, pulse: 0.3 }), | |
| LV('Whirl crown', 40, 0.050, 0.064, 1.4, 0.78, { fog: true, twin: 0.3, flip: 0.3, pulse: 0.35, rush: 0.4, boss: true }), | |
| ]; | |
| /** Course of level `num` (1-based): a finite list of blades, gems, gold rush arcs and the finish line. */ | |
| export function levelCourse(num, L = LEVELS[num - 1], seed = `whirlane:lv:${num}`) { | |
| const r = rng(seed); | |
| const on = (k, i) => !!L[k] && i >= ((L.unlock && L.unlock[k]) || 0); // Gauntlet: each twist joins after N blades | |
| const vmax = L.cap * (L.rush ? RUSH : 1); | |
| const hop = vmax * SWITCH + 2 * PAD + 0.12; // room to hop at this level's top speed, with a margin | |
| const list = [], gems = [], zones = []; | |
| let prevEnd = 0, prevLane = 1, rushLeft = 0; | |
| for (let i = 0; i < L.goal; i++) { | |
| const d = i / L.goal; | |
| const len = 0.34 + r() * (0.18 + 0.12 * d); | |
| let ln = i === 0 ? (num === 1 ? 1 : r() < 0.5 ? 0 : 1) : r() < L.swap ? 1 - prevLane : prevLane; | |
| let gap = L.gap * (0.85 + 0.3 * r()) * (1 - 0.15 * d); | |
| if (i > 0 && ln !== prevLane && gap < hop) gap = hop; | |
| const a0 = i === 0 ? 2.2 : prevEnd + gap; | |
| // a gem in the middle of a roomy gap: always reachable (room to hop there and away again) | |
| // gems: a roomy gap gets one in the middle on either orbit (hop there and away again); a tighter gap gets one just | |
| // after the last blade on the orbit you are already on (hop too early and you miss it). Every gem is reachable. | |
| if (i > 0 && gems.length < L.gems && r() < 0.6) { | |
| if (a0 - prevEnd >= 2 * hop + 0.1) gems.push({ a: (prevEnd + a0) / 2, lane: r() < 0.5 ? 0 : 1 }); | |
| else if (a0 - prevEnd >= hop + 0.35) gems.push({ a: prevEnd + 0.2, lane: 1 - prevLane }); | |
| } | |
| const b = { i, lane: ln, a0, a1: a0 + len }; | |
| const kind = i > 1 ? r() : 1; | |
| const fl = on('flip', i) ? L.flip : 0, pu = on('pulse', i) ? L.pulse : 0; | |
| if (fl && kind < fl) { b.flip = true; b.lane = 1 - ln; } // starts on the other orbit, jumps to `ln` at FLIP | |
| else if (pu && kind < fl + pu) { b.pulse = true; b.ph = Math.floor(r() * PULSE); } | |
| if (on('rush', i) && rushLeft === 0 && i > 1 && r() < L.rush * 0.4) { rushLeft = 3; zones.push({ a0: a0 - 0.8, a1: 0 }); } | |
| if (rushLeft > 0 && --rushLeft === 0) zones[zones.length - 1].a1 = b.a1 + 0.4; | |
| list.push(b); | |
| prevEnd = b.a1; prevLane = ln; | |
| if (on('twin', i) && i > 1 && !b.flip && r() < L.twin) { | |
| list.push({ i, lane: 1 - ln, a0: prevEnd + hop, a1: prevEnd + hop + 0.22, small: true }); | |
| prevEnd += hop + 0.22; prevLane = 1 - ln; | |
| } | |
| } | |
| if (zones.length && !zones[zones.length - 1].a1) zones[zones.length - 1].a1 = prevEnd + 0.4; | |
| return { list, gems, zones, finish: prevEnd + 0.8, hop }; | |
| } | |
| const tri = (u) => (u < 0.5 ? 2 * u : 2 - 2 * u); | |
| /** | |
| * A level run. Same controls as the daily; the run ends at the finish line ('win') or on a hit with no shield left. | |
| * opts.shields overrides the level's shields (the tests use 0 to prove a flawless 3-star run exists). | |
| */ | |
| export function makeLevelSim(num, opts = {}) { | |
| const L = opts.def || LEVELS[num - 1], c = levelCourse(num, L, opts.seed); | |
| const ramp = (L.cap - L.w0) / (L.goal * 30); | |
| const s = { level: num, t: 0, a: 0, lane: 1, to: 1, hop: 0, score: 0, dead: false, won: false, next: 0, course: c.list, gems: c.gems, zones: c.zones, | |
| finish: c.finish, goal: L.goal, got: 0, hits: 0, shields: opts.shields ?? (L.boss || L.endless ? 0 : SHIELDS), safe: 0, fog: !!L.fog, rushing: false }; | |
| let gi = 0, zi = 0; | |
| /** Current length of a blade (pulse blades breathe between 40% and 100%). */ | |
| s.len = (b) => (b.pulse ? (b.a1 - b.a0) * (0.4 + 0.6 * tri(((s.t + b.ph) % PULSE) / PULSE)) : b.a1 - b.a0); | |
| s.step = (tap) => { | |
| if (s.dead || s.won) return 'dead'; | |
| if (tap && s.hop === 0) { s.to = 1 - s.lane; s.hop = SWITCH; } | |
| if (s.hop > 0) { s.hop--; if (s.hop === 0) s.lane = s.to; } | |
| while (zi < c.zones.length && c.zones[zi].a1 <= s.a) zi++; | |
| s.rushing = zi < c.zones.length && c.zones[zi].a0 <= s.a; | |
| const w = L.w0 + s.t * ramp; | |
| const base = w < L.cap ? w : L.cap; | |
| s.a += s.rushing ? base * RUSH : base; | |
| s.t++; | |
| if (s.safe > 0) s.safe--; | |
| const hopping = s.hop > 0; | |
| let ev = ''; | |
| for (let k = s.next; k < c.list.length; k++) { | |
| const b = c.list[k]; | |
| if (b.a0 > s.a + FLIP) break; | |
| if (b.flip && !b.flipped) { b.lane = 1 - b.lane; b.flipped = true; ev = 'flip'; } | |
| } | |
| while (gi < c.gems.length && c.gems[gi].a < s.a - 0.12) gi++; | |
| for (let k = gi; k < c.gems.length && c.gems[k].a <= s.a + 0.12; k++) { | |
| const gm = c.gems[k]; | |
| if (!gm.got && !hopping && gm.lane === s.lane) { gm.got = true; s.got++; ev = 'gem'; } | |
| } | |
| for (let k = s.next; k < c.list.length; k++) { | |
| const b = c.list[k]; | |
| if (b.a0 > s.a + PAD) break; | |
| if (b.a1 < s.a - PAD) { if (k === s.next) { s.next++; if (!b.small) { s.score++; if (ev !== 'gem') ev = 'pass'; } } continue; } | |
| if (b.broken || s.safe > 0 || b.a0 + s.len(b) < s.a - PAD) continue; | |
| if (hopping || b.lane === s.lane) { | |
| s.hits++; | |
| if (s.shields > 0) { s.shields--; b.broken = true; s.safe = 30; ev = 'shield'; continue; } | |
| s.dead = true; return 'hit'; | |
| } | |
| } | |
| if (s.a >= s.finish) { s.won = true; return 'win'; } | |
| if (s.t >= MAX_TICKS) { s.dead = true; return 'timeout'; } | |
| return ev; | |
| }; | |
| return s; | |
| } | |
| /** Replay a level run (taps = increasing tick numbers). Returns { won, hits, gems, score, ticks, ok }. */ | |
| export function simulateLevel(num, taps, opts) { | |
| if (!(num >= 1 && num <= LEVELS.length) || !Array.isArray(taps)) return { won: false, hits: 0, gems: 0, score: 0, ticks: 0, ok: false }; | |
| const s = makeLevelSim(num, opts); | |
| let k = 0; | |
| while (!s.dead && !s.won) { | |
| const tap = k < taps.length && taps[k] === s.t; | |
| if (tap) k++; | |
| s.step(tap); | |
| } | |
| return { won: s.won, hits: s.hits, gems: s.got, score: s.score, ticks: s.t, ok: true }; | |
| } | |
| /** Stars for a cleared level: 3 with no hits, 2 with one, 1 with two (0 when not cleared). */ | |
| export function levelStars(won, hits) { | |
| if (!won) return 0; | |
| let st = 0; | |
| for (const v of STAR_HITS) if (hits <= v) st++; | |
| return st; | |
| } | |
| /** Share line for a level: stars and gems. */ | |
| export function levelBar(stars, gems, total) { | |
| return `${'β'.repeat(stars)}${'β'.repeat(3 - stars)} π ${gems}/${total}`; | |
| } | |
| // ------------------------------------------------------------------ Gauntlet (full-game mode, games lane GM 9 Oct 2026) | |
| /** Gauntlet: one long run on a fresh course every time. No shields; every twist joins in turn (twins after 10 blades, | |
| * gold rush after 25, purple flips after 45, pulse blades after 70) and the speed keeps rising. Medals at 25/50/100/150 | |
| * blades; reaching 300 is the crown. Survivable by design: every gap still leaves room to hop at the top speed. */ | |
| export const GAUNTLET = { name: 'Gauntlet', goal: 300, w0: 0.036, cap: 0.068, gap: 1.5, swap: 0.76, gems: 120, endless: true, | |
| twin: 0.3, rush: 0.4, flip: 0.3, pulse: 0.35, unlock: { twin: 10, rush: 25, flip: 45, pulse: 70 } }; | |
| export const MEDALS = [[25, 'π₯', 'Bronze'], [50, 'π₯', 'Silver'], [100, 'π₯', 'Gold'], [150, 'π ', 'Diamond'], [300, 'π', 'Crown']]; | |
| /** The best medal for a blade count: [blades, emoji, name] or null. */ | |
| export const medalFor = (n) => MEDALS.filter((m) => n >= m[0]).pop() || null; | |
| export function makeGauntletSim(seed) { return makeLevelSim(0, { def: GAUNTLET, seed: `whirlane:gauntlet:${seed}` }); } | |