pawfoil / game.js
CyberMax-tools's picture
Deploy pawfoil
d05c291 verified
Raw History Blame
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();