// Latchkite: game screen (fixed-tick loop, canvas, sound, daily/practice/archive, share, Fun Pass, Skin Pack). (c) 2026 CyberMax. import { makeSim, bar, HZ, FRAY, spin } from './core.js'; import { dayNumber, dateOf, count, store, toast, share, intents, download, canvasBlob, pwa, themeToggle } from './cmx.js'; import * as skins from './skins.js'; import * as pass from './funpass.js'; const GAME = 'latchkite'; const PATH = '/games/latchkite/'; const GUMROAD = 'https://cybermax1.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); 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: ['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'); let mode = { kind: 'daily', date: todayIso }; let sim, state = 'ready', pending = false, acc = 0, last = 0, deadAt = 0, flash = 0, shake = 0, lastScore = 0, camY = -40, pop = 0; let trail = []; const install = pwa(GAME, $('install')); themeToggle($('theme')); // ------------------------------------------------------------------ sound (WebAudio blips, no files) let ac; function blip(f, d = 0.06, type = 'sine', vol = 0.08) { if (muted) return; try { ac = ac || new (window.AudioContext || window.webkitAudioContext)(); const o = ac.createOscillator(), v = ac.createGain(); o.type = type; o.frequency.setValueAtTime(f, ac.currentTime); o.frequency.exponentialRampToValueAtTime(f * (type === 'sawtooth' ? 0.3 : 1.6), ac.currentTime + d); v.gain.setValueAtTime(vol, ac.currentTime); v.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + d); o.connect(v); v.connect(ac.destination); o.start(); o.stop(ac.currentTime + d + 0.02); } catch { /* no audio */ } } $('mute').textContent = muted ? '๐Ÿ”‡' : '๐Ÿ”Š'; $('mute').onclick = () => { muted = !muted; store('latchkite:mute', muted); $('mute').textContent = muted ? '๐Ÿ”‡' : '๐Ÿ”Š'; }; // ------------------------------------------------------------------ modes + stats 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 }; function renderModes() { const el = $('modes'); el.innerHTML = ''; const items = [ { t: `Daily #${todayNum}`, on: mode.kind === 'daily' && mode.date === todayIso, go: () => setMode({ kind: 'daily', date: todayIso }) }, { 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 setMode(m) { mode = m; $('num').textContent = m.kind === 'practice' ? 'practice' : `#${dayNumber(m.date)}`; $('over').hidden = true; renderModes(); reset(); hud(); } function hud() { const s = mode.kind === 'daily' ? dayStats(mode) : { best: store('latchkite:practice-best') || 0, tries: 0 }; $('best').textContent = s.best; $('tries').textContent = s.tries || 0; } // ------------------------------------------------------------------ loop function reset() { sim = makeSim(seedOf(mode)); state = 'ready'; pending = false; acc = 0; trail = []; flash = 0; shake = 0; camY = -40; } function start() { if (mode.kind === 'practice') mode.seed = `p${Date.now()}`; reset(); state = 'run'; last = performance.now(); count(GAME, 'play', mode.kind === 'daily' ? (mode.date === todayIso ? 'daily' : 'archive') : 'practice'); $('over').hidden = true; } function input() { if (state === 'ready') { start(); return; } if (state === 'run') { pending = true; return; } if (state === 'dead' && performance.now() - deadAt > 300) start(); } function die() { state = 'dead'; deadAt = performance.now(); flash = 1; shake = 12; lastScore = sim.score; blip(150, 0.32, 'sawtooth', 0.11); 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 (sim.score > (store('latchkite:practice-best') || 0)) { store('latchkite:practice-best', sim.score); newBest = true; } hud(); showOver(newBest); if (sim.score >= 5) install.offer(); } function frame(now) { requestAnimationFrame(frame); if (state === 'run') { acc += Math.min(0.25, (now - last) / 1000); last = now; let n = 0; while (acc >= 1 / HZ && n++ < 8) { acc -= 1 / HZ; const tap = pending && !sim.flying; pending = false; if (tap) blip(620, 0.05, 'triangle'); const ev = sim.step(tap); if (ev === 'latch') { blip(700 + Math.min(sim.score, 40) * 22, 0.07, 'sine', 0.07); pop = 1; } 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 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 (shake > 0) { g.translate((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake); shake *= 0.85; if (shake < 0.5) shake = 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(); } // 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); if (y < -60) break; if (y > Hc + 60) continue; if (j > sim.peg) { g.strokeStyle = S.ring; g.lineWidth = 3; g.setLineDash([8, 8]); g.beginPath(); g.arc(sx(q.x), y, q.r * K, 0, TAU); g.stroke(); g.setLineDash([]); } g.fillStyle = S.peg; g.beginPath(); g.arc(sx(q.x), y, j === sim.peg ? 9 : 7, 0, TAU); g.fill(); } // line, fray warning const kx = sx(sim.x), ky = sy(sim.y); if (!sim.flying) { const fr = state === 'run' ? sim.held / FRAY : 0; g.strokeStyle = fr > 0.6 && Math.floor(now / 90) % 2 ? '#FF4D4D' : S.line; g.lineWidth = 3; g.beginPath(); g.moveTo(sx(p.x), sy(p.y)); g.lineTo(kx, ky); g.stroke(); if (state === 'run') { g.strokeStyle = fr > 0.6 ? '#FF4D4D' : S.line; g.globalAlpha = 0.6; g.lineWidth = 4; g.beginPath(); g.arc(sx(p.x), sy(p.y), 16, -Math.PI / 2, -Math.PI / 2 + TAU * (1 - fr)); g.stroke(); 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') { 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 { 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(sim.why === 'fray' ? 'the line frayed ยท tap to retry' : 'lost in the wind ยท 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; } g.restore(); } // ------------------------------------------------------------------ results, share, card function shareText() { const head = mode.kind === 'practice' ? 'Latchkite practice ๐Ÿช' : `Latchkite #${dayNumber(mode.date)} ๐Ÿช`; const best = mode.kind === 'daily' ? dayStats(mode).best : lastScore; return `${head} ${best} catches${mode.kind === 'daily' ? ` (${dayStats(mode).tries} tries)` : ''}\n${bar(best)}\nCan you fly further?`; } function showOver(newBest) { const s = mode.kind === 'daily' ? dayStats(mode) : null; $('over-title').textContent = newBest && lastScore > 0 ? `New best: ${lastScore}!` : `${lastScore} catch${lastScore === 1 ? '' : 'es'}`; $('over-line').textContent = s ? `Today's best ${s.best} after ${s.tries} tries. Tap the game to fly again.` : 'Practice course. Tap the game to fly again.'; const it = intents(GAME, shareText(), 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 best = mode.kind === 'daily' ? dayStats(mode).best : lastScore; x.fillStyle = S.text; x.textAlign = 'center'; x.font = '900 230px system-ui, sans-serif'; x.fillText(String(best), 540, 420); x.font = '700 46px system-ui, sans-serif'; x.fillText('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(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('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; } $('share').onclick = async () => share(GAME, shareText(), PATH, await canvasBlob(card()), `latchkite-${mode.date || 'practice'}.png`); ['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()), `latchkite-${mode.date || 'practice'}.png`); count(GAME, 'share', 'png'); }; // ------------------------------------------------------------------ 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.key === ' ' && document.activeElement === document.body) { e.preventDefault(); input(); } }); $('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) => { 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 = makeSim('demo'); Object.assign(c, { pegs: sim.pegs, peg: sim.peg, a: sim.a, len: sim.len, dir: sim.dir, score: sim.score, held: 0 }); c.step(true); for (let i = 0; i < HZ * 2; i++) { const e = c.step(false); if (e) return e === 'latch' && 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 === 'dead') { clearInterval(iv); return; } if (state === 'run' && sim.score < n && !sim.flying && !pending && sim.held > 8 && fits()) tap(); }, 4); }; setMode(mode); requestAnimationFrame(frame);