From eb97d90f105bcb060291d08113db28e226128a4c Mon Sep 17 00:00:00 2001 From: Time Date: Sun, 18 Oct 2026 17:59:04 +0000 Subject: [PATCH] card #149: update ventures/box-art-night/app.js --- ventures/box-art-night/app.js | 176 ++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 ventures/box-art-night/app.js diff --git a/ventures/box-art-night/app.js b/ventures/box-art-night/app.js new file mode 100644 index 0000000..3b71813 --- /dev/null +++ b/ventures/box-art-night/app.js @@ -0,0 +1,176 @@ +'use strict'; +// Cartridge: everything runs in this page. No network calls, no libraries. +const W = 600, H = 840; +const STYLES = { + snes: { label: '16-BIT', bg: '#c5c3cf', ink: '#1b1630', band: '#4b3a8c', accent: '#e0433a', bandInk: '#ffffff', font: 'Impact, "Arial Black", sans-serif', px: 150, smooth: false, levels: 5 }, + ps1: { label: '32-BIT CD', bg: '#0b0b0f', ink: '#f2f2f2', band: '#2a2d3a', accent: '#9aa7c7', bandInk: '#e8ecf8', font: '"Arial Black", Impact, sans-serif', px: 320, smooth: true, levels: 8 }, + hand: { label: 'HANDHELD', bg: '#a9b08c', ink: '#1e2a14', band: '#3b4a2a', accent: '#b3263e', bandInk: '#dbe5b0', font: '"Courier New", monospace', px: 96, smooth: false, pal: [[15, 56, 15], [48, 98, 48], [139, 172, 15], [155, 188, 15]] } +}; +const RATINGS = [['E', 'EVERYONE'], ['T', 'TEEN'], ['M', 'MATURE'], ['?', 'RATING PENDING']]; + +// Pure helpers (self-checked in test.js). +function hash(s) { let h = 2166136261; for (const c of s) { h ^= c.codePointAt(0); h = Math.imul(h, 16777619); } return h >>> 0; } +function parseStats(t, n) { + return t.split('\n').map(l => l.trim()).filter(Boolean).slice(0, n).map(l => { + const i = l.indexOf(':'); + return i < 0 ? ['', l] : [l.slice(0, i).trim(), l.slice(i + 1).trim()]; + }); +} +// shortcut: a single word wider than max is not broken, upgrade if long unbroken strings matter. +function wrap(measure, text, max, maxLines) { + const lines = []; let cur = ''; + for (const w of text.split(/\s+/).filter(Boolean)) { + const t = cur ? cur + ' ' + w : w; + if (!cur || measure(t) <= max) cur = t; else { lines.push(cur); cur = w; } + } + if (cur) lines.push(cur); + if (lines.length > maxLines) { + lines.length = maxLines; + let l = lines[maxLines - 1]; + while (l && measure(l + '…') > max) l = l.slice(0, -1); + lines[maxLines - 1] = l.trimEnd() + '…'; + } + return lines; +} +if (typeof module !== 'undefined') module.exports = { hash, parseStats, wrap }; + +if (typeof document !== 'undefined') (function () { + const $ = id => document.getElementById(id); + const cv = $('cv'), form = document.querySelector('#try form'), status = $('status'); + let photo = null; + + // Photo cropped to w x h, reduced to the console's resolution and palette, then scaled up. + function art(g, x, y, w, h, st, zoom, fx, fy) { + const sw = Math.max(8, Math.round(st.px * w / W)), sh = Math.max(8, Math.round(sw * h / w)); + const c = document.createElement('canvas'); c.width = sw; c.height = sh; + const k = c.getContext('2d'); + k.fillStyle = '#fff'; k.fillRect(0, 0, sw, sh); + const s = Math.max(sw / photo.width, sh / photo.height) * zoom; + k.drawImage(photo, (sw - photo.width * s) * fx, (sh - photo.height * s) * fy, photo.width * s, photo.height * s); + const d = k.getImageData(0, 0, sw, sh), a = d.data; + for (let i = 0; i < a.length; i += 4) { + if (st.pal) { + const l = (0.299 * a[i] + 0.587 * a[i + 1] + 0.114 * a[i + 2]) / 255, p = st.pal[Math.min(3, (l * 4) | 0)]; + a[i] = p[0]; a[i + 1] = p[1]; a[i + 2] = p[2]; + } else { + const q = 255 / (st.levels - 1); + for (let j = 0; j < 3; j++) a[i + j] = Math.round(a[i + j] / q) * q; + } + } + k.putImageData(d, 0, 0); + g.imageSmoothingEnabled = st.smooth; + g.drawImage(c, x, y, w, h); + } + function fit(g, text, font, max, size) { + for (; size > 12; size -= 2) { g.font = size + 'px ' + font; if (g.measureText(text).width <= max) break; } + } + function top(g, m, st) { + g.fillStyle = st.bg; g.fillRect(0, 0, W, H); + g.fillStyle = st.band; g.fillRect(0, 0, W, 84); + g.fillStyle = st.accent; g.fillRect(0, 84, W, 8); + g.textBaseline = 'middle'; g.fillStyle = st.bandInk; + } + function front(g, m) { + const st = m.st; + top(g, m, st); + g.textAlign = 'left'; g.font = 'bold 34px ' + st.font; g.fillText(st.label, 30, 44); + g.textAlign = 'right'; fit(g, 'PLAYER 1: ' + m.player.toUpperCase(), st.font, 300, 22); g.fillText('PLAYER 1: ' + m.player.toUpperCase(), 570, 44); + g.fillStyle = st.ink; g.fillRect(24, 112, 552, 552); + art(g, 30, 118, 540, 540, st, m.zoom, m.fx, m.fy); + const r = RATINGS[m.rating], x = 42, y = 530; + g.fillStyle = '#fff'; g.fillRect(x, y, 84, 114); + g.strokeStyle = '#000'; g.lineWidth = 4; g.strokeRect(x + 2, y + 2, 80, 110); + g.fillStyle = '#000'; g.textAlign = 'center'; + g.font = 'bold 70px Arial, sans-serif'; g.fillText(r[0], x + 42, y + 52); + g.font = 'bold 11px Arial, sans-serif'; g.fillText(r[1], x + 42, y + 98, 74); + g.fillStyle = st.band; g.fillRect(24, 684, 552, 96); + g.fillStyle = st.bandInk; fit(g, m.title, st.font, 520, 76); g.fillText(m.title, W / 2, 733); + g.fillStyle = st.ink; fit(g, m.tag, st.font, 552, 24); g.fillText(m.tag, W / 2, 812); + } + function shot(g, x, y, w, h, m, zoom, fx, fy, n) { + const st = m.st; + g.fillStyle = st.ink; g.fillRect(x, y, w, h); + art(g, x + 3, y + 3, w - 6, h - 6, st, zoom, fx, fy); + g.save(); g.beginPath(); g.rect(x + 3, y + 3, w - 6, h - 6); g.clip(); + g.font = 'bold 16px "Courier New", monospace'; g.textBaseline = 'top'; g.textAlign = 'left'; + const t = (s, tx, ty) => { g.fillStyle = '#000'; g.fillText(s, tx + 1, ty + 1); g.fillStyle = '#fff'; g.fillText(s, tx, ty); }; + t('P1 ' + m.player.slice(0, 10).toUpperCase(), x + 10, y + 10); + t('SCORE ' + String((hash(m.title) * (n + 1)) % 1000000).padStart(6, '0'), x + 10, y + h - 26); + g.fillStyle = '#e0433a'; for (let i = 0; i < 3; i++) g.fillRect(x + w - 24 - i * 18, y + 12, 12, 12); + g.restore(); g.textBaseline = 'middle'; + } + function back(g, m) { + const st = m.st; + top(g, m, st); + g.textAlign = 'left'; fit(g, m.title, st.font, 380, 40); g.fillText(m.title, 30, 44); + g.textAlign = 'right'; g.font = 'bold 22px ' + st.font; g.fillText(st.label, 570, 44); + shot(g, 30, 112, 540, 230, m, m.zoom * 1.6, 0.3, 0.4, 0); + shot(g, 30, 358, 262, 150, m, m.zoom * 2.4, 0.8, 0.25, 1); + shot(g, 308, 358, 262, 150, m, m.zoom * 2.4, 0.5, 0.95, 2); + g.fillStyle = st.ink; g.textAlign = 'left'; g.textBaseline = 'top'; g.font = '20px Arial, sans-serif'; + wrap(s => g.measureText(s).width, m.blurb, 540, 4).forEach((l, i) => g.fillText(l, 30, 526 + i * 26)); + g.fillStyle = st.band; g.fillRect(30, 664, 410, 140); + g.fillStyle = st.bandInk; g.font = '19px "Courier New", monospace'; + m.stats.forEach(([a, b], i) => { + g.textAlign = 'left'; g.fillText(a.toUpperCase(), 44, 678 + i * 30, 150); + g.textAlign = 'right'; g.fillText(b, 426, 678 + i * 30, 220); + }); + g.fillStyle = '#fff'; g.fillRect(460, 664, 110, 140); + g.fillStyle = '#000'; let s = hash(m.title) || 1; + for (let x = 470; x < 560; ) { s = Math.imul(s, 1664525) + 1013904223 >>> 0; const w = 2 + (s >>> 28) % 4; g.fillRect(x, 676, w, 100); x += w + 2 + (s >>> 24) % 3; } + g.textBaseline = 'middle'; + } + + function read() { + const f = form.elements; + return { + st: STYLES[f.style.value], title: (f.ttl.value.trim() || 'Untitled').toUpperCase(), + player: f.player.value.trim() || 'Player', tag: f.tag.value.trim(), blurb: f.blurb.value.trim(), + stats: parseStats(f.stats.value, 4), rating: +f.rating.value, + zoom: +f.zoom.value, fx: +f.fx.value, fy: +f.fy.value + }; + } + function render() { + if (!photo) return; + const g = cv.getContext('2d'), m = read(); + g.setTransform(1, 0, 0, 1, 0, 0); + g.fillStyle = '#14141c'; g.fillRect(0, 0, cv.width, cv.height); + g.save(); g.translate(20, 20); front(g, m); g.restore(); + g.save(); g.translate(660, 20); back(g, m); g.restore(); + } + let queued = false; + const later = () => queued || (queued = requestAnimationFrame(() => { queued = false; render(); })); + + // Same path for a user's file and the built-in sample. + function loadPhoto(blob, label) { + return new Promise(res => { + const url = URL.createObjectURL(blob), img = new Image(); + img.onload = () => { photo = img; URL.revokeObjectURL(url); status.textContent = label; render(); res(); }; + img.onerror = () => { URL.revokeObjectURL(url); status.textContent = 'That file could not be read as an image.'; res(); }; + img.src = url; + }); + } + const pick = f => f && loadPhoto(f, 'Using ' + f.name + '. It stays in this page.'); + + function save(kind) { + let c = cv; + if (kind) { c = document.createElement('canvas'); c.width = W; c.height = H; (kind === 'front' ? front : back)(c.getContext('2d'), read()); } + const name = (read().title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'cartridge') + '-' + (kind || 'front-back') + '.png'; + c.toBlob(b => { + const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = name; a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1e4); + }, 'image/png'); + } + + form.addEventListener('submit', e => e.preventDefault()); + form.addEventListener('input', later); + $('file').addEventListener('change', e => pick(e.target.files[0])); + const tryEl = $('try'); + tryEl.addEventListener('dragover', e => { e.preventDefault(); tryEl.classList.add('over'); }); + tryEl.addEventListener('dragleave', () => tryEl.classList.remove('over')); + tryEl.addEventListener('drop', e => { e.preventDefault(); tryEl.classList.remove('over'); pick(e.dataTransfer.files[0]); }); + document.querySelectorAll('[data-k]').forEach(b => b.addEventListener('click', () => save(b.dataset.k))); + + const SAMPLE = ''; + loadPhoto(new Blob([SAMPLE], { type: 'image/svg+xml' }), 'Showing the built-in sample image. Drop your own photo to replace it.'); +})();