Spaces:
Running
Running
Download game.js from CyberMax-tools/pawfoil: direct link, hf CLI and curl.
- Browser
- Download file 10.2 kB
-
https://huggingface.co/spaces/CyberMax-tools/pawfoil/resolve/d05c291e989c7c8dfff5ff90f16b63edf9b9440d/game.js
- Command line
-
hf download hf://spaces/CyberMax-tools/pawfoil@d05c291e989c7c8dfff5ff90f16b63edf9b9440d/game.js
-
curl -L -o game.js https://huggingface.co/spaces/CyberMax-tools/pawfoil/resolve/d05c291e989c7c8dfff5ff90f16b63edf9b9440d/game.js
10.2 kB
| // Pawfoil: photo + name -> collectible card on a canvas, holo tilt preview, HD PNG download, share. (c) 2026 CyberMax. | |
| // The photo is read with createImageBitmap and drawn locally; it is never uploaded. | |
| import { makeCard, KINDS, RARITY, FRAMES, cleanPetName } from './core.js'; | |
| import { count, store, toast, share, intents, download, canvasBlob, pwa, rr, themeToggle } from './cmx.js'; | |
| import * as pass from './funpass.js'; | |
| const GAME = 'pawfoil'; | |
| const PATH = '/games/pawfoil/'; | |
| const $ = (id) => document.getElementById(id); | |
| const cv = $('card'); | |
| const g = cv.getContext('2d'); | |
| const W = 750, H = 1050, ART = { x: 52, y: 128, w: 646, h: 430 }; | |
| let photo = null, kind = store('pawfoil:kind') || 'dog', roll = 0, forced = null, ox = 0, oy = 0, zoom = 1, made = false; | |
| pass.init({ game: GAME, name: 'Pawfoil', perks: ['Pick any frame: Legendary gold holo on demand', 'Hide the CyberMax line on your cards'] }); | |
| const install = pwa(GAME, $('install')); | |
| themeToggle($('theme')); | |
| // ------------------------------------------------------------------ controls | |
| function renderKinds() { | |
| const el = $('kinds'); el.innerHTML = ''; | |
| for (const [id, k] of Object.entries(KINDS)) { | |
| const b = document.createElement('button'); | |
| b.type = 'button'; b.className = 'chip'; b.textContent = `${k.emoji} ${k.label}`; b.setAttribute('aria-pressed', String(id === kind)); | |
| b.onclick = () => { kind = id; store('pawfoil:kind', id); roll = 0; renderKinds(); draw(); }; | |
| el.appendChild(b); | |
| } | |
| } | |
| function renderFrames() { | |
| const el = $('frames'); el.innerHTML = ''; | |
| for (const r of [{ id: null, label: 'Roll' }, ...RARITY]) { | |
| const b = document.createElement('button'); | |
| b.type = 'button'; b.className = 'chip'; b.setAttribute('aria-pressed', String(forced === r.id)); | |
| b.innerHTML = `${r.label}${r.id && !pass.isPro() ? ' <span class="lock">🔒</span>' : ''}`; | |
| b.onclick = () => { if (r.id && !pass.isPro()) { pass.open(`frame-${r.id}`); return; } forced = r.id; renderFrames(); draw(); }; | |
| el.appendChild(b); | |
| } | |
| $('clean').disabled = false; | |
| } | |
| $('clean').onchange = (e) => { if (e.target.checked && !pass.isPro()) { e.target.checked = false; pass.open('clean'); return; } draw(); }; | |
| $('name').addEventListener('input', () => { roll = 0; draw(); }); | |
| $('zoom').addEventListener('input', (e) => { zoom = Number(e.target.value); draw(); }); | |
| $('roll').onclick = () => { | |
| roll++; draw(); | |
| const c = card(); | |
| $('rolls').textContent = `Roll ${roll + 1}: ${c.rarityLabel}${c.rarity === 'legendary' ? '! 🌟' : ''}`; | |
| if (navigator.vibrate) try { navigator.vibrate(c.rarity === 'legendary' ? [20, 40, 60] : 10); } catch { /* */ } | |
| }; | |
| async function loadPhoto(blob, from) { | |
| try { | |
| photo = await createImageBitmap(blob); | |
| ox = 0; oy = 0; zoom = 1; $('zoom').value = 1; | |
| if (!made) { made = true; count(GAME, 'play', from); } | |
| draw(); | |
| } catch { toast('Could not read that image. Try a JPG or PNG.'); } | |
| } | |
| $('file').onchange = (e) => { const f = e.target.files[0]; if (f) loadPhoto(f, 'photo'); }; | |
| $('sample').onclick = async () => { const r = await fetch('sample.jpg'); loadPhoto(await r.blob(), 'sample'); if (!$('name').value) $('name').value = 'Biscuit'; draw(); }; | |
| // drag to move the photo inside the art box | |
| let drag = null; | |
| cv.addEventListener('pointerdown', (e) => { if (!photo) return; drag = { x: e.clientX, y: e.clientY, ox, oy }; cv.setPointerCapture(e.pointerId); }); | |
| cv.addEventListener('pointermove', (e) => { | |
| if (!drag) return; | |
| const s = W / cv.getBoundingClientRect().width; | |
| ox = drag.ox + (e.clientX - drag.x) * s; oy = drag.oy + (e.clientY - drag.y) * s; draw(); | |
| }); | |
| cv.addEventListener('pointerup', () => { drag = null; }); | |
| // holo tilt (mouse or device tilt) | |
| const tilt = $('tilt'); | |
| function setTilt(rx, ry) { tilt.style.transform = `perspective(900px) rotateX(${rx}deg) rotateY(${ry}deg)`; $('holo').style.backgroundPosition = `${50 + ry * 4}% ${50 - rx * 4}%`; } | |
| tilt.addEventListener('pointermove', (e) => { if (drag) return; const r = tilt.getBoundingClientRect(); setTilt(((e.clientY - r.top) / r.height - 0.5) * -14, ((e.clientX - r.left) / r.width - 0.5) * 14); }); | |
| tilt.addEventListener('pointerleave', () => setTilt(0, 0)); | |
| window.addEventListener('deviceorientation', (e) => { if (e.beta == null) return; setTilt(Math.max(-12, Math.min(12, (e.beta - 40) / 3)), Math.max(-12, Math.min(12, e.gamma / 3))); }); | |
| // ------------------------------------------------------------------ drawing | |
| const card = () => makeCard($('name').value || 'Mystery Pet', kind, roll, forced); | |
| function wrap(text, x, y, maxW, lh) { | |
| const words = text.split(' '); let line = ''; | |
| for (const w of words) { if (g.measureText(`${line}${w} `).width > maxW && line) { g.fillText(line.trim(), x, y); y += lh; line = ''; } line += `${w} `; } | |
| g.fillText(line.trim(), x, y); | |
| } | |
| function draw() { | |
| const c = card(), F = FRAMES[c.rarity]; | |
| tilt.classList.toggle('foil', F.foil); | |
| g.clearRect(0, 0, W, H); | |
| // frame | |
| const grd = g.createLinearGradient(0, 0, W, H); grd.addColorStop(0, F.a); grd.addColorStop(1, F.b); | |
| g.fillStyle = grd; rr(g, 0, 0, W, H, 40); g.fill(); | |
| g.fillStyle = 'rgba(255,255,255,.55)'; rr(g, 24, 24, W - 48, H - 48, 28); g.fill(); | |
| // header | |
| g.fillStyle = F.ink; g.font = '900 52px system-ui, sans-serif'; g.textBaseline = 'alphabetic'; | |
| g.fillText(c.name.slice(0, 16), 52, 96); | |
| g.textAlign = 'right'; g.font = '800 26px system-ui, sans-serif'; g.fillText('HP', W - 150, 96); | |
| g.font = '900 52px system-ui, sans-serif'; g.fillText(String(c.hp), W - 52, 96); g.textAlign = 'left'; | |
| // art | |
| g.save(); rr(g, ART.x, ART.y, ART.w, ART.h, 18); g.clip(); | |
| g.fillStyle = '#1B1430'; g.fillRect(ART.x, ART.y, ART.w, ART.h); | |
| if (photo) { | |
| const s = Math.max(ART.w / photo.width, ART.h / photo.height) * zoom; | |
| const dw = photo.width * s, dh = photo.height * s; | |
| g.drawImage(photo, ART.x + (ART.w - dw) / 2 + ox, ART.y + (ART.h - dh) / 2 + oy, dw, dh); | |
| } else { | |
| g.fillStyle = '#ffffff22'; g.font = '200px system-ui, "Apple Color Emoji", "Segoe UI Emoji", sans-serif'; g.textAlign = 'center'; | |
| g.fillText(KINDS[c.kind].emoji, W / 2, ART.y + 280); g.font = '700 28px system-ui, sans-serif'; g.fillStyle = '#ffffffaa'; | |
| g.fillText('Add a photo of your pet', W / 2, ART.y + 380); g.textAlign = 'left'; | |
| } | |
| if (F.foil) { // printed holo sheen | |
| const h = g.createLinearGradient(ART.x, ART.y, ART.x + ART.w, ART.y + ART.h); | |
| ['rgba(255,0,170,0)', 'rgba(255,0,170,.18)', 'rgba(0,255,230,.2)', 'rgba(255,230,0,.18)', 'rgba(255,230,0,0)'].forEach((col, i) => h.addColorStop(i / 4, col)); | |
| g.globalCompositeOperation = 'screen'; g.fillStyle = h; g.fillRect(ART.x, ART.y, ART.w, ART.h); g.globalCompositeOperation = 'source-over'; | |
| } | |
| g.restore(); | |
| g.strokeStyle = F.b; g.lineWidth = 6; rr(g, ART.x, ART.y, ART.w, ART.h, 18); g.stroke(); | |
| // type banner | |
| g.fillStyle = F.ink; rr(g, 52, 576, W - 104, 50, 25); g.fill(); | |
| g.fillStyle = F.a; g.font = '800 26px system-ui, sans-serif'; | |
| g.fillText(`${c.emoji} ${c.type}-type ${c.kindLabel} · ${'★'.repeat(c.stars)} ${c.rarityLabel}`, 76, 610); | |
| // moves | |
| g.fillStyle = F.ink; | |
| c.moves.forEach((m, i) => { | |
| const y = 680 + i * 104; | |
| g.font = '900 32px system-ui, sans-serif'; g.fillText(m.name, 60, y); | |
| g.textAlign = 'right'; g.fillText(String(m.dmg), W - 60, y); g.textAlign = 'left'; | |
| g.font = '500 23px system-ui, sans-serif'; g.globalAlpha = 0.85; wrap(m.text, 60, y + 32, W - 140, 28); g.globalAlpha = 1; | |
| }); | |
| // stats | |
| c.stats.forEach((st, i) => { | |
| const col = i % 3, row = Math.floor(i / 3), x = 60 + col * 214, y = 892 + row * 50; | |
| g.font = '700 19px system-ui, sans-serif'; g.fillText(st.s, x, y); | |
| g.textAlign = 'right'; g.font = '900 19px system-ui, sans-serif'; g.fillText(String(st.v), x + 180, y); g.textAlign = 'left'; | |
| g.fillStyle = 'rgba(0,0,0,.12)'; rr(g, x, y + 8, 180, 12, 6); g.fill(); | |
| g.globalAlpha = 0.8; g.fillStyle = F.ink; rr(g, x, y + 8, 180 * st.v / 99, 12, 6); g.fill(); g.globalAlpha = 1; | |
| }); | |
| // footer | |
| g.font = '800 18px system-ui, sans-serif'; g.globalAlpha = 0.75; | |
| g.fillText(`#${String(c.number).padStart(3, '0')}/999`, 60, H - 48); | |
| g.textAlign = 'right'; g.fillText(`Power ${c.power}`, W - 60, H - 48); g.textAlign = 'left'; | |
| if (!($('clean').checked && pass.isPro())) { g.font = '600 15px system-ui, sans-serif'; g.textAlign = 'center'; g.fillText('Made with Pawfoil · cybermax.cybermax-tools.workers.dev/games/pawfoil', W / 2, H - 22); g.textAlign = 'left'; } | |
| g.globalAlpha = 1; | |
| const text = `Meet ${c.name}: a ${c.rarityLabel} ${c.type}-type ${c.kindLabel} with ${c.hp} HP 🐾 Make your pet's card:`; | |
| const it = intents(GAME, text, PATH); | |
| $('sx').href = it.x; $('sb').href = it.bsky; $('sw').href = it.whatsapp; | |
| } | |
| const fname = () => `pawfoil-${cleanPetName($('name').value || 'pet').toLowerCase().replace(/\s+/g, '-')}.png`; | |
| $('dl').onclick = async () => { | |
| if (!photo) { toast('Add a photo first (or try the sample pet).'); return; } | |
| download(await canvasBlob(cv), fname()); count(GAME, 'win', 'download'); count(GAME, 'share', 'png'); install.offer(); | |
| }; | |
| $('share').onclick = async () => { | |
| if (!photo) { toast('Add a photo first (or try the sample pet).'); return; } | |
| const c = card(); | |
| await share(GAME, `Meet ${c.name}: a ${c.rarityLabel} ${c.type}-type ${c.kindLabel} with ${c.hp} HP 🐾`, PATH, await canvasBlob(cv), fname()); | |
| count(GAME, 'win', 'share'); install.offer(); | |
| }; | |
| ['sx', 'sb', 'sw'].forEach((id) => $(id).addEventListener('click', () => count(GAME, 'share', { sx: 'x', sb: 'bsky', sw: 'whatsapp' }[id]))); | |
| $('pass-btn').onclick = () => pass.open('header'); | |
| document.addEventListener('click', (e) => { const b = e.target.closest('[data-pass]'); if (b) pass.open(b.dataset.pass); }); | |
| pass.onChange((pro) => { if (!pro) { forced = null; $('clean').checked = false; } renderFrames(); draw(); }); | |
| // Screenshot helper for scripts/games/render_assets.mjs and studio shots (no effect on normal play). | |
| window.__cmxShot = async (stage = 1) => { | |
| await new Promise((ok) => { $('sample').click(); setTimeout(ok, 600); }); | |
| $('name').value = stage === 2 ? 'Biscuit' : 'Biscuit'; | |
| roll = 0; forced = stage === 2 ? 'legendary' : null; draw(); forced = null; | |
| }; | |
| renderKinds(); | |
| draw(); | |