// 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 ? ' ๐Ÿ”’' : ''}`; 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 === undefined ? 'โ€“' : `${b} s`} ยท ${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 ${s.best} ยท tries ${s.tries || 0}${st ? ` ยท ๐Ÿ”ฅ ${st}-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 ${L.pegs} pegs to the ๐Ÿšฉ flag`); bits.push(`โญโญโญ under ${c.stars[2]} s`); if (L.twist) bits.push(`NEW: ${L.twist}`); } 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 ${best}`); } $('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 = `๐ŸŽฏ A friend ${isLv() ? `cleared level ${chal.level} in ${chal.score} s` : `caught ${chal.score} pegs on daily #${dayNumber(chal.date)}`}. Beat it!`; } // ------------------------------------------------------------------ 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);