CyberMax-tools commited on
Commit
d05c291
·
verified ·
1 Parent(s): c9d8394

Deploy pawfoil

Browse files
Files changed (18) hide show
  1. .gitattributes +2 -0
  2. README.md +20 -4
  3. cmx.js +179 -0
  4. core.js +62 -0
  5. fun.css +51 -0
  6. funpass.js +117 -0
  7. game.js +171 -0
  8. icon-192.png +0 -0
  9. icon-512.png +0 -0
  10. icon-maskable.png +0 -0
  11. icon.svg +1 -0
  12. index.html +62 -18
  13. manifest.webmanifest +47 -0
  14. og.png +3 -0
  15. sample.jpg +0 -0
  16. shot-phone.png +3 -0
  17. style.css +18 -28
  18. sw.js +25 -0
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ og.png filter=lfs diff=lfs merge=lfs -text
37
+ shot-phone.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,10 +1,26 @@
1
  ---
2
- title: Pawfoil
3
- emoji: 🐨
4
  colorFrom: purple
5
- colorTo: indigo
6
  sdk: static
7
  pinned: false
 
 
 
 
 
 
 
 
 
 
 
8
  ---
 
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
1
  ---
2
+ title: "Pawfoil: Pet card maker"
3
+ emoji: 🐾
4
  colorFrom: purple
5
+ colorTo: yellow
6
  sdk: static
7
  pinned: false
8
+ license: other
9
+ short_description: "Make your pet a holo trading card"
10
+ tags:
11
+ - game
12
+ - pets
13
+ - trading-card
14
+ - image-maker
15
+ - html5
16
+ - browser-game
17
+ - pwa
18
+ - cybermax
19
  ---
20
+ **Pawfoil**: Make your pet a holo trading card. A free daily game from CyberMax: the same challenge for everyone each day, a shareable result, works offline and installs on your phone (no app store, no sign-up).
21
 
22
+ Play it full screen: https://cybermax.cybermax-tools.workers.dev/games/pawfoil/?utm_source=huggingface
23
+
24
+ The optional **CyberMax Fun Pass** ($2.99/month, $19/year or $4.99 once) adds extra packs, the full archive of past dailies and colour themes in every CyberMax game: https://cybermax.cybermax-tools.workers.dev/games/?utm_source=huggingface
25
+
26
+ More free games: https://cybermax.cybermax-tools.workers.dev/games/ · © 2026 CyberMax. All rights reserved.
cmx.js ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // CyberMax Fun kit: shared browser helpers for the CyberMax games (copied into each game's web/ folder by
2
+ // scripts/games/sync_kit.py, so every game stays self-contained and sellable on its own).
3
+ // Counter beacon (game + event + channel only: no cookies, no IDs, nothing personal), daily seed, share, PWA install.
4
+ // (c) 2026 CyberMax. All rights reserved.
5
+
6
+ export const SITE = 'https://cybermax.cybermax-tools.workers.dev';
7
+ export const API = 'https://toys.cybermax-tools.workers.dev';
8
+ export const EPOCH = Date.UTC(2026, 9, 2); // 2 Oct 2026 = puzzle #1 for every CyberMax daily game
9
+
10
+ /** Today's date in the player's own time zone as YYYY-MM-DD (everyone gets a new puzzle at their local midnight). */
11
+ export function today(d = new Date()) {
12
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
13
+ }
14
+ /** Puzzle number for a YYYY-MM-DD date (#1 = 2026-10-02). */
15
+ export function dayNumber(iso) {
16
+ const [y, m, d] = iso.split('-').map(Number);
17
+ return Math.round((Date.UTC(y, m - 1, d) - EPOCH) / 864e5) + 1;
18
+ }
19
+ export function dateOf(n) {
20
+ const t = new Date(EPOCH + (n - 1) * 864e5);
21
+ return t.toISOString().slice(0, 10);
22
+ }
23
+
24
+ /** 32-bit string hash (FNV-1a) and a small fast PRNG (mulberry32): the same seed gives the same game everywhere. */
25
+ export function hash(str) {
26
+ let h = 0x811c9dc5;
27
+ for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193); }
28
+ return h >>> 0;
29
+ }
30
+ export function rng(seed) {
31
+ let a = typeof seed === 'string' ? hash(seed) : seed >>> 0;
32
+ return function () {
33
+ a = (a + 0x6d2b79f5) >>> 0;
34
+ let t = a;
35
+ t = Math.imul(t ^ (t >>> 15), t | 1);
36
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
37
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
38
+ };
39
+ }
40
+
41
+ /** Count one event (play, win, share, install, pro, cta). Never breaks the page; skipped on localhost and in tests. */
42
+ export function count(game, ev, ch = '') {
43
+ try {
44
+ if (typeof location === 'undefined' || /^(localhost|127\.0\.0\.1)$/.test(location.hostname) || navigator.webdriver) return;
45
+ const u = `${API}/e?t=${encodeURIComponent(game)}&a=${encodeURIComponent(ev)}&c=${encodeURIComponent(String(ch).toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 40))}`;
46
+ if (navigator.sendBeacon && navigator.sendBeacon(u)) return;
47
+ fetch(u, { method: 'POST', keepalive: true, mode: 'no-cors' }).catch(() => {});
48
+ } catch { /* never break the game */ }
49
+ }
50
+
51
+ export function store(key, val) {
52
+ try {
53
+ if (val === undefined) return JSON.parse(localStorage.getItem(key) || 'null');
54
+ localStorage.setItem(key, JSON.stringify(val));
55
+ } catch { /* private mode */ }
56
+ return val;
57
+ }
58
+
59
+ export function toast(msg, ms = 2400) {
60
+ let t = document.getElementById('toast');
61
+ if (!t) { t = document.createElement('div'); t.id = 'toast'; t.setAttribute('role', 'status'); document.body.appendChild(t); }
62
+ t.textContent = msg;
63
+ t.classList.add('on');
64
+ clearTimeout(t._h);
65
+ t._h = setTimeout(() => t.classList.remove('on'), ms);
66
+ }
67
+
68
+ /** The link people share: the canonical game page with UTM tags the site view counter reads. */
69
+ export function shareLink(path, channel, game) {
70
+ const u = new URL(SITE + path);
71
+ u.searchParams.set('utm_source', channel);
72
+ u.searchParams.set('utm_medium', 'share');
73
+ u.searchParams.set('utm_campaign', `game-${game}`);
74
+ return u.toString();
75
+ }
76
+
77
+ /**
78
+ * Share a result: native share sheet on phones (text + optional PNG card), else copy to clipboard.
79
+ * text = Wordle-style text block; path = the game's path on the CyberMax site; blob = optional PNG card.
80
+ */
81
+ export async function share(game, text, path, blob, filename) {
82
+ const url = shareLink(path, 'share', game);
83
+ const full = `${text}\n${url}`;
84
+ if (navigator.share) {
85
+ try {
86
+ const file = blob ? new File([blob], filename || `${game}.png`, { type: 'image/png' }) : null;
87
+ const data = file && navigator.canShare && navigator.canShare({ files: [file] }) ? { files: [file], text: full } : { text: full };
88
+ await navigator.share(data);
89
+ count(game, 'share', 'native');
90
+ return 'shared';
91
+ } catch (e) {
92
+ if (e && e.name === 'AbortError') return 'cancelled';
93
+ }
94
+ }
95
+ try { await navigator.clipboard.writeText(full); toast('Result copied. Paste it anywhere.'); } catch { prompt('Copy your result:', full); }
96
+ count(game, 'share', 'copy');
97
+ return 'copied';
98
+ }
99
+
100
+ export function intents(game, text, path) {
101
+ const u = (ch) => shareLink(path, ch, game);
102
+ return {
103
+ x: `https://x.com/intent/post?text=${encodeURIComponent(text)}&url=${encodeURIComponent(u('x'))}`,
104
+ bsky: `https://bsky.app/intent/compose?text=${encodeURIComponent(`${text}\n${u('bsky')}`)}`,
105
+ whatsapp: `https://wa.me/?text=${encodeURIComponent(`${text}\n${u('whatsapp')}`)}`,
106
+ };
107
+ }
108
+
109
+ export function download(blob, filename) {
110
+ const a = document.createElement('a');
111
+ a.href = URL.createObjectURL(blob);
112
+ a.download = filename;
113
+ document.body.appendChild(a);
114
+ a.click();
115
+ setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500);
116
+ }
117
+
118
+ /**
119
+ * Installable app: registers the service worker (offline play) and shows an "Add to Home Screen" button.
120
+ * Chrome/Edge/Android: the real install prompt. iPhone/iPad Safari: a short Share → Add to Home Screen hint.
121
+ * The button only appears after the first finished round (offer(): never on first load) and never once installed.
122
+ */
123
+ export function pwa(game, btn) {
124
+ if ('serviceWorker' in navigator && (location.protocol === 'https:' || location.hostname === 'localhost')) {
125
+ navigator.serviceWorker.register('sw.js').catch(() => {});
126
+ }
127
+ const standalone = matchMedia('(display-mode: standalone)').matches || navigator.standalone;
128
+ if (standalone) { count(game, 'play', 'app'); return { offer() {} }; }
129
+ let deferred = null;
130
+ let wanted = false;
131
+ const ios = /iphone|ipad|ipod/i.test(navigator.userAgent) && !/crios|fxios/i.test(navigator.userAgent);
132
+ window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferred = e; if (wanted && btn) btn.hidden = false; });
133
+ window.addEventListener('appinstalled', () => { count(game, 'install', 'prompt'); if (btn) btn.hidden = true; });
134
+ if (btn) {
135
+ btn.addEventListener('click', async () => {
136
+ if (deferred) {
137
+ deferred.prompt();
138
+ const r = await deferred.userChoice.catch(() => null);
139
+ if (r && r.outcome === 'accepted') btn.hidden = true;
140
+ deferred = null;
141
+ } else if (ios) {
142
+ toast('Tap the Share button, then "Add to Home Screen".', 5000);
143
+ count(game, 'install', 'ios-hint');
144
+ }
145
+ });
146
+ }
147
+ return {
148
+ offer() {
149
+ wanted = true;
150
+ if (btn && (deferred || ios) && !store(`cmx-install-no:${game}`)) btn.hidden = false;
151
+ },
152
+ };
153
+ }
154
+
155
+ /** Rounded rectangle path for canvas cards. */
156
+ export function rr(ctx, x, y, w, h, r) {
157
+ ctx.beginPath();
158
+ ctx.moveTo(x + r, y);
159
+ ctx.arcTo(x + w, y, x + w, y + h, r);
160
+ ctx.arcTo(x + w, y + h, x, y + h, r);
161
+ ctx.arcTo(x, y + h, x, y, r);
162
+ ctx.arcTo(x, y, x + w, y, r);
163
+ ctx.closePath();
164
+ }
165
+
166
+ export const canvasBlob = (canvas) => new Promise((res) => canvas.toBlob(res, 'image/png'));
167
+
168
+ /** Theme toggle shared by every page (light/dark, remembered). */
169
+ export function themeToggle(btn) {
170
+ if (!btn) return;
171
+ btn.addEventListener('click', () => {
172
+ const r = document.documentElement;
173
+ const c = r.dataset.theme || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
174
+ const n = c === 'dark' ? 'light' : 'dark';
175
+ r.dataset.theme = n;
176
+ store('cm-theme-v', n);
177
+ try { localStorage.setItem('cm-theme', n); } catch { /* private mode */ }
178
+ });
179
+ }
core.js ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Pawfoil core: turns a pet's name + kind into a collectible card's stats, moves and rarity (no DOM).
2
+ // Deterministic for a given name, kind and roll number, so a re-roll is reproducible and testable.
3
+ // (c) 2026 CyberMax. All rights reserved.
4
+ import { rng } from './cmx.js';
5
+
6
+ export const KINDS = {
7
+ dog: { label: 'Dog', emoji: '🐶', types: ['Zoomies', 'Loyal', 'Snack', 'Chaos'] },
8
+ cat: { label: 'Cat', emoji: '🐱', types: ['Shadow', 'Royal', 'Chaos', 'Nap'] },
9
+ bunny: { label: 'Bunny', emoji: '🐰', types: ['Hop', 'Fluff', 'Snack', 'Shadow'] },
10
+ bird: { label: 'Bird', emoji: '🐦', types: ['Sky', 'Song', 'Chaos', 'Royal'] },
11
+ other: { label: 'Other', emoji: '🐾', types: ['Mystery', 'Fluff', 'Chaos', 'Nap'] },
12
+ };
13
+ export const STATS = ['Cuteness', 'Chaos', 'Snack drive', 'Nap power', 'Zoomies', 'Loyalty'];
14
+ export const RARITY = [
15
+ { id: 'common', label: 'Common', p: 0.5, stars: 1 },
16
+ { id: 'rare', label: 'Rare', p: 0.3, stars: 2 },
17
+ { id: 'epic', label: 'Epic', p: 0.15, stars: 3 },
18
+ { id: 'legendary', label: 'Legendary', p: 0.05, stars: 4 },
19
+ ];
20
+ const MOVES = {
21
+ any: [['Puppy Eyes', 'Opponent forgets why they were mad.'], ['Loaf Mode', 'Becomes bread. Immune to chores.'], ['3 AM Zoomies', 'Runs laps. Wakes the house.'],
22
+ ['Sock Heist', 'Steals one sock. Never returns it.'], ['Treat Radar', 'Detects a wrapper from 3 rooms away.'], ['Lap Claim', 'Sits on you. You can never leave.'],
23
+ ['Dramatic Sigh', 'Guilt-trips everyone in range.'], ['Zoom Call Cameo', 'Appears on camera at the worst time.'], ['Vet Vanish', 'Disappears when the carrier comes out.'],
24
+ ['Sunbeam Charge', 'Recharges fully in any sunny spot.'], ['Head Tilt', 'Confuses the enemy with pure cuteness.'], ['Couch Fortress', 'Holds the best seat all day.']],
25
+ dog: [['Tail Helicopter', 'Knocks everything off the coffee table.'], ['Doorbell Alarm', 'Barks at every delivery, forever.'], ['Fetch Loop', 'Infinite fetch. You tire first.']],
26
+ cat: [['Desk Sweep', 'Pushes one item off the table, slowly.'], ['Keyboard Nap', 'Types "jjjjjjjjj" into your email.'], ['Box Fit', 'If it fits, it sits.']],
27
+ bunny: [['Binky Burst', 'A joyful jump that dodges all attacks.'], ['Cable Snip', 'Chews one charger. Critical hit.'], ['Hay Storm', 'Covers the floor in hay.']],
28
+ bird: [['Morning Concert', 'Sings at sunrise. No snooze.'], ['Shoulder Ride', 'Takes the high ground.'], ['Mimic', 'Copies the microwave beep perfectly.']],
29
+ other: [['Mystery Stare', 'Nobody knows what it is thinking.'], ['Tiny Speedrun', 'Escapes the enclosure in record time.'], ['Night Shift', 'Most active when you sleep.']],
30
+ };
31
+
32
+ export function cleanPetName(s) {
33
+ return String(s || '').normalize('NFKC').replace(/[^\p{L}\p{N} '.-]/gu, '').replace(/\s+/g, ' ').trim().slice(0, 18);
34
+ }
35
+
36
+ /** Card data for a pet. roll = 0 for the first card, 1, 2, … for re-rolls. pick() chooses rarity unless forced (Fun Pass). */
37
+ export function makeCard(name, kind = 'dog', roll = 0, forceRarity = null) {
38
+ const k = KINDS[kind] ? kind : 'other';
39
+ const r = rng(`pawfoil:${cleanPetName(name).toLowerCase()}:${k}:${roll}`);
40
+ let x = r(), rarity = RARITY[0];
41
+ for (const t of RARITY) { if (x < t.p) { rarity = t; break; } x -= t.p; }
42
+ if (forceRarity) rarity = RARITY.find((t) => t.id === forceRarity) || rarity;
43
+ const boost = { common: 0, rare: 6, epic: 12, legendary: 20 }[rarity.id];
44
+ const stats = STATS.map((s) => ({ s, v: Math.min(99, 35 + Math.floor(r() * 58) + boost) }));
45
+ stats[0].v = Math.max(stats[0].v, 88); // every pet is very cute; that is the one fixed fact
46
+ const pool = [...MOVES[k], ...MOVES.any];
47
+ const m1 = pool.splice(Math.floor(r() * MOVES[k].length), 1)[0];
48
+ const m2 = pool.splice(Math.floor(r() * pool.length), 1)[0];
49
+ const type = KINDS[k].types[Math.floor(r() * KINDS[k].types.length)];
50
+ const hp = 60 + Math.floor(r() * 9) * 10 + boost * 2;
51
+ const power = Math.round(stats.reduce((a, b) => a + b.v, 0) / stats.length);
52
+ const number = 1 + Math.floor(r() * 999);
53
+ return { name: cleanPetName(name) || 'Mystery Pet', kind: k, kindLabel: KINDS[k].label, emoji: KINDS[k].emoji, type, rarity: rarity.id, rarityLabel: rarity.label, stars: rarity.stars,
54
+ hp, power, stats, moves: [{ name: m1[0], text: m1[1], dmg: 10 * (2 + Math.floor(r() * 7)) }, { name: m2[0], text: m2[1], dmg: 10 * (3 + Math.floor(r() * 8)) }], number };
55
+ }
56
+
57
+ export const FRAMES = {
58
+ common: { a: '#E9E4D8', b: '#C9BFA8', ink: '#11141B', foil: false },
59
+ rare: { a: '#BFE3FF', b: '#5BA8E8', ink: '#0B2238', foil: false },
60
+ epic: { a: '#E5C8FF', b: '#9B5BE8', ink: '#1E0B38', foil: true },
61
+ legendary: { a: '#FFE9A8', b: '#E8A93A', ink: '#2B1A00', foil: true },
62
+ };
fun.css ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* CyberMax Fun kit: shared look for CyberMax games (copied into each game). Mobile first, light + dark. (c) 2026 CyberMax. */
2
+ :root{--bg:#F6F3EC;--card:#fff;--ink:#11141B;--muted:#565C6B;--line:#E2DCD0;--chip:#ECE6DA;--acc:#FF6A3D;--acc-ink:#11141B;--ok:#0B7F5E;--bad:#C2361B;--shadow:0 1px 2px rgba(17,20,27,.06),0 10px 30px rgba(17,20,27,.08);color-scheme:light}
3
+ @media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#0F1218;--card:#181C25;--ink:#EDEAE3;--muted:#A6ACB9;--line:#2A303C;--chip:#222734;--ok:#2FD3A0;--bad:#FF7A5C;--shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px rgba(0,0,0,.3);color-scheme:dark}}
4
+ :root[data-theme=dark]{--bg:#0F1218;--card:#181C25;--ink:#EDEAE3;--muted:#A6ACB9;--line:#2A303C;--chip:#222734;--ok:#2FD3A0;--bad:#FF7A5C;--shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px rgba(0,0,0,.3);color-scheme:dark}
5
+ *{box-sizing:border-box}[hidden]{display:none!important}html{-webkit-text-size-adjust:100%}
6
+ body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;overflow-x:hidden}
7
+ a{color:inherit}img,canvas{max-width:100%}
8
+ .wrap{max-width:760px;margin:0 auto;padding:0 16px}
9
+ .top{display:flex;align-items:center;gap:6px;padding:12px 0}
10
+ .top .brand{display:flex;align-items:center;gap:8px;font-weight:800;text-decoration:none;font-size:18px;margin-right:auto}.top .brand svg{width:28px;height:28px}
11
+ .top nav a,.top .icon{color:var(--muted);text-decoration:none;padding:8px 10px;border-radius:10px;font-size:15px;background:none;border:0;font-family:inherit;cursor:pointer;min-height:40px}
12
+ .top nav a:hover,.top .icon:hover{color:var(--ink);background:var(--chip)}
13
+ .btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:48px;padding:10px 18px;border-radius:12px;border:0;background:var(--acc);color:var(--acc-ink);font:inherit;font-weight:800;font-size:16px;cursor:pointer;text-decoration:none;touch-action:manipulation}
14
+ .btn:hover{filter:brightness(1.06)}.btn.ghost{background:transparent;color:var(--ink);border:1.5px solid var(--line)}.btn.sm{min-height:40px;padding:6px 14px;font-size:15px}
15
+ .btn.pass{background:linear-gradient(135deg,#FFB547,#FF6A3D);color:#11141B}
16
+ .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
17
+ .chips{display:flex;gap:6px;flex-wrap:wrap}
18
+ .chip{border:1.5px solid var(--line);background:var(--card);color:var(--ink);border-radius:999px;padding:6px 12px;font:inherit;font-size:14px;font-weight:700;cursor:pointer;min-height:36px}
19
+ .chip[aria-pressed=true]{background:var(--ink);color:var(--bg);border-color:var(--ink)}.chip .lock{opacity:.7;font-size:12px}
20
+ .panel{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:16px;box-shadow:var(--shadow)}
21
+ .muted{color:var(--muted)}.small{font-size:14px}
22
+ h1{font-size:clamp(26px,6vw,40px);line-height:1.08;letter-spacing:-.02em;margin:0 0 6px;text-wrap:balance}
23
+ h2{font-size:21px;letter-spacing:-.01em;margin:30px 0 8px}
24
+ .seo h3{font-size:17px;margin:18px 0 4px}.seo p,.seo li{color:var(--muted)}
25
+ .more{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin:12px 0}
26
+ .more a{display:block;padding:12px 14px;border:1px solid var(--line);border-radius:14px;background:var(--card);text-decoration:none}.more a b{display:block}.more a span{color:var(--muted);font-size:14px}
27
+ footer{color:var(--muted);font-size:14px;padding:26px 0 40px;border-top:1px solid var(--line);margin-top:36px}
28
+ #toast{position:fixed;left:50%;bottom:22px;transform:translate(-50%,40px);opacity:0;background:var(--ink);color:var(--bg);padding:12px 16px;border-radius:12px;font-weight:700;transition:.25s;z-index:99;max-width:calc(100% - 32px);text-align:center;pointer-events:none}
29
+ #toast.on{transform:translate(-50%,0);opacity:1}
30
+ .nudge{display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;border:1.5px dashed var(--line);border-radius:14px;padding:10px 12px;margin-top:12px;font-size:15px}
31
+ html.cmx-pro .nudge{display:none}
32
+ .pro-badge{display:none;font-size:12px;font-weight:800;background:linear-gradient(135deg,#FFB547,#FF6A3D);color:#11141B;border-radius:999px;padding:2px 8px;vertical-align:middle}
33
+ html.cmx-pro .pro-badge{display:inline-block}
34
+ dialog.funpass{border:0;padding:0;border-radius:20px;max-width:min(520px,calc(100% - 24px));width:100%;background:var(--card);color:var(--ink);box-shadow:0 20px 60px rgba(0,0,0,.35)}
35
+ dialog.funpass::backdrop{background:rgba(10,12,18,.55)}
36
+ .fp{padding:22px 20px 18px;position:relative}.fp h2{margin:2px 0 10px;font-size:24px}
37
+ .fp-x{position:absolute;right:10px;top:8px;border:0;background:none;color:var(--muted);font-size:28px;cursor:pointer;width:44px;height:44px}
38
+ .fp-kick{margin:0;color:var(--acc);font-weight:800;font-size:13px;letter-spacing:.06em;text-transform:uppercase}
39
+ .fp-perks{margin:0 0 14px;padding-left:20px}.fp-perks li{margin:2px 0}
40
+ .fp-plans{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
41
+ .fp-plan{position:relative;display:flex;flex-direction:column;gap:2px;padding:14px 10px 12px;border:1.5px solid var(--line);border-radius:14px;text-decoration:none;text-align:center;background:var(--bg)}
42
+ .fp-plan:hover{border-color:var(--acc)}.fp-plan.best{border-color:var(--acc);box-shadow:0 0 0 2px var(--acc) inset}
43
+ .fp-plan i{position:absolute;top:-10px;left:50%;transform:translateX(-50%);background:var(--acc);color:#11141B;font-style:normal;font-size:11px;font-weight:800;padding:2px 8px;border-radius:999px;white-space:nowrap}
44
+ .fp-plan b{font-size:14px}.fp-price{font-size:22px;font-weight:900}.fp-price small{font-size:12px;font-weight:600;color:var(--muted)}.fp-note{font-size:12px;color:var(--muted)}
45
+ .fp-small{font-size:13px;color:var(--muted);margin:10px 0 4px}
46
+ .fp-code{display:block;user-select:all;word-break:break-all;background:var(--chip);padding:8px;border-radius:8px;font-size:12px}
47
+ .fp details{margin-top:6px;font-size:14px}.fp-form{display:flex;gap:6px;margin-top:8px}.fp-form input{flex:1;min-width:0;font:inherit;font-size:14px;padding:8px 10px;border-radius:10px;border:1.5px solid var(--line);background:var(--bg);color:var(--ink)}
48
+ @media (max-width:420px){.fp-plans{grid-template-columns:1fr}.fp-plan{flex-direction:row;align-items:center;justify-content:space-between;text-align:left;padding:12px}.fp-plan i{left:auto;right:10px;transform:none}}
49
+ @media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
50
+ @media (max-width:560px){.chips{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none;margin:0 -16px;padding:2px 16px 4px}.chips::-webkit-scrollbar{display:none}.chip{flex:0 0 auto}}
51
+ @media (min-width:760px){dialog.funpass{max-width:720px}.fp-perks{columns:2;column-gap:28px}}
funpass.js ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // CyberMax Fun Pass: ONE optional supporter pass for every CyberMax game (copied into each game's web/ folder).
2
+ // Free games stay free. The pass removes the supporter nudges and unlocks extra packs, the full archive and themes.
3
+ // Monthly $2.99 · Yearly $19 · Unlock all $4.99 once (Stripe Payment Links, pack revenue/packs/stripe/fun-pass).
4
+ // Buy: counted redirect https://data.cybermax-tools.workers.dev/buy/st-fun-pass-<plan>?s=game-<id> -> Stripe checkout.
5
+ // After checkout Stripe sends the buyer to data.cybermax-tools.workers.dev/unlock/fun-pass?session_id=..., which checks
6
+ // the Checkout Session (and, for a subscription, that it is still active) and returns to /games/ with #cmx-pro=<code>.
7
+ // Every game on the same site reads the same browser key, so one pass covers them all. The code on the receipt page
8
+ // unlocks any other device or browser. No account, no cookies, nothing personal stored. (c) 2026 CyberMax.
9
+ import { count, store, toast } from './cmx.js';
10
+
11
+ const DATA = 'https://data.cybermax-tools.workers.dev';
12
+ const KEY = 'cmx-pro:fun-pass';
13
+ const SID = /^cs_(live|test)_[A-Za-z0-9]{10,200}$/;
14
+ const RECHECK = 3 * 864e5; // subscriptions are re-checked every 3 days; a cancelled pass turns off by itself
15
+ export const PLANS = [
16
+ { id: 'monthly', label: 'Monthly', price: '$2.99', per: '/month', note: 'Cancel any time' },
17
+ { id: 'yearly', label: 'Yearly', price: '$19', per: '/year', note: 'Save 47%', best: true },
18
+ { id: 'unlock', label: 'Unlock all', price: '$4.99', per: ' once', note: 'Pay once, keep forever' },
19
+ ];
20
+ export const COMMON_PERKS = ['No supporter nudges, ever', 'Extra puzzle packs in every game', 'The full archive of past dailies', 'Custom colour themes'];
21
+
22
+ let state = store(KEY);
23
+ const listeners = [];
24
+ let cfg = { game: 'games', name: 'CyberMax games', perks: [] };
25
+
26
+ export const isPro = () => !!(state && state.code);
27
+ export function onChange(fn) { listeners.push(fn); fn(isPro()); }
28
+ function emit() {
29
+ document.documentElement.classList.toggle('cmx-pro', isPro());
30
+ listeners.forEach((fn) => { try { fn(isPro()); } catch { /* game hook */ } });
31
+ }
32
+
33
+ async function check(code) {
34
+ try {
35
+ const r = await fetch(`${DATA}/unlock/fun-pass?json=1&session_id=${encodeURIComponent(code)}`);
36
+ const j = await r.json();
37
+ return j && typeof j.pro === 'boolean' ? j.pro : null;
38
+ } catch { return null; }
39
+ }
40
+
41
+ export async function unlock(code) {
42
+ const ok = await check(code);
43
+ if (ok) {
44
+ state = store(KEY, { code, at: new Date().toISOString(), checked: Date.now() });
45
+ count(cfg.game, 'pro', 'unlocked');
46
+ toast('Fun Pass is on. Thank you for supporting CyberMax games!', 3500);
47
+ emit();
48
+ render();
49
+ } else if (ok === false) toast('That code is not an active Fun Pass.', 3500);
50
+ else toast('Could not reach the pass check. Try again in a minute.', 3500);
51
+ return ok;
52
+ }
53
+
54
+ async function recheck() {
55
+ if (!isPro() || Date.now() - (state.checked || 0) < RECHECK) return;
56
+ const ok = await check(state.code);
57
+ if (ok === false) { state = null; try { localStorage.removeItem(KEY); } catch { /* */ } emit(); render(); toast('Your Fun Pass has ended. Everything free is still here.', 4000); }
58
+ else if (ok) state = store(KEY, { ...state, checked: Date.now() });
59
+ }
60
+
61
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
62
+ const buyUrl = (plan) => `${DATA}/buy/st-fun-pass-${plan}?s=game-${cfg.game}`;
63
+
64
+ function dialog() {
65
+ let d = document.getElementById('funpass');
66
+ if (!d) {
67
+ d = document.createElement('dialog');
68
+ d.id = 'funpass';
69
+ d.className = 'funpass';
70
+ document.body.appendChild(d);
71
+ d.addEventListener('click', (e) => { if (e.target === d || e.target.closest('[data-close]')) d.close(); });
72
+ }
73
+ return d;
74
+ }
75
+
76
+ function render() {
77
+ const d = dialog();
78
+ const perks = [...(cfg.perks || []), ...COMMON_PERKS.filter((p) => !(cfg.perks || []).some((q) => q.split(' ')[0] === p.split(' ')[0]))].slice(0, 6);
79
+ if (isPro()) {
80
+ d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">CyberMax Fun Pass</p><h2>You're a supporter. Thank you!</h2>
81
+ <p>The pass is on in this browser for every CyberMax game: ${perks.map(esc).join(' · ')}.</p>
82
+ <p class="fp-small">Your code (paste it on another device to turn the pass on there):</p><code class="fp-code">${esc(state.code)}</code>
83
+ <p class="fp-small">Manage or cancel a subscription any time from the link in your Stripe receipt email.</p></div>`;
84
+ return;
85
+ }
86
+ d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">CyberMax Fun Pass</p>
87
+ <h2>One pass. Every game. More to play.</h2>
88
+ <ul class="fp-perks">${perks.map((p) => `<li>${esc(p)}</li>`).join('')}</ul>
89
+ <div class="fp-plans">${PLANS.map((p) => `<a class="fp-plan${p.best ? ' best' : ''}" href="${buyUrl(p.id)}" rel="nofollow" data-plan="${p.id}">${p.best ? '<i>Best value</i>' : ''}<b>${p.label}</b><span class="fp-price">${p.price}<small>${p.per}</small></span><span class="fp-note">${p.note}</span></a>`).join('')}</div>
90
+ <p class="fp-small">Secure Stripe checkout. Works in every CyberMax game. The free daily games stay free for everyone.</p>
91
+ <details><summary>Already have a pass?</summary><form class="fp-form"><input aria-label="Pass code" autocomplete="off" placeholder="Code from your receipt page (cs_live_…)"><button class="btn sm" type="submit">Turn on</button></form></details></div>`;
92
+ d.querySelector('.fp-form').addEventListener('submit', (e) => {
93
+ e.preventDefault();
94
+ const c = e.target.querySelector('input').value.trim();
95
+ if (SID.test(c)) unlock(c); else toast('Paste the code from your receipt page. It starts with cs_live_.', 3500);
96
+ });
97
+ d.querySelectorAll('[data-plan]').forEach((a) => a.addEventListener('click', () => count(cfg.game, 'cta', `pass-${a.dataset.plan}`)));
98
+ }
99
+
100
+ /** Open the pass sheet; `from` names the button that opened it (counted as cta). */
101
+ export function open(from = 'button') {
102
+ render();
103
+ const d = dialog();
104
+ if (d.showModal) d.showModal(); else d.setAttribute('open', '');
105
+ count(cfg.game, 'cta', `pass-open-${from}`);
106
+ }
107
+
108
+ /** Call once per page: game id, display name and the game's own Pro perks (listed first). */
109
+ export function init(options) {
110
+ cfg = { ...cfg, ...options };
111
+ const m = /[#&]cmx-pro=([^&]+)/.exec(location.hash || '');
112
+ if (m && SID.test(decodeURIComponent(m[1]))) {
113
+ history.replaceState(null, '', location.pathname + location.search);
114
+ unlock(decodeURIComponent(m[1]));
115
+ } else recheck();
116
+ emit();
117
+ }
game.js ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Pawfoil: photo + name -> collectible card on a canvas, holo tilt preview, HD PNG download, share. (c) 2026 CyberMax.
2
+ // The photo is read with createImageBitmap and drawn locally; it is never uploaded.
3
+ import { makeCard, KINDS, RARITY, FRAMES, cleanPetName } from './core.js';
4
+ import { count, store, toast, share, intents, download, canvasBlob, pwa, rr, themeToggle } from './cmx.js';
5
+ import * as pass from './funpass.js';
6
+
7
+ const GAME = 'pawfoil';
8
+ const PATH = '/games/pawfoil/';
9
+ const $ = (id) => document.getElementById(id);
10
+ const cv = $('card');
11
+ const g = cv.getContext('2d');
12
+ const W = 750, H = 1050, ART = { x: 52, y: 128, w: 646, h: 430 };
13
+ let photo = null, kind = store('pawfoil:kind') || 'dog', roll = 0, forced = null, ox = 0, oy = 0, zoom = 1, made = false;
14
+
15
+ pass.init({ game: GAME, name: 'Pawfoil', perks: ['Pick any frame: Legendary gold holo on demand', 'Hide the CyberMax line on your cards'] });
16
+ const install = pwa(GAME, $('install'));
17
+ themeToggle($('theme'));
18
+
19
+ // ------------------------------------------------------------------ controls
20
+ function renderKinds() {
21
+ const el = $('kinds'); el.innerHTML = '';
22
+ for (const [id, k] of Object.entries(KINDS)) {
23
+ const b = document.createElement('button');
24
+ b.type = 'button'; b.className = 'chip'; b.textContent = `${k.emoji} ${k.label}`; b.setAttribute('aria-pressed', String(id === kind));
25
+ b.onclick = () => { kind = id; store('pawfoil:kind', id); roll = 0; renderKinds(); draw(); };
26
+ el.appendChild(b);
27
+ }
28
+ }
29
+ function renderFrames() {
30
+ const el = $('frames'); el.innerHTML = '';
31
+ for (const r of [{ id: null, label: 'Roll' }, ...RARITY]) {
32
+ const b = document.createElement('button');
33
+ b.type = 'button'; b.className = 'chip'; b.setAttribute('aria-pressed', String(forced === r.id));
34
+ b.innerHTML = `${r.label}${r.id && !pass.isPro() ? ' <span class="lock">🔒</span>' : ''}`;
35
+ b.onclick = () => { if (r.id && !pass.isPro()) { pass.open(`frame-${r.id}`); return; } forced = r.id; renderFrames(); draw(); };
36
+ el.appendChild(b);
37
+ }
38
+ $('clean').disabled = false;
39
+ }
40
+ $('clean').onchange = (e) => { if (e.target.checked && !pass.isPro()) { e.target.checked = false; pass.open('clean'); return; } draw(); };
41
+ $('name').addEventListener('input', () => { roll = 0; draw(); });
42
+ $('zoom').addEventListener('input', (e) => { zoom = Number(e.target.value); draw(); });
43
+ $('roll').onclick = () => {
44
+ roll++; draw();
45
+ const c = card();
46
+ $('rolls').textContent = `Roll ${roll + 1}: ${c.rarityLabel}${c.rarity === 'legendary' ? '! 🌟' : ''}`;
47
+ if (navigator.vibrate) try { navigator.vibrate(c.rarity === 'legendary' ? [20, 40, 60] : 10); } catch { /* */ }
48
+ };
49
+ async function loadPhoto(blob, from) {
50
+ try {
51
+ photo = await createImageBitmap(blob);
52
+ ox = 0; oy = 0; zoom = 1; $('zoom').value = 1;
53
+ if (!made) { made = true; count(GAME, 'play', from); }
54
+ draw();
55
+ } catch { toast('Could not read that image. Try a JPG or PNG.'); }
56
+ }
57
+ $('file').onchange = (e) => { const f = e.target.files[0]; if (f) loadPhoto(f, 'photo'); };
58
+ $('sample').onclick = async () => { const r = await fetch('sample.jpg'); loadPhoto(await r.blob(), 'sample'); if (!$('name').value) $('name').value = 'Biscuit'; draw(); };
59
+
60
+ // drag to move the photo inside the art box
61
+ let drag = null;
62
+ cv.addEventListener('pointerdown', (e) => { if (!photo) return; drag = { x: e.clientX, y: e.clientY, ox, oy }; cv.setPointerCapture(e.pointerId); });
63
+ cv.addEventListener('pointermove', (e) => {
64
+ if (!drag) return;
65
+ const s = W / cv.getBoundingClientRect().width;
66
+ ox = drag.ox + (e.clientX - drag.x) * s; oy = drag.oy + (e.clientY - drag.y) * s; draw();
67
+ });
68
+ cv.addEventListener('pointerup', () => { drag = null; });
69
+
70
+ // holo tilt (mouse or device tilt)
71
+ const tilt = $('tilt');
72
+ function setTilt(rx, ry) { tilt.style.transform = `perspective(900px) rotateX(${rx}deg) rotateY(${ry}deg)`; $('holo').style.backgroundPosition = `${50 + ry * 4}% ${50 - rx * 4}%`; }
73
+ 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); });
74
+ tilt.addEventListener('pointerleave', () => setTilt(0, 0));
75
+ 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))); });
76
+
77
+ // ------------------------------------------------------------------ drawing
78
+ const card = () => makeCard($('name').value || 'Mystery Pet', kind, roll, forced);
79
+ function wrap(text, x, y, maxW, lh) {
80
+ const words = text.split(' '); let line = '';
81
+ for (const w of words) { if (g.measureText(`${line}${w} `).width > maxW && line) { g.fillText(line.trim(), x, y); y += lh; line = ''; } line += `${w} `; }
82
+ g.fillText(line.trim(), x, y);
83
+ }
84
+ function draw() {
85
+ const c = card(), F = FRAMES[c.rarity];
86
+ tilt.classList.toggle('foil', F.foil);
87
+ g.clearRect(0, 0, W, H);
88
+ // frame
89
+ const grd = g.createLinearGradient(0, 0, W, H); grd.addColorStop(0, F.a); grd.addColorStop(1, F.b);
90
+ g.fillStyle = grd; rr(g, 0, 0, W, H, 40); g.fill();
91
+ g.fillStyle = 'rgba(255,255,255,.55)'; rr(g, 24, 24, W - 48, H - 48, 28); g.fill();
92
+ // header
93
+ g.fillStyle = F.ink; g.font = '900 52px system-ui, sans-serif'; g.textBaseline = 'alphabetic';
94
+ g.fillText(c.name.slice(0, 16), 52, 96);
95
+ g.textAlign = 'right'; g.font = '800 26px system-ui, sans-serif'; g.fillText('HP', W - 150, 96);
96
+ g.font = '900 52px system-ui, sans-serif'; g.fillText(String(c.hp), W - 52, 96); g.textAlign = 'left';
97
+ // art
98
+ g.save(); rr(g, ART.x, ART.y, ART.w, ART.h, 18); g.clip();
99
+ g.fillStyle = '#1B1430'; g.fillRect(ART.x, ART.y, ART.w, ART.h);
100
+ if (photo) {
101
+ const s = Math.max(ART.w / photo.width, ART.h / photo.height) * zoom;
102
+ const dw = photo.width * s, dh = photo.height * s;
103
+ g.drawImage(photo, ART.x + (ART.w - dw) / 2 + ox, ART.y + (ART.h - dh) / 2 + oy, dw, dh);
104
+ } else {
105
+ g.fillStyle = '#ffffff22'; g.font = '200px system-ui, "Apple Color Emoji", "Segoe UI Emoji", sans-serif'; g.textAlign = 'center';
106
+ g.fillText(KINDS[c.kind].emoji, W / 2, ART.y + 280); g.font = '700 28px system-ui, sans-serif'; g.fillStyle = '#ffffffaa';
107
+ g.fillText('Add a photo of your pet', W / 2, ART.y + 380); g.textAlign = 'left';
108
+ }
109
+ if (F.foil) { // printed holo sheen
110
+ const h = g.createLinearGradient(ART.x, ART.y, ART.x + ART.w, ART.y + ART.h);
111
+ ['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));
112
+ g.globalCompositeOperation = 'screen'; g.fillStyle = h; g.fillRect(ART.x, ART.y, ART.w, ART.h); g.globalCompositeOperation = 'source-over';
113
+ }
114
+ g.restore();
115
+ g.strokeStyle = F.b; g.lineWidth = 6; rr(g, ART.x, ART.y, ART.w, ART.h, 18); g.stroke();
116
+ // type banner
117
+ g.fillStyle = F.ink; rr(g, 52, 576, W - 104, 50, 25); g.fill();
118
+ g.fillStyle = F.a; g.font = '800 26px system-ui, sans-serif';
119
+ g.fillText(`${c.emoji} ${c.type}-type ${c.kindLabel} · ${'★'.repeat(c.stars)} ${c.rarityLabel}`, 76, 610);
120
+ // moves
121
+ g.fillStyle = F.ink;
122
+ c.moves.forEach((m, i) => {
123
+ const y = 680 + i * 104;
124
+ g.font = '900 32px system-ui, sans-serif'; g.fillText(m.name, 60, y);
125
+ g.textAlign = 'right'; g.fillText(String(m.dmg), W - 60, y); g.textAlign = 'left';
126
+ g.font = '500 23px system-ui, sans-serif'; g.globalAlpha = 0.85; wrap(m.text, 60, y + 32, W - 140, 28); g.globalAlpha = 1;
127
+ });
128
+ // stats
129
+ c.stats.forEach((st, i) => {
130
+ const col = i % 3, row = Math.floor(i / 3), x = 60 + col * 214, y = 892 + row * 50;
131
+ g.font = '700 19px system-ui, sans-serif'; g.fillText(st.s, x, y);
132
+ g.textAlign = 'right'; g.font = '900 19px system-ui, sans-serif'; g.fillText(String(st.v), x + 180, y); g.textAlign = 'left';
133
+ g.fillStyle = 'rgba(0,0,0,.12)'; rr(g, x, y + 8, 180, 12, 6); g.fill();
134
+ g.globalAlpha = 0.8; g.fillStyle = F.ink; rr(g, x, y + 8, 180 * st.v / 99, 12, 6); g.fill(); g.globalAlpha = 1;
135
+ });
136
+ // footer
137
+ g.font = '800 18px system-ui, sans-serif'; g.globalAlpha = 0.75;
138
+ g.fillText(`#${String(c.number).padStart(3, '0')}/999`, 60, H - 48);
139
+ g.textAlign = 'right'; g.fillText(`Power ${c.power}`, W - 60, H - 48); g.textAlign = 'left';
140
+ 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'; }
141
+ g.globalAlpha = 1;
142
+ const text = `Meet ${c.name}: a ${c.rarityLabel} ${c.type}-type ${c.kindLabel} with ${c.hp} HP 🐾 Make your pet's card:`;
143
+ const it = intents(GAME, text, PATH);
144
+ $('sx').href = it.x; $('sb').href = it.bsky; $('sw').href = it.whatsapp;
145
+ }
146
+
147
+ const fname = () => `pawfoil-${cleanPetName($('name').value || 'pet').toLowerCase().replace(/\s+/g, '-')}.png`;
148
+ $('dl').onclick = async () => {
149
+ if (!photo) { toast('Add a photo first (or try the sample pet).'); return; }
150
+ download(await canvasBlob(cv), fname()); count(GAME, 'win', 'download'); count(GAME, 'share', 'png'); install.offer();
151
+ };
152
+ $('share').onclick = async () => {
153
+ if (!photo) { toast('Add a photo first (or try the sample pet).'); return; }
154
+ const c = card();
155
+ await share(GAME, `Meet ${c.name}: a ${c.rarityLabel} ${c.type}-type ${c.kindLabel} with ${c.hp} HP 🐾`, PATH, await canvasBlob(cv), fname());
156
+ count(GAME, 'win', 'share'); install.offer();
157
+ };
158
+ ['sx', 'sb', 'sw'].forEach((id) => $(id).addEventListener('click', () => count(GAME, 'share', { sx: 'x', sb: 'bsky', sw: 'whatsapp' }[id])));
159
+ $('pass-btn').onclick = () => pass.open('header');
160
+ document.addEventListener('click', (e) => { const b = e.target.closest('[data-pass]'); if (b) pass.open(b.dataset.pass); });
161
+ pass.onChange((pro) => { if (!pro) { forced = null; $('clean').checked = false; } renderFrames(); draw(); });
162
+
163
+ // Screenshot helper for scripts/games/render_assets.mjs and studio shots (no effect on normal play).
164
+ window.__cmxShot = async (stage = 1) => {
165
+ await new Promise((ok) => { $('sample').click(); setTimeout(ok, 600); });
166
+ $('name').value = stage === 2 ? 'Biscuit' : 'Biscuit';
167
+ roll = 0; forced = stage === 2 ? 'legendary' : null; draw(); forced = null;
168
+ };
169
+
170
+ renderKinds();
171
+ draw();
icon-192.png ADDED
icon-512.png ADDED
icon-maskable.png ADDED
icon.svg ADDED
index.html CHANGED
@@ -1,19 +1,63 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
3
+ <title>Pawfoil: free pet trading card maker, holo cards in seconds | CyberMax</title>
4
+ <meta name="description" content="Turn your pet's photo into a shiny collectible trading card with funny stats and moves. Free HD download, no sign-up, no watermark grab: your photo never leaves your phone.">
5
+ <link rel="canonical" href="https://cybermax.cybermax-tools.workers.dev/games/pawfoil/">
6
+ <meta property="og:type" content="website"><meta property="og:site_name" content="CyberMax">
7
+ <meta property="og:title" content="Pawfoil: make your pet a holo trading card">
8
+ <meta property="og:description" content="Photo in, collectible card out: stats, two funny moves, a rarity roll. Free HD download.">
9
+ <meta property="og:url" content="https://cybermax.cybermax-tools.workers.dev/games/pawfoil/">
10
+ <meta property="og:image" content="https://cybermax.cybermax-tools.workers.dev/games/pawfoil/og.png"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">
11
+ <meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="https://cybermax.cybermax-tools.workers.dev/games/pawfoil/og.png">
12
+ <meta name="theme-color" content="#1B1430">
13
+ <link rel="manifest" href="manifest.webmanifest">
14
+ <link rel="icon" href="icon.svg" type="image/svg+xml"><link rel="apple-touch-icon" href="icon-192.png">
15
+ <meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="Pawfoil">
16
+ <link rel="stylesheet" href="fun.css"><link rel="stylesheet" href="style.css">
17
+ <script>try{var t=localStorage.getItem('cm-theme');if(t)document.documentElement.dataset.theme=t}catch(e){}</script>
18
+ <script type="application/ld+json">{"@context":"https://schema.org","@type":"WebApplication","name":"Pawfoil","url":"https://cybermax.cybermax-tools.workers.dev/games/pawfoil/","image":"https://cybermax.cybermax-tools.workers.dev/games/pawfoil/og.png","description":"Free pet trading card maker: your pet's photo becomes a collectible card with stats, moves and a rarity roll. Runs in the browser; photos never leave the device.","applicationCategory":"MultimediaApplication","operatingSystem":"Any","publisher":{"@type":"Organization","name":"CyberMax","url":"https://cybermax.cybermax-tools.workers.dev/"},"offers":[{"@type":"Offer","price":"0","priceCurrency":"USD","description":"Free HD card"},{"@type":"Offer","price":"2.99","priceCurrency":"USD","description":"CyberMax Fun Pass, monthly"}]}</script>
19
+ </head><body>
20
+ <div class="wrap wide">
21
+ <header class="top"><a class="brand" href="https://cybermax.cybermax-tools.workers.dev/games/"><svg viewBox="0 0 512 512" aria-hidden="true"><clipPath id="lg"><rect width="512" height="512" rx="116"/></clipPath><g clip-path="url(#lg)"><rect width="512" height="512" fill="#FF6A3D"/><path d="M512 250V512H250Z" fill="#D9481F"/></g><g transform="translate(86 86) scale(3.4)"><path d="M66 27 A27 27 0 1 0 66 73" fill="none" stroke="#11141B" stroke-width="17" stroke-linecap="round"/><rect x="66" y="41" width="20" height="18" rx="4" fill="#11141B"/></g></svg>CyberMax</a>
22
+ <nav><a href="https://cybermax.cybermax-tools.workers.dev/games/">Games</a></nav><button class="icon" id="theme" type="button" aria-label="Switch light or dark">◐</button></header>
23
+ <main>
24
+ <h1>Make your pet a holo trading card <span class="pro-badge">PASS</span></h1>
25
+ <p class="muted lede">Add a photo, type a name, roll for rarity. Stats and moves are made for your pet. Free HD download; your photo never leaves this device.</p>
26
+ <div class="maker">
27
+ <section class="controls panel">
28
+ <label class="photo"><input type="file" id="file" accept="image/*"><span class="btn">📷 Choose a photo</span></label>
29
+ <button class="btn ghost sm" id="sample" type="button">Try a sample pet (AI image)</button>
30
+ <label class="field">Pet's name <input id="name" maxlength="18" placeholder="e.g. Biscuit" autocomplete="off"></label>
31
+ <div class="chips" id="kinds" role="group" aria-label="Kind of pet"></div>
32
+ <div class="row adj"><label>Zoom <input id="zoom" type="range" min="1" max="3" step="0.01" value="1"></label><span class="muted small">Drag the photo on the card to move it.</span></div>
33
+ <div class="row"><button class="btn" id="roll" type="button">🎲 Roll again</button><span class="muted small" id="rolls"></span></div>
34
+ <div class="pro-box"><b>Fun Pass extras</b>
35
+ <div class="chips" id="frames" role="group" aria-label="Pick a frame"></div>
36
+ <label class="small"><input type="checkbox" id="clean"> Hide the CyberMax line on the card</label>
37
+ </div>
38
+ </section>
39
+ <section class="preview">
40
+ <div class="tilt" id="tilt"><canvas id="card" width="750" height="1050" aria-label="Your pet's trading card"></canvas><div class="holo" id="holo"></div></div>
41
+ <div class="row share"><button class="btn" id="dl" type="button">⬇ Download HD card</button><button class="btn ghost" id="share" type="button">Share</button><a class="btn ghost sm" id="sx" target="_blank" rel="noopener">X</a><a class="btn ghost sm" id="sb" target="_blank" rel="noopener">Bluesky</a><a class="btn ghost sm" id="sw" target="_blank" rel="noopener">WhatsApp</a></div>
42
+ <div class="row"><button class="btn ghost sm" id="install" type="button" hidden>📲 Add to Home Screen</button><button class="btn pass sm" id="pass-btn" type="button">✨ Fun Pass</button></div>
43
+ </section>
44
+ </div>
45
+ <div class="nudge"><span>Want a Legendary every time? The <b>Fun Pass</b> lets you pick any frame, hides the CyberMax line and works in every CyberMax game.</span><button class="btn pass sm" data-pass="nudge" type="button">See the pass</button></div>
46
+
47
+ <section class="seo">
48
+ <h2>How Pawfoil works</h2>
49
+ <p>Pick a photo of your dog, cat, bunny, bird or any pet. Pawfoil frames it as a collectible trading card with HP, six stats (Cuteness is always high), a type and two funny moves like <i>Sock Heist</i> or <i>3 AM Zoomies</i>. Each roll draws a rarity: Common, Rare, Epic (holo) or Legendary (gold holo). Roll again until you love it, then download the HD PNG or share it.</p>
50
+ <h3>Private: your photo stays on your device</h3>
51
+ <p>The card is drawn in your browser. Your photo is never uploaded, stored or seen by anyone but you. No account, no email.</p>
52
+ <h3>Free, with an optional Fun Pass</h3>
53
+ <p>Every card is free in full HD. The optional <b>CyberMax Fun Pass</b> ($2.99/month, $19/year, or $4.99 once) lets you pick any frame (Legendary on demand), hides the small CyberMax line on the card, and unlocks extras in every CyberMax game.</p>
54
+ <h3>FAQ</h3>
55
+ <p><b>Can I print it?</b> Yes: the download is 750×1050 px, a standard 2.5×3.5 inch card at 300 dpi. <b>Why not an AI card generator?</b> Pawfoil uses your real photo (no strange AI faces), is instant, works offline and needs no sign-up. <b>Is it really free?</b> Yes, the HD download is free; the pass only adds extras.</p>
56
+ <h2>More free games from CyberMax</h2>
57
+ <div class="more"><a href="https://cybermax.cybermax-tools.workers.dev/games/kenwren/"><b>Kenwren</b><span>How well do your friends know you?</span></a><a href="https://cybermax.cybermax-tools.workers.dev/games/pathmoor/"><b>Pathmoor</b><span>Daily path puzzle</span></a><a href="https://cybermax.cybermax-tools.workers.dev/games/whirlane/"><b>Whirlane</b><span>One-tap orbit dodger</span></a></div>
58
+ </section>
59
+ </main>
60
+ <footer>© 2026 CyberMax · <a href="https://cybermax.cybermax-tools.workers.dev/games/">Games</a> · <a href="https://cybermax.cybermax-tools.workers.dev/toys/">Toys</a> · <a href="https://cybermax.cybermax-tools.workers.dev/store/">Store</a> · <a href="https://cybermax.cybermax-tools.workers.dev/privacy">Privacy</a> · <a href="https://cybermax.cybermax-tools.workers.dev/terms">Terms</a></footer>
61
+ </div>
62
+ <script type="module" src="game.js"></script>
63
+ </body></html>
manifest.webmanifest ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Pawfoil: Make your pet a holo trading card",
3
+ "short_name": "Pawfoil",
4
+ "description": "Make your pet a holo trading card. Free daily game by CyberMax, works offline.",
5
+ "id": "index.html",
6
+ "start_url": "index.html?utm_source=pwa-hf",
7
+ "scope": "./",
8
+ "display": "standalone",
9
+ "orientation": "portrait",
10
+ "background_color": "#1B1430",
11
+ "theme_color": "#1B1430",
12
+ "categories": [
13
+ "games",
14
+ "entertainment"
15
+ ],
16
+ "icons": [
17
+ {
18
+ "src": "icon-192.png",
19
+ "sizes": "192x192",
20
+ "type": "image/png"
21
+ },
22
+ {
23
+ "src": "icon-512.png",
24
+ "sizes": "512x512",
25
+ "type": "image/png"
26
+ },
27
+ {
28
+ "src": "icon-maskable.png",
29
+ "sizes": "512x512",
30
+ "type": "image/png",
31
+ "purpose": "maskable"
32
+ },
33
+ {
34
+ "src": "icon.svg",
35
+ "sizes": "any",
36
+ "type": "image/svg+xml"
37
+ }
38
+ ],
39
+ "screenshots": [
40
+ {
41
+ "src": "shot-phone.png",
42
+ "sizes": "780x1688",
43
+ "type": "image/png",
44
+ "form_factor": "narrow"
45
+ }
46
+ ]
47
+ }
og.png ADDED

Git LFS Details

  • SHA256: c990f6e8cf19e93a07f07fcd28713b3cdac45f906d9515ec5f6eeb96e4517809
  • Pointer size: 131 Bytes
  • Size of remote file: 115 kB
sample.jpg ADDED
shot-phone.png ADDED

Git LFS Details

  • SHA256: f4a1a131be85313ba3500319a07c4935c5e4ea88d8e1c9f0f3b3672f924ea453
  • Pointer size: 131 Bytes
  • Size of remote file: 160 kB
style.css CHANGED
@@ -1,28 +1,18 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
- }
5
-
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
- }
10
-
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
- }
17
-
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
- }
25
-
26
- .card p:last-child {
27
- margin-bottom: 0;
28
- }
 
1
+ /* Pawfoil layout. (c) 2026 CyberMax. */
2
+ .wrap.wide{max-width:1040px}
3
+ .lede{font-size:17px;margin:0 0 14px}
4
+ .maker{display:grid;grid-template-columns:1fr;gap:16px;align-items:start}
5
+ @media (min-width:860px){.maker{grid-template-columns:minmax(300px,380px) 1fr}.preview{position:sticky;top:12px}}
6
+ .controls{display:flex;flex-direction:column;gap:12px}
7
+ .photo input{position:absolute;width:1px;height:1px;opacity:0}.photo .btn{width:100%}
8
+ .field{display:flex;flex-direction:column;gap:4px;font-weight:700;font-size:14px}
9
+ .field input{font:inherit;font-size:18px;padding:11px 12px;border-radius:12px;border:1.5px solid var(--line);background:var(--bg);color:var(--ink)}
10
+ .adj label{display:flex;align-items:center;gap:8px;font-weight:700;font-size:14px}.adj input{width:150px}
11
+ .pro-box{border:1.5px dashed var(--line);border-radius:14px;padding:10px 12px;display:flex;flex-direction:column;gap:8px}
12
+ .preview{display:flex;flex-direction:column;align-items:center;gap:10px}
13
+ .tilt{position:relative;width:min(100%,420px);aspect-ratio:750/1050;border-radius:22px;transform-style:preserve-3d;transition:transform .12s ease-out;will-change:transform;touch-action:none}
14
+ .tilt canvas{width:100%;height:100%;border-radius:22px;box-shadow:0 18px 50px rgba(0,0,0,.35);cursor:grab;display:block}
15
+ .holo{position:absolute;inset:0;border-radius:22px;pointer-events:none;mix-blend-mode:color-dodge;opacity:0;background:linear-gradient(115deg,transparent 20%,rgba(255,0,170,.35) 35%,rgba(0,255,230,.35) 50%,rgba(255,230,0,.35) 65%,transparent 80%);background-size:250% 250%;transition:opacity .3s}
16
+ .tilt.foil .holo{opacity:.55}
17
+ .share{justify-content:center}
18
+ .controls .chips{flex-wrap:wrap!important;overflow:visible!important;margin:0!important;padding:0!important}
 
 
 
 
 
 
 
 
 
 
sw.js ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // CyberMax game service worker: the game works offline after the first visit. Cache-first for the game's own files,
2
+ // network-first for the page itself (so updates arrive), never caches API calls or other sites. (c) 2026 CyberMax.
3
+ const GAME = 'pawfoil';
4
+ const CACHE = `cmx-${GAME}-da5d827006`;
5
+ const FILES = ["index.html", "cmx.js", "core.js", "fun.css", "funpass.js", "game.js", "icon-192.png", "icon-512.png", "icon-maskable.png", "icon.svg", "index.html", "manifest.webmanifest", "sample.jpg", "style.css"];
6
+
7
+ self.addEventListener('install', (e) => {
8
+ e.waitUntil(caches.open(CACHE).then((c) => c.addAll(FILES)).then(() => self.skipWaiting()));
9
+ });
10
+ self.addEventListener('activate', (e) => {
11
+ e.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((k) => k.startsWith(`cmx-${GAME}-`) && k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim()));
12
+ });
13
+ self.addEventListener('fetch', (e) => {
14
+ const u = new URL(e.request.url);
15
+ if (e.request.method !== 'GET' || u.origin !== location.origin || !u.pathname.startsWith(new URL(self.registration.scope).pathname)) return;
16
+ if (e.request.mode === 'navigate') {
17
+ e.respondWith(fetch(e.request).then((r) => { const c = r.clone(); caches.open(CACHE).then((x) => x.put(u.pathname, c)); return r; })
18
+ .catch(() => caches.match(u.pathname).then((r) => r || caches.match('index.html'))));
19
+ return;
20
+ }
21
+ e.respondWith(caches.match(e.request, { ignoreSearch: true }).then((r) => r || fetch(e.request).then((res) => {
22
+ if (res.ok) { const c = res.clone(); caches.open(CACHE).then((x) => x.put(e.request, c)); }
23
+ return res;
24
+ })));
25
+ });