// 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() ? ' ๐Ÿ”’' : ''}`; 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 ยท cybermaxtools.com/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();