Spaces:
Running
Running
Download latchkite/game.js from CyberMax-tools/cybermax: direct link, hf CLI and curl.
- Browser
- Download file 34.9 kB
-
https://huggingface.co/spaces/CyberMax-tools/cybermax/resolve/main/latchkite/game.js
- Command line
-
hf download hf://spaces/CyberMax-tools/cybermax/latchkite/game.js
-
curl -L -o game.js https://huggingface.co/spaces/CyberMax-tools/cybermax/resolve/main/latchkite/game.js
34.9 kB
| // Latchkite: game screen (fixed-tick loop, canvas, 30 levels, daily/practice/archive, share, Fun Pass, Skin Pack). (c) 2026 CyberMax. | |
| import { makeSim, bar, HZ, spin, LEVELS, levelCfg, makeLevelSim, levelBot, levelStars, secs, winOf } from './core.js'; | |
| import { dayNumber, dateOf, count, store, toast, share, intents, download, canvasBlob, pwa, themeToggle } from './cmx.js'; | |
| import * as lv from './levels.js'; | |
| import * as skins from './skins.js'; | |
| import * as pass from './funpass.js'; | |
| const GAME = 'latchkite'; | |
| const PATH = '/games/latchkite/'; | |
| const GUMROAD = 'https://cybermaxtools.gumroad.com/l/cybermax-latchkite-skins'; | |
| const $ = (id) => document.getElementById(id); | |
| const cv = $('c'); | |
| const g = cv.getContext('2d'); | |
| // tier: free | pass (CyberMax Fun Pass) | pack (Latchkite Skin Pack, one-time) | |
| const SKINS = { | |
| dawn: { name: 'Dawn', tier: 'free', top: '#1B2350', bot: '#E9806B', peg: '#FFF4E0', ring: 'rgba(255,244,224,.28)', line: '#FFF4E0', k1: '#FFCF4A', k2: '#FF5E5B', trail: 'rgba(255,207,74,.55)', text: '#FFFFFF' }, | |
| dusk: { name: 'Dusk', tier: 'pass', top: '#0D0B26', bot: '#5B2A86', peg: '#E8E1FF', ring: 'rgba(232,225,255,.25)', line: '#E8E1FF', k1: '#7CF5FF', k2: '#B26BFF', trail: 'rgba(124,245,255,.5)', text: '#FFFFFF' }, | |
| meadow: { name: 'Meadow', tier: 'pass', top: '#7FD3FF', bot: '#E7F7C9', peg: '#2E4A1F', ring: 'rgba(46,74,31,.25)', line: '#2E4A1F', k1: '#FF6A3D', k2: '#2E86DE', trail: 'rgba(255,106,61,.45)', text: '#11141B' }, | |
| koi: { name: 'Koi', tier: 'pack', top: '#0F3B4C', bot: '#2E8C8C', peg: '#FDF6EC', ring: 'rgba(253,246,236,.25)', line: '#FDF6EC', k1: '#FF7B2E', k2: '#FFFFFF', trail: 'rgba(255,123,46,.5)', text: '#FFFFFF', tail: 'fins' }, | |
| aurora: { name: 'Aurora', tier: 'pack', top: '#020814', bot: '#0E4D3C', peg: '#C9FFE9', ring: 'rgba(116,255,196,.28)', line: '#9FFFD8', k1: '#74FFC4', k2: '#8A7CFF', trail: 'rgba(116,255,196,.6)', text: '#E8FFF6', tail: 'ribbon' }, | |
| ember: { name: 'Ember', tier: 'pack', top: '#1A0500', bot: '#7A1E00', peg: '#FFD9A8', ring: 'rgba(255,170,80,.3)', line: '#FFB067', k1: '#FFD23F', k2: '#FF3D00', trail: 'rgba(255,90,0,.65)', text: '#FFF1E0', tail: 'sparks' }, | |
| paper: { name: 'Paper', tier: 'pack', top: '#F4EFE6', bot: '#E6DCCB', peg: '#2B2B2B', ring: 'rgba(43,43,43,.2)', line: '#2B2B2B', k1: '#D7263D', k2: '#F4EFE6', trail: 'rgba(43,43,43,.25)', text: '#2B2B2B', tail: 'bows' }, | |
| neon: { name: 'Neon', tier: 'pack', top: '#07001A', bot: '#1F0B3A', peg: '#FF2E97', ring: 'rgba(255,46,151,.35)', line: '#00F0FF', k1: '#00F0FF', k2: '#FF2E97', trail: 'rgba(0,240,255,.6)', text: '#FFFFFF', tail: 'ribbon' }, | |
| gold: { name: 'Gold', tier: 'pack', top: '#14110A', bot: '#3D3218', peg: '#FFE7A3', ring: 'rgba(255,215,0,.3)', line: '#FFE7A3', k1: '#FFD700', k2: '#B8860B', trail: 'rgba(255,215,0,.55)', text: '#FFF6D6', tail: 'sparks' }, | |
| }; | |
| const PACK = Object.values(SKINS).filter((s) => s.tier === 'pack'); | |
| const todayIso = new Date().toISOString().slice(0, 10); // UTC day: the same course for everyone on Earth | |
| const todayNum = dayNumber(todayIso); | |
| const FLIP = '#B26BFF', SNAP = '#FF4D4D', GOLD = '#FFD23F'; | |
| skins.capture({ game: GAME, name: 'Latchkite', skins: PACK.length, price: '$1.99', gumroad: GUMROAD, preview: PACK.map((s) => s.k1), | |
| perks: [`${PACK.length} kites with their own sky, line and tail: ${PACK.map((s) => s.name).join(', ')}`, 'Every skin also on your share card'] }); | |
| pass.init({ game: GAME, name: 'Latchkite', perks: ['Levels 21-30: snap pegs, storm clouds and the Storm Crown', 'Every past daily course in the archive', 'Dusk and Meadow skies'] }); | |
| const canUse = (s) => s.tier === 'free' || (s.tier === 'pass' && pass.isPro()) || (s.tier === 'pack' && skins.owned()); | |
| let skin = SKINS[store('latchkite:skin')] && canUse(SKINS[store('latchkite:skin')]) ? store('latchkite:skin') : 'dawn'; | |
| let muted = !!store('latchkite:mute'); | |
| const sfx = lv.sfx(() => muted); | |
| lv.css(); // kit styles (tutorial hand, banner, stars) before the first frame | |
| const prog = lv.progress(GAME, { total: LEVELS.length, free: 20, isPro: () => pass.isPro(), lower: true }); | |
| const chal = lv.readChallenge(); // ?seed=L7&score=12.3 (seconds) or ?seed=2026-10-08&score=14 (catches): "beat my score" | |
| const MAP = LEVELS.map((l, i) => ({ n: i + 1, name: l.name, twist: l.twist, boss: l.boss })); | |
| const WORLDS = ['Dawn Bluffs', 'Drift Isles', 'Storm Crown (Fun Pass)']; | |
| let mode = null; // { kind: 'daily', date } | { kind: 'practice', seed } | { kind: 'level', level, challenge? } | |
| let sim, state = 'ready', pending = false, acc = 0, last = 0, deadAt = 0, flash = 0, shakeAmt = 0, lastScore = 0, camY = -40, pop = 0; | |
| let trail = [], result = null, auto = null, speed = 1, winGlow = 0; | |
| const install = pwa(GAME, $('install')); | |
| themeToggle($('theme')); | |
| function setMute(m) { muted = m; store('latchkite:mute', muted); $('mute').textContent = muted ? '🔇' : '🔊'; } | |
| $('mute').textContent = muted ? '🔇' : '🔊'; | |
| $('mute').onclick = () => setMute(!muted); | |
| // ------------------------------------------------------------------ modes + stats | |
| const isLv = () => mode.kind === 'level'; | |
| const seedOf = (m) => (m.kind === 'practice' ? m.seed : m.date); | |
| const dayKey = (m) => `latchkite:day:${m.date}`; | |
| const dayStats = (m) => store(dayKey(m)) || { best: 0, tries: 0 }; | |
| /** Daily streak: days in a row with at least one try on that day's course (kept in the existing per-day stats). */ | |
| function streakNow() { | |
| let n = todayNum, k = 0; | |
| if (!(dayStats({ date: todayIso }).tries > 0)) n--; // today not played yet: the streak is still alive from yesterday | |
| while (n >= 1 && dayStats({ date: dateOf(n) }).tries > 0) { k++; n--; } | |
| return k; | |
| } | |
| function returning() { | |
| try { for (let i = 0; i < localStorage.length; i++) if (/^latchkite:(day:|practice-best)/.test(localStorage.key(i))) return true; } catch { /* */ } | |
| return prog.cleared() > 0; | |
| } | |
| function renderModes() { | |
| if (!mode) return; | |
| const el = $('modes'); el.innerHTML = ''; | |
| const st = streakNow(); | |
| const items = [ | |
| { t: `Daily #${todayNum}${st ? ` 🔥${st}` : ''}`, on: mode.kind === 'daily' && mode.date === todayIso, go: () => setMode({ kind: 'daily', date: todayIso }) }, | |
| { t: `Levels ${prog.cleared()}/${LEVELS.length} ⭐${prog.totalStars()}`, on: isLv(), go: showMap }, | |
| { t: 'Practice', on: mode.kind === 'practice', go: () => setMode({ kind: 'practice', seed: `p${Date.now()}` }) }, | |
| { t: 'Archive', on: mode.kind === 'daily' && mode.date !== todayIso, lock: !pass.isPro(), go: archive }, | |
| ]; | |
| for (const it of items) { | |
| const b = document.createElement('button'); | |
| b.className = 'chip'; b.type = 'button'; b.setAttribute('aria-pressed', String(it.on)); | |
| b.innerHTML = `${it.t}${it.lock ? ' <span class="lock">🔒</span>' : ''}`; | |
| b.onclick = it.go; el.appendChild(b); | |
| } | |
| } | |
| function archive() { | |
| if (!pass.isPro()) { pass.open('archive'); return; } | |
| const n = todayNum - 1; | |
| if (n < 1) { toast('The archive fills up day by day; the first archived course arrives tomorrow.', 4000); return; } | |
| const k = Number(prompt(`Play a past daily course: enter a number from 1 to ${n}`, String(n))); | |
| if (k >= 1 && k <= n) setMode({ kind: 'daily', date: dateOf(k) }); | |
| } | |
| function showMap() { | |
| const el = $('levels'); | |
| if (!el.hidden) { el.hidden = true; return; } | |
| lv.renderMap($('lvmap'), prog, MAP, { | |
| current: isLv() ? mode.level : 0, worlds: WORLDS, | |
| onPick: (k) => { setMode({ kind: 'level', level: k }); cv.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, | |
| onLocked: (k, why) => (why === 'pass' ? pass.open('level') : toast(`Clear level ${prog.reached()} to open level ${k}.`)), | |
| }); | |
| el.hidden = false; | |
| el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| } | |
| /** Switch mode. Returns false (and opens the right sheet or toast) when a level is locked. */ | |
| function setMode(m, quiet = false) { | |
| if (m.kind === 'level') { | |
| const lk = prog.lock(m.level, { challenge: !!m.challenge }); | |
| if (lk) { | |
| if (!quiet) { if (lk === 'pass') pass.open('level'); else toast(`Clear level ${prog.reached()} first.`); } | |
| else if (lk === 'pass') pass.open('level'); | |
| return false; | |
| } | |
| prog.open(m.level); | |
| } | |
| mode = m; | |
| $('num').textContent = m.kind === 'practice' ? 'practice' : m.kind === 'level' ? `Level ${m.level}` : `#${dayNumber(m.date)}`; | |
| $('over').hidden = true; $('res').hidden = true; $('levels').hidden = true; | |
| const u = new URL(location.href); | |
| if (!chal || !sameChal()) { u.search = m.kind === 'level' ? `?lv=${m.level}` : m.kind === 'daily' && m.date !== todayIso ? `?d=${m.date}` : ''; history.replaceState(null, '', u.pathname + u.search); } | |
| renderModes(); reset(); hud(); goal(); banner(); | |
| if (isLv()) { | |
| const L = LEVELS[m.level - 1]; | |
| if (L.boss) sfx.boss(); | |
| if (m.level === 1) setTimeout(() => lv.tutorial(GAME, cv, { fx: 0.5, fy: 0.62 }), 250); | |
| } | |
| return true; | |
| } | |
| function hud() { | |
| if (isLv()) { | |
| const b = prog.best(mode.level); | |
| $('stat').innerHTML = `Level ${mode.level}: ${LEVELS[mode.level - 1].name} · best <b id="best">${b === undefined ? '–' : `${b} s`}</b> · ${lv.starText(prog.stars(mode.level))}`; | |
| return; | |
| } | |
| const s = mode.kind === 'daily' ? dayStats(mode) : { best: store('latchkite:practice-best') || 0, tries: 0 }; | |
| const st = mode.kind === 'daily' && mode.date === todayIso ? streakNow() : 0; | |
| $('stat').innerHTML = `${mode.kind === 'daily' ? 'Today\'s' : 'Practice'} best <b id="best">${s.best}</b> · tries <b id="tries">${s.tries || 0}</b>${st ? ` · 🔥 <b>${st}</b>-day streak` : ''}`; | |
| } | |
| /** The goal line above the canvas: what this run asks of you, in one glance. */ | |
| function goal() { | |
| const bits = []; | |
| if (isLv()) { | |
| const L = LEVELS[mode.level - 1], c = levelCfg(mode.level); | |
| if (L.boss) bits.push('👑 Challenge level'); | |
| bits.push(`🎯 Catch <b>${L.pegs}</b> pegs to the 🚩 flag`); | |
| bits.push(`⭐⭐⭐ under <b>${c.stars[2]} s</b>`); | |
| if (L.twist) bits.push(`<span class="new-twist">NEW: ${L.twist}</span>`); | |
| } else { | |
| bits.push(mode.kind === 'daily' ? '🎯 Catch as many pegs as you can on today\'s course' : '🎯 Endless practice course'); | |
| const best = mode.kind === 'daily' ? dayStats(mode).best : 0; | |
| if (best) bits.push(`your best <b>${best}</b>`); | |
| } | |
| $('goal').innerHTML = bits.join(' · '); | |
| } | |
| function sameChal() { | |
| return !!(chal && mode && ((chal.level && isLv() && mode.level === chal.level) || (chal.date && mode.kind === 'daily' && mode.date === chal.date))); | |
| } | |
| /** "Beat my score" banner when the page was opened from a friend's link for this same level or daily. */ | |
| function banner() { | |
| const el = $('challenge'); | |
| const on = sameChal() && chal.score !== null; | |
| el.hidden = !on; | |
| if (on) el.innerHTML = `<span>🎯 A friend ${isLv() ? `cleared level ${chal.level} in <b>${chal.score} s</b>` : `caught <b>${chal.score}</b> pegs on daily #${dayNumber(chal.date)}`}. Beat it!</span>`; | |
| } | |
| // ------------------------------------------------------------------ loop | |
| function reset() { | |
| sim = isLv() ? makeLevelSim(mode.level) : makeSim(seedOf(mode)); | |
| state = 'ready'; pending = false; acc = 0; trail = []; flash = 0; shakeAmt = 0; camY = -40; result = null; winGlow = 0; | |
| } | |
| function start() { | |
| if (mode.kind === 'practice') mode.seed = `p${Date.now()}`; | |
| reset(); state = 'run'; last = performance.now(); | |
| count(GAME, 'play', isLv() ? `level-${mode.level}` : mode.kind === 'daily' ? (mode.date === todayIso ? 'daily' : 'archive') : 'practice'); | |
| $('over').hidden = true; $('res').hidden = true; | |
| } | |
| function input() { | |
| if (state === 'ready') { start(); return; } | |
| if (state === 'run') { pending = true; return; } | |
| if ((state === 'dead' || state === 'won') && performance.now() - deadAt > 250) start(); | |
| } | |
| /** Instant restart (R key, Retry): a fresh run that is already flying, no ready screen. */ | |
| function restart() { sfx.tap(); start(); } | |
| /** Page point of a world position (for particle bursts). */ | |
| function pagePt(x, y) { | |
| const r = cv.getBoundingClientRect(), k = r.width / cv.width; | |
| return [r.left + x * K * k, r.top + (cv.height - (y - camY) * K) * k]; | |
| } | |
| function die() { | |
| state = 'dead'; deadAt = performance.now(); flash = 1; shakeAmt = 12; lastScore = sim.score; | |
| sfx.hit(); lv.shake(cv.parentElement, 8, 260); | |
| if (navigator.vibrate) try { navigator.vibrate([30, 40, 30]); } catch { /* */ } | |
| let newBest = false; | |
| if (mode.kind === 'daily') { | |
| const s = dayStats(mode); s.tries = (s.tries || 0) + 1; | |
| if (sim.score > (s.best || 0)) { s.best = sim.score; newBest = true; } | |
| store(dayKey(mode), s); | |
| } else if (mode.kind === 'practice' && sim.score > (store('latchkite:practice-best') || 0)) { store('latchkite:practice-best', sim.score); newBest = true; } | |
| hud(); renderModes(); goal(); | |
| if (isLv()) showRes(false); else showOver(newBest); | |
| if (sim.score >= 5) install.offer(); | |
| } | |
| function won() { | |
| state = 'won'; deadAt = performance.now(); winGlow = 1; | |
| const sec = secs(sim.t), st = levelStars(mode.level, sec); | |
| result = { ...prog.record(mode.level, st, sec), got: st, sec }; | |
| count(GAME, 'win', `level-${mode.level}`); | |
| sfx.win(); for (let k = 0; k < st; k++) sfx.star(k + 1); | |
| const [x, y] = pagePt(sim.x, sim.y); | |
| lv.burst(x, y, { n: 70, speed: 9, colors: [SKINS[skin].k1, SKINS[skin].k2, GOLD, '#FFFFFF'] }); | |
| if (navigator.vibrate) try { navigator.vibrate(20); } catch { /* */ } | |
| hud(); renderModes(); | |
| showRes(true); | |
| install.offer(); | |
| } | |
| function frame(now) { | |
| requestAnimationFrame(frame); | |
| if (state === 'run') { | |
| acc += Math.min(0.25, (now - last) / 1000) * speed; last = now; | |
| let n = 0; | |
| while (acc >= 1 / HZ && n++ < 8 * speed) { | |
| acc -= 1 / HZ; | |
| const tap = auto ? auto.has(sim.t) : pending && !sim.flying; pending = false; | |
| if (tap) sfx.tap(); | |
| const ev = sim.step(tap); | |
| if (ev === 'latch') { | |
| sfx.good(isLv() ? sim.peg : sim.score); pop = 1; | |
| const [x, y] = pagePt(sim.px(sim.peg), sim.pegs[sim.peg].y); | |
| lv.burst(x, y, { n: 12, speed: 4, life: 450, colors: [SKINS[skin].k1, SKINS[skin].peg] }); | |
| } | |
| if (ev === 'win') { won(); break; } | |
| if (sim.dead) { die(); break; } | |
| } | |
| } else last = now; | |
| draw(now); | |
| } | |
| // ------------------------------------------------------------------ drawing (world units -> canvas: 7.2 px a unit) | |
| const TAU = Math.PI * 2; | |
| const K = 7.2; | |
| function kite(x, y, ang, S, size = 1, ctx = g) { | |
| ctx.save(); ctx.translate(x, y); ctx.rotate(ang); ctx.scale(size, size); | |
| ctx.fillStyle = S.k1; ctx.beginPath(); ctx.moveTo(0, -26); ctx.lineTo(17, 0); ctx.lineTo(0, 30); ctx.lineTo(-17, 0); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle = S.k2; ctx.beginPath(); ctx.moveTo(0, -26); ctx.lineTo(17, 0); ctx.lineTo(0, 0); ctx.closePath(); ctx.fill(); | |
| ctx.beginPath(); ctx.moveTo(0, 30); ctx.lineTo(-17, 0); ctx.lineTo(0, 0); ctx.closePath(); ctx.fill(); | |
| ctx.strokeStyle = 'rgba(0,0,0,.25)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, -26); ctx.lineTo(0, 30); ctx.moveTo(-17, 0); ctx.lineTo(17, 0); ctx.stroke(); | |
| ctx.restore(); | |
| } | |
| function sky(ctx, w, h, S) { | |
| const gr = ctx.createLinearGradient(0, 0, 0, h); gr.addColorStop(0, S.top); gr.addColorStop(1, S.bot); | |
| ctx.fillStyle = gr; ctx.fillRect(-20, -20, w + 40, h + 40); | |
| } | |
| function cloud(ctx, x, y, r, now) { | |
| ctx.fillStyle = 'rgba(28,24,40,.9)'; | |
| for (const [dx, dy, k] of [[-0.55, 0.15, 0.7], [0.55, 0.15, 0.7], [0, -0.2, 0.9], [0, 0.25, 0.75]]) { ctx.beginPath(); ctx.arc(x + dx * r, y + dy * r, k * r, 0, TAU); ctx.fill(); } | |
| if (Math.floor(now / 160) % 7 === 0) { ctx.strokeStyle = '#FFE066'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(x - r * 0.1, y + r * 0.5); ctx.lineTo(x + r * 0.15, y + r * 0.9); ctx.lineTo(x - r * 0.05, y + r * 0.95); ctx.lineTo(x + r * 0.2, y + r * 1.35); ctx.stroke(); } | |
| } | |
| function flag(ctx, x, y, now) { | |
| ctx.strokeStyle = GOLD; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y - 46); ctx.stroke(); | |
| const w = Math.sin(now / 160) * 4; | |
| ctx.fillStyle = '#FF5E5B'; ctx.beginPath(); ctx.moveTo(x, y - 46); ctx.lineTo(x + 30, y - 38 + w); ctx.lineTo(x, y - 28); ctx.closePath(); ctx.fill(); | |
| } | |
| function draw(now) { | |
| const S = SKINS[skin], Wc = cv.width, Hc = cv.height; | |
| const p = sim.pegs[sim.peg]; | |
| const target = (sim.flying ? Math.min(p.y, sim.y) : p.y) - 40; | |
| camY += (target - camY) * 0.12; | |
| const sx = (x) => x * K, sy = (y) => Hc - (y - camY) * K; | |
| g.save(); | |
| if (shakeAmt > 0) { g.translate((Math.random() - 0.5) * shakeAmt, (Math.random() - 0.5) * shakeAmt); shakeAmt *= 0.85; if (shakeAmt < 0.5) shakeAmt = 0; } | |
| sky(g, Wc, Hc, S); | |
| // parallax clouds | |
| g.fillStyle = 'rgba(255,255,255,.07)'; | |
| for (let i = 0; i < 6; i++) { const cy = ((i * 211 - camY * 2.2) % (Hc + 200) + Hc + 200) % (Hc + 200) - 100; g.beginPath(); g.ellipse((i * 137) % Wc, Hc - cy, 90, 26, 0, 0, TAU); g.fill(); } | |
| // wind streaks (levels with wind): they blow the way your flight bends | |
| if (sim.wind) { | |
| g.strokeStyle = 'rgba(255,255,255,.22)'; g.lineWidth = 2; | |
| const dir = Math.sign(sim.wind), sp = Math.abs(sim.wind) * 6; | |
| for (let i = 0; i < 9; i++) { | |
| const y = (i * 109 + 40) % Hc, x = (((i * 233 + dir * now * sp / 1000) % (Wc + 160)) + Wc + 160) % (Wc + 160) - 80; | |
| g.beginPath(); g.moveTo(x, y); g.lineTo(x - dir * 60, y); g.stroke(); | |
| } | |
| } | |
| // storm clouds | |
| for (const c of sim.clouds) { const y = sy(c.y); if (y > -80 && y < Hc + 80) cloud(g, sx(c.x), y, c.r * K, now + c.x * 37); } | |
| // pegs + catch rings | |
| for (let j = Math.max(0, sim.peg - 1); j < sim.pegs.length; j++) { | |
| const q = sim.pegs[j]; const y = sy(q.y), x = sx(sim.px(j)); | |
| if (y < -80) break; | |
| if (y > Hc + 60) continue; | |
| if (q.ax) { g.strokeStyle = S.ring; g.lineWidth = 2; g.beginPath(); g.moveTo(sx(q.x - q.ax), y); g.lineTo(sx(q.x + q.ax), y); g.stroke(); } | |
| const isGoal = j === sim.goal; | |
| if (j > sim.peg) { | |
| g.strokeStyle = isGoal ? GOLD : j === sim.peg + 1 ? S.line : S.ring; g.globalAlpha = j === sim.peg + 1 && !isGoal ? 0.55 : 1; | |
| g.lineWidth = isGoal ? 4 : 3; g.setLineDash([8, 8]); g.beginPath(); g.arc(x, y, q.r * K, 0, TAU); g.stroke(); g.setLineDash([]); g.globalAlpha = 1; | |
| } | |
| g.fillStyle = q.flip ? FLIP : q.snap ? SNAP : isGoal ? GOLD : S.peg; g.beginPath(); g.arc(x, y, j === sim.peg ? 9 : q.flip || q.snap ? 9 : 7, 0, TAU); g.fill(); | |
| if (q.flip) { g.strokeStyle = FLIP; g.lineWidth = 3; g.beginPath(); g.arc(x, y, 16, 0.3, Math.PI - 0.3); g.stroke(); g.beginPath(); g.arc(x, y, 16, Math.PI + 0.3, TAU - 0.3); g.stroke(); } | |
| if (isGoal) flag(g, x, y, now); | |
| } | |
| // line, fray warning, aim guide on the first levels | |
| const kx = sx(sim.x), ky = sy(sim.y); | |
| if (!sim.flying) { | |
| const px = sx(sim.px(sim.peg)), fr = state === 'run' ? sim.held / sim.limit() : 0; | |
| g.strokeStyle = fr > 0.6 && Math.floor(now / 90) % 2 ? '#FF4D4D' : S.line; g.lineWidth = 3; | |
| g.beginPath(); g.moveTo(px, sy(p.y)); g.lineTo(kx, ky); g.stroke(); | |
| if (state === 'run') { g.strokeStyle = fr > 0.6 || p.snap ? '#FF4D4D' : S.line; g.globalAlpha = 0.6; g.lineWidth = 4; g.beginPath(); g.arc(px, sy(p.y), 16, -Math.PI / 2, -Math.PI / 2 + TAU * (1 - fr)); g.stroke(); g.globalAlpha = 1; } | |
| if (isLv() && mode.level <= 3 && state !== 'won') { | |
| const tx = -Math.sin(sim.a) * sim.dir, ty = Math.cos(sim.a) * sim.dir; | |
| g.strokeStyle = S.line; g.globalAlpha = 0.35; g.lineWidth = 2; g.setLineDash([4, 10]); | |
| g.beginPath(); g.moveTo(kx, ky); g.lineTo(kx + tx * 26 * K, ky - ty * 26 * K); g.stroke(); g.setLineDash([]); g.globalAlpha = 1; | |
| } | |
| } | |
| // trail | |
| trail.push([sim.x, sim.y]); if (trail.length > 16) trail.shift(); | |
| for (let i = 0; i < trail.length; i++) { | |
| const [x, y] = trail[i]; const a = i / trail.length; | |
| g.fillStyle = S.trail; g.globalAlpha = a * 0.8; | |
| const r = S.tail === 'sparks' ? 3 + (i % 3) * 2 : S.tail === 'bows' ? (i % 4 === 0 ? 7 : 2) : 4 + a * 7; | |
| g.beginPath(); g.arc(sx(x) + (S.tail === 'ribbon' ? Math.sin(now / 90 + i) * 6 : 0), sy(y), r, 0, TAU); g.fill(); | |
| } | |
| g.globalAlpha = 1; | |
| const ang = sim.flying ? Math.atan2(sim.vx, sim.vy) : Math.PI / 2 - sim.a; // nose along the flight, or outward on the line | |
| if (state !== 'dead' || (now - deadAt) % 280 < 140) kite(kx, ky, ang, S, 1 + pop * 0.35); | |
| pop *= 0.86; | |
| // text | |
| g.fillStyle = S.text; g.textAlign = 'center'; g.textBaseline = 'middle'; | |
| if (state === 'ready') { | |
| if (isLv()) { g.font = '800 30px system-ui, sans-serif'; g.globalAlpha = 0.9; g.fillText(`${LEVELS[mode.level - 1].boss ? '👑 ' : ''}Level ${mode.level} · ${LEVELS[mode.level - 1].name}`, Wc / 2, Hc * 0.3 - 70); g.globalAlpha = 1; } | |
| g.font = '900 64px system-ui, sans-serif'; g.fillText('TAP', Wc / 2, Hc * 0.3); | |
| g.font = '700 26px system-ui, sans-serif'; g.globalAlpha = 0.85; g.fillText('to let go · fly into the next ring', Wc / 2, Hc * 0.3 + 52); g.globalAlpha = 1; | |
| } else if (isLv()) { | |
| g.font = '900 96px system-ui, sans-serif'; g.globalAlpha = 0.9; g.fillText(`${sim.peg}/${sim.goal}`, Wc / 2, 100); g.globalAlpha = 1; | |
| // progress bar toward the flag + run time | |
| g.fillStyle = 'rgba(0,0,0,.25)'; g.fillRect(60, 164, Wc - 120, 10); | |
| g.fillStyle = GOLD; g.fillRect(60, 164, (Wc - 120) * (sim.peg / sim.goal), 10); | |
| g.fillStyle = S.text; g.font = '700 26px system-ui, sans-serif'; g.textAlign = 'right'; g.fillText(`${secs(sim.t).toFixed(1)} s`, Wc - 24, 36); g.textAlign = 'center'; | |
| if (state === 'dead') { g.font = '800 28px system-ui, sans-serif'; g.fillText(`${why()} · tap to retry`, Wc / 2, 210); } | |
| } else { | |
| g.font = '900 110px system-ui, sans-serif'; g.globalAlpha = 0.9; g.fillText(String(sim.score), Wc / 2, 110); g.globalAlpha = 1; | |
| if (state === 'dead') { g.font = '800 28px system-ui, sans-serif'; g.fillText(`${why()} · tap to retry`, Wc / 2, 185); } | |
| } | |
| if (flash > 0) { g.fillStyle = `rgba(255,77,77,${flash * 0.3})`; g.fillRect(0, 0, Wc, Hc); flash *= 0.88; if (flash < 0.02) flash = 0; } | |
| if (winGlow > 0) { g.fillStyle = `rgba(255,210,63,${winGlow * 0.25})`; g.fillRect(0, 0, Wc, Hc); winGlow *= 0.94; if (winGlow < 0.02) winGlow = 0; } | |
| g.restore(); | |
| } | |
| const why = () => ({ fray: 'the line frayed', snap: 'the red peg snapped', storm: 'lost in the storm', lost: 'lost in the wind' }[sim.why] || 'lost in the wind'); | |
| // ------------------------------------------------------------------ results, share, card | |
| function shareText() { | |
| if (isLv()) { | |
| const sec = result ? result.sec : prog.best(mode.level); | |
| return `Latchkite level ${mode.level} 🪁 ${lv.starText(prog.stars(mode.level))}\nCleared ${LEVELS[mode.level - 1].name} in ${sec} s\nCan you beat it?`; | |
| } | |
| const head = mode.kind === 'practice' ? 'Latchkite practice 🪁' : `Latchkite #${dayNumber(mode.date)} 🪁`; | |
| const best = mode.kind === 'daily' ? dayStats(mode).best : lastScore; | |
| const st = mode.kind === 'daily' && mode.date === todayIso && streakNow() > 1 ? ` 🔥${streakNow()}` : ''; | |
| return `${head} ${best} catches${mode.kind === 'daily' ? ` (${dayStats(mode).tries} tries)` : ''}${st}\n${bar(best)}\nCan you fly further?`; | |
| } | |
| /** The "beat my score" link: the same level (my time) or the same daily course (my best catches). */ | |
| function beatLink() { | |
| if (isLv()) return lv.challengeLink(PATH, GAME, { level: mode.level, score: result ? result.sec : prog.best(mode.level) }); | |
| if (mode.kind === 'daily') return lv.challengeLink(PATH, GAME, { date: mode.date, score: dayStats(mode).best }); | |
| return `https://cybermaxtools.com${PATH}`; | |
| } | |
| function vsFriend() { | |
| if (!sameChal() || chal.score === null) return ''; | |
| if (isLv()) { | |
| if (!result) return ''; | |
| return result.sec < chal.score ? `🎯 You beat your friend's ${chal.score} s!` : result.sec === chal.score ? `🎯 Tied with your friend (${chal.score} s)` : `🎯 ${(result.sec - chal.score).toFixed(1)} s slower than your friend. Retry?`; | |
| } | |
| return lastScore > chal.score ? `🎯 You beat your friend's ${chal.score}!` : lastScore === chal.score ? `🎯 Tied with your friend (${chal.score})` : `🎯 ${chal.score - lastScore} short of your friend's ${chal.score}. Retry?`; | |
| } | |
| /** Compact result over the canvas (stars, next level, retry, challenge) so a phone player never has to scroll. */ | |
| function showRes(win) { | |
| const el = $('res'); | |
| const n = isLv() ? mode.level : 0; | |
| $('res-stars').hidden = !win; | |
| if (win) $('res-stars').innerHTML = lv.starsHtml(result.got); | |
| if (isLv()) { | |
| $('res-title').textContent = win ? (result.got === 3 ? `Level ${n}: perfect!` : `Level ${n} cleared!`) : `Peg ${sim.peg}/${sim.goal}: ${why()}`; | |
| const c = levelCfg(n); | |
| let line = win ? `${result.sec} s${result.improved && !result.first ? ' · new best!' : ''} · ⭐⭐⭐ under ${c.stars[2]} s, ⭐⭐ under ${c.stars[1]} s` : `Tap the sky or ↻ to retry at once.`; | |
| const vs = vsFriend(); if (vs && win) line += ` · ${vs}`; | |
| $('res-line').textContent = line; | |
| } else { | |
| $('res-title').textContent = `${lastScore} catch${lastScore === 1 ? '' : 'es'}`; | |
| $('res-line').textContent = vsFriend() || 'Tap the sky or ↻ to fly again.'; | |
| } | |
| const nx = $('next-lv'); | |
| nx.hidden = !(isLv() && win && n < LEVELS.length); | |
| if (!nx.hidden) nx.textContent = prog.lock(n + 1) === 'pass' ? `Level ${n + 1} 🔒 Fun Pass` : `Next: level ${n + 1} ▶`; | |
| $('beat').hidden = isLv() ? !win : mode.kind !== 'daily'; | |
| $('to-daily').hidden = !(isLv() && win && n === LEVELS.length); | |
| el.hidden = false; | |
| if (isLv() && win) showOver(false); | |
| } | |
| function showOver(newBest) { | |
| const s = mode.kind === 'daily' ? dayStats(mode) : null; | |
| if (isLv()) { | |
| $('over-title').textContent = `Level ${mode.level} ${lv.starText(result.got)}`; | |
| $('over-line').textContent = `${result.sec} s. Share it and dare a friend to fly it faster.`; | |
| } else { | |
| $('over-title').textContent = newBest && lastScore > 0 ? `New best: ${lastScore}!` : `${lastScore} catch${lastScore === 1 ? '' : 'es'}`; | |
| const st = mode.kind === 'daily' && mode.date === todayIso ? streakNow() : 0; | |
| $('over-line').textContent = s ? `Today's best ${s.best} after ${s.tries} tries${st ? ` · 🔥 ${st}-day streak` : ''}. Tap the game to fly again.${vsFriend() ? ` ${vsFriend()}` : ''}` : 'Practice course. Tap the game to fly again.'; | |
| showRes(false); | |
| } | |
| const it = intents(GAME, mode.kind === 'practice' ? shareText() : `${shareText()}\n🎯 ${beatLink()}`, PATH); | |
| $('sx').href = it.x; $('sb').href = it.bsky; $('sw').href = it.whatsapp; | |
| $('over').hidden = false; | |
| pass.afterWin($('over'), 'over'); | |
| } | |
| function card() { | |
| const S = SKINS[skin], c = document.createElement('canvas'); | |
| c.width = 1080; c.height = 1350; | |
| const x = c.getContext('2d'); | |
| sky(x, 1080, 1350, S); | |
| x.fillStyle = S.peg; | |
| [[300, 1040], [700, 820], [420, 560]].forEach(([a, b]) => { x.beginPath(); x.arc(a, b, 14, 0, TAU); x.fill(); }); | |
| x.strokeStyle = S.ring; x.lineWidth = 5; x.setLineDash([12, 12]); x.beginPath(); x.arc(420, 560, 70, 0, TAU); x.stroke(); x.setLineDash([]); | |
| x.strokeStyle = S.line; x.lineWidth = 5; x.beginPath(); x.moveTo(700, 820); x.lineTo(610, 700); x.stroke(); | |
| x.fillStyle = S.trail; for (let i = 0; i < 10; i++) { x.globalAlpha = i / 12; x.beginPath(); x.arc(780 - i * 10, 930 - i * 12, 6 + i, 0, TAU); x.fill(); } x.globalAlpha = 1; | |
| kite(610, 690, -0.6, S, 2.6, x); | |
| const lvl = isLv(); | |
| const big = lvl ? `${result ? result.sec : prog.best(mode.level)}s` : String(mode.kind === 'daily' ? dayStats(mode).best : lastScore); | |
| x.fillStyle = S.text; x.textAlign = 'center'; | |
| x.font = `900 ${lvl ? 190 : 230}px system-ui, sans-serif`; x.fillText(big, 540, 420); | |
| x.font = '700 46px system-ui, sans-serif'; x.fillText(lvl ? `${lv.starText(prog.stars(mode.level))} to the flag` : 'catches in a row', 540, 485); | |
| x.textAlign = 'left'; x.font = '900 66px system-ui, sans-serif'; x.fillText('LATCHKITE', 80, 130); | |
| x.globalAlpha = 0.85; x.font = '700 40px system-ui, sans-serif'; | |
| x.fillText(lvl ? `Level ${mode.level} · ${LEVELS[mode.level - 1].name}` : mode.kind === 'daily' ? `Daily course #${dayNumber(mode.date)} · ${mode.date}` : 'Practice course', 80, 190); | |
| x.globalAlpha = 1; x.font = '800 48px system-ui, sans-serif'; x.fillText(lvl ? 'Can you fly it faster?' : 'Can you fly further?', 80, 1190); | |
| x.globalAlpha = 0.75; x.font = '600 32px system-ui, sans-serif'; x.fillText('Play free: cybermaxtools.com/games/latchkite', 80, 1290); | |
| return c; | |
| } | |
| const fileName = () => `latchkite-${isLv() ? `level-${mode.level}` : mode.date || 'practice'}.png`; | |
| $('share').onclick = async () => share(GAME, mode.kind === 'practice' ? shareText() : `${shareText()}\n🎯 Beat me: ${beatLink()}`, PATH, await canvasBlob(card()), fileName()); | |
| ['sx', 'sb', 'sw'].forEach((id) => $(id).addEventListener('click', () => count(GAME, 'share', { sx: 'x', sb: 'bsky', sw: 'whatsapp' }[id]))); | |
| $('card').onclick = async () => { download(await canvasBlob(card()), fileName()); count(GAME, 'share', 'png'); }; | |
| $('beat').onclick = async () => { | |
| const what = isLv() ? `cleared Latchkite level ${mode.level} in ${result ? result.sec : prog.best(mode.level)} s` : `caught ${dayStats(mode).best} pegs on Latchkite #${dayNumber(mode.date)}`; | |
| const r = await lv.shareChallenge(`I ${what}. Can you beat me? 🪁`, beatLink(), await canvasBlob(card()), fileName()); | |
| if (r === 'copied') toast('Challenge link copied. Send it to a friend!'); | |
| if (r !== 'cancelled') count(GAME, 'share', 'challenge'); | |
| }; | |
| $('retry').onclick = () => restart(); | |
| $('next-lv').onclick = () => { if (setMode({ kind: 'level', level: mode.level + 1 })) cv.scrollIntoView({ behavior: 'smooth', block: 'center' }); }; | |
| $('to-daily').onclick = () => setMode({ kind: 'daily', date: todayIso }); | |
| // ------------------------------------------------------------------ input, skins, start | |
| cv.addEventListener('pointerdown', (e) => { e.preventDefault(); input(); }); | |
| cv.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter' || e.key === 'ArrowUp') { e.preventDefault(); input(); } }); | |
| document.addEventListener('keydown', (e) => { | |
| if (e.target.closest && e.target.closest('input,textarea,dialog')) return; | |
| if (e.key === ' ' && document.activeElement === document.body) { e.preventDefault(); input(); } | |
| else if ((e.key === 'r' || e.key === 'R') && !e.ctrlKey && !e.metaKey) { e.preventDefault(); restart(); } | |
| else if (e.key === 'm' || e.key === 'M') setMute(!muted); | |
| }); | |
| $('pass-btn').onclick = () => pass.open('header'); | |
| $('skins-btn').onclick = () => skins.open('header'); | |
| document.addEventListener('click', (e) => { | |
| const b = e.target.closest('[data-pass]'); if (b) pass.open(b.dataset.pass); | |
| const k = e.target.closest('[data-skins]'); if (k) skins.open(k.dataset.skins); | |
| }); | |
| document.addEventListener('visibilitychange', () => { if (document.hidden && state === 'run') last = performance.now(); }); | |
| function renderSkins() { | |
| const el = $('skins'); el.innerHTML = ''; | |
| for (const [id, s] of Object.entries(SKINS)) { | |
| const b = document.createElement('button'); | |
| const lock = !canUse(s); | |
| b.type = 'button'; b.title = `${s.name}${s.tier === 'pass' ? ' (Fun Pass)' : s.tier === 'pack' ? ' (Skin Pack)' : ''}`; b.setAttribute('aria-label', b.title); | |
| b.style.background = `linear-gradient(135deg, ${s.top} 50%, ${s.k1} 50%)`; | |
| b.setAttribute('aria-pressed', String(id === skin)); | |
| if (lock) b.classList.add('locked'); | |
| b.onclick = () => { | |
| if (lock) { if (s.tier === 'pass') pass.open('theme'); else skins.open('dot'); return; } | |
| skin = id; store('latchkite:skin', id); renderSkins(); | |
| }; | |
| el.appendChild(b); | |
| } | |
| $('skins-btn').textContent = skins.owned() ? '🪁 Skins' : '🪁 Skin Pack $1.99'; | |
| } | |
| const refresh = () => { if (!canUse(SKINS[skin])) { skin = 'dawn'; store('latchkite:skin', 'dawn'); } renderSkins(); renderModes(); }; | |
| pass.onChange(refresh); | |
| skins.onChange(refresh); | |
| // Screenshot helper for scripts/games/render_assets.mjs (a mid-run frame; no effect on normal play). | |
| window.__cmxShot = async (stage = 1) => { | |
| if (isLv()) setMode({ kind: 'daily', date: todayIso }); | |
| start(); | |
| const s = makeSim(todayIso); | |
| const peek = () => { const c = makeSim(todayIso); Object.assign(c, { peg: s.peg, a: s.a, len: s.len, dir: s.dir, score: s.score, held: 0 }); c.step(true); for (let i = 0; i < 120; i++) { const e = c.step(false); if (e) return e === 'latch'; } return false; }; | |
| while (s.score < 6 && !s.dead) s.step(!s.flying && s.held > 6 && peek()); | |
| for (let i = 0; i < 25; i++) s.step(false); | |
| sim = s; camY = s.pegs[s.peg].y - 40; state = 'shot'; | |
| if (stage === 2) die(); | |
| }; | |
| window.__cmx = { spin }; | |
| // Demo recorder helper (scripts/studio/demo_video.mjs): plays real taps (pointer events) at catchable moments for n | |
| // catches, then stops tapping so the round ends on its own. Cosmetic games only; there is no leaderboard to game. | |
| window.__cmxDemo = (n = 8) => { | |
| const fits = () => { const c = sim.clone(); c.held = 0; c.step(true); for (let i = 0; i < HZ * 2; i++) { const e = c.step(false); if (e) return (e === 'latch' || e === 'win') && c.peg === sim.peg + 1; } return false; }; | |
| const tap = () => cv.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true })); | |
| if (state !== 'run') tap(); | |
| const iv = setInterval(() => { if (state !== 'run' && state !== 'ready') { clearInterval(iv); return; } if (state === 'run' && sim.score < n && !sim.flying && !pending && sim.held > 8 && fits()) tap(); }, 4); | |
| }; | |
| // Test hook (tests/e2e.mjs): auto-fly the current level with the level bot's own release ticks, optionally faster. | |
| window.__cmxTest = { | |
| autoLevel(fast = 1) { | |
| if (!isLv()) return false; | |
| const b = levelBot(makeLevelSim(mode.level), winOf(mode.level)); | |
| auto = new Set(b.taps); speed = fast; | |
| start(); return b.won; | |
| }, | |
| manual() { auto = null; speed = 1; }, | |
| state: () => ({ state, mode: { ...mode }, peg: sim.peg, goal: sim.goal, score: sim.score }), | |
| }; | |
| /** Where a visit lands: a friend's link, ?lv= / ?d=, a first visit (level 1 + tutorial), else today's daily. */ | |
| function fromUrl() { | |
| const q = new URLSearchParams(location.search); | |
| if (chal && chal.level && chal.level <= LEVELS.length) return { kind: 'level', level: chal.level, challenge: true }; | |
| if (chal && chal.date) return { kind: 'daily', date: pass.isPro() || chal.date === todayIso ? chal.date : todayIso }; | |
| const lq = Number(q.get('lv')); | |
| if (lq >= 1 && lq <= LEVELS.length) return { kind: 'level', level: lq }; | |
| const d = q.get('d'); | |
| if (d && /^\d{4}-\d{2}-\d{2}$/.test(d) && dayNumber(d) >= 1 && d <= todayIso && pass.isPro()) return { kind: 'daily', date: d }; | |
| if (!returning()) return { kind: 'level', level: 1 }; | |
| return { kind: 'daily', date: todayIso }; | |
| } | |
| // a locked link (a Fun Pass level, a level not reached yet) falls back to the next open level, then today's daily | |
| setMode(fromUrl(), true) || (!prog.lock(prog.reached()) && setMode({ kind: 'level', level: prog.reached() }, true)) || setMode({ kind: 'daily', date: todayIso }); | |
| requestAnimationFrame(frame); | |