// Prism Puddle: a tiny fictional demo game for storebound's screenshot and video pipeline. Everything is drawn // procedurally on a canvas (no image files). Colored drops fall into puddles; tap a puddle to change its color so the // next drop matches. Matches splash, build a combo and fill the level meter. // // URL switches (what a real game needs for store captures): // scene=play|combo|map|stars which screen lang=en|tr|ja|ar UI language // seed= fixed randomness t= freeze at this moment (screenshots; with auto=1: start there) // auto=1 autoplay, runs live (video clips) level= level number shown // window.game.ready turns true after the first frame is drawn (storebound waits for it). (() => { 'use strict'; const q = new URLSearchParams(location.search); const SCENE = q.get('scene') || 'play'; const LANG = (q.get('lang') || 'en').split('-')[0]; const AUTO = q.get('auto') === '1'; const FREEZE = q.has('t') ? Math.max(0, Number(q.get('t'))) : null; const LEVEL = Number(q.get('level') || 12); let seed = Number(q.get('seed') || 7) >>> 0 || 7; const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296); const T = { en: { level: 'Level', best: 'Best', clear: 'Level clear!', next: 'Next', daily: 'Daily drop' }, tr: { level: 'Bölüm', best: 'En iyi', clear: 'Bölüm tamam!', next: 'Sonraki', daily: 'Günün damlası' }, ja: { level: 'ステージ', best: 'ベスト', clear: 'クリア!', next: 'つぎへ', daily: 'デイリー' }, ar: { level: 'المرحلة', best: 'الأفضل', clear: 'أحسنت!', next: 'التالي', daily: 'تحدي اليوم' }, }[LANG] || null; const L = T || { level: 'Level', best: 'Best', clear: 'Level clear!', next: 'Next', daily: 'Daily drop' }; const RTL = LANG === 'ar'; const FONT = '"Arial Rounded MT Bold", "Hiragino Maru Gothic ProN", "Segoe UI", system-ui, sans-serif'; const COLORS = [ { name: 'coral', a: '#ff8a80', b: '#ff4d6d', c: '#c9184a' }, { name: 'sun', a: '#fff3a0', b: '#ffcc33', c: '#e09200' }, { name: 'mint', a: '#b9fbc0', b: '#3ddc97', c: '#139a63' }, { name: 'sky', a: '#a5f0ff', b: '#4cc9f0', c: '#1f78c9' }, { name: 'violet', a: '#e0c3ff', b: '#9b5de5', c: '#5f2bb3' }, ]; const cv = document.getElementById('c'); const g = cv.getContext('2d'); let W = 0, H = 0, DPR = 1; function resize() { DPR = Math.min(3, window.devicePixelRatio || 1); W = window.innerWidth; H = window.innerHeight; cv.width = Math.round(W * DPR); cv.height = Math.round(H * DPR); g.setTransform(DPR, 0, 0, DPR, 0, 0); } resize(); window.addEventListener('resize', () => { resize(); draw(); }); // ---- world ---- const LANES = 4; const S = { t: 0, score: 0, combo: 0, best: 0, meter: 0, drops: [], parts: [], rings: [], pops: [], spawn: 0.4, puddles: Array.from({ length: LANES }, (_, i) => ({ color: i % COLORS.length, wob: 0 })), mapScroll: 0 }; const laneX = i => W * (i + 0.5) / LANES; const groundY = () => H * 0.8; const clouds = Array.from({ length: 5 }, () => ({ x: rnd(), y: 0.1 + rnd() * 0.3, s: 0.5 + rnd() * 0.5, v: 0.004 + rnd() * 0.01 })); const stars = Array.from({ length: 40 }, () => ({ x: rnd(), y: rnd() * 0.6, r: 0.4 + rnd() * 1.3, p: rnd() * 6 })); function spawnDrop() { const lane = Math.floor(rnd() * LANES); S.drops.push({ lane, color: Math.floor(rnd() * COLORS.length), y: -30, v: 120 + rnd() * 60 + Math.min(120, S.t * 3), size: 15 + rnd() * 5, decided: false }); } function splash(x, y, color, big) { const n = big ? 34 : 18; for (let i = 0; i < n; i++) { const a = -Math.PI * (0.1 + 0.8 * rnd()), sp = (big ? 260 : 170) * (0.4 + rnd()); S.parts.push({ x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, r: 2 + rnd() * (big ? 6 : 4), life: 0.9 + rnd() * 0.5, age: 0, color }); } S.rings.push({ x, y, age: 0, color, big }); } function step(dt) { S.t += dt; for (const c of clouds) { c.x += c.v * dt; if (c.x > 1.3) c.x = -0.3; } if (SCENE === 'map') { S.mapScroll += dt * 26; return; } if (SCENE === 'stars') return; S.spawn -= dt; if (S.spawn <= 0) { spawnDrop(); S.spawn = Math.max(0.45, 1.05 - S.t * 0.01) * (0.8 + rnd() * 0.4); } const gy = groundY(); for (const d of S.drops) { d.y += d.v * dt; // the "player": switches the puddle color when a drop gets close (autoplay misses now and then) if (!d.decided && d.y > gy - 230) { d.decided = true; const miss = AUTO && rnd() < 0.12; if (!miss) S.puddles[d.lane].color = d.color; S.puddles[d.lane].wob = 1; S.pops.push({ x: laneX(d.lane), y: gy, age: 0 }); } } for (const d of S.drops.filter(d => d.y >= gy - 6)) { const p = S.puddles[d.lane]; const hit = p.color === d.color; if (hit) { S.combo++; S.score += 10 * Math.min(9, S.combo); S.meter = Math.min(1, S.meter + 0.045); splash(laneX(d.lane), gy, d.color, S.combo % 5 === 0); } else { S.combo = 0; p.wob = 1.6; splash(laneX(d.lane), gy, d.color, false); } S.best = Math.max(S.best, S.combo); } S.drops = S.drops.filter(d => d.y < gy - 6); for (const p of S.parts) { p.age += dt; p.vy += 520 * dt; p.x += p.vx * dt; p.y += p.vy * dt; } S.parts = S.parts.filter(p => p.age < p.life); for (const r of S.rings) r.age += dt; S.rings = S.rings.filter(r => r.age < 0.8); for (const p of S.puddles) p.wob = Math.max(0, p.wob - dt * 2.2); for (const p of S.pops) p.age += dt; S.pops = S.pops.filter(p => p.age < 0.35); if (S.meter >= 1) S.meter = 0.08; } // ---- drawing ---- function sky() { const gr = g.createLinearGradient(0, 0, 0, H); gr.addColorStop(0, '#2b1d6e'); gr.addColorStop(0.45, '#5b3cc4'); gr.addColorStop(0.8, '#ff7eb6'); gr.addColorStop(1, '#ffb38a'); g.fillStyle = gr; g.fillRect(0, 0, W, H); for (const s of stars) { g.globalAlpha = 0.35 + 0.35 * Math.sin(S.t * 2 + s.p); g.fillStyle = '#fff'; g.beginPath(); g.arc(s.x * W, s.y * H, s.r, 0, 7); g.fill(); } g.globalAlpha = 1; for (const c of clouds) cloud(c.x * W, c.y * H, 46 * c.s); } function cloud(x, y, r) { g.fillStyle = 'rgba(255,255,255,.13)'; g.beginPath(); for (const [dx, dy, k] of [[0, 0, 1], [-0.9, 0.25, 0.7], [0.9, 0.2, 0.75], [0.35, -0.35, 0.7]]) { g.moveTo(x + dx * r + r * k, y + dy * r); g.arc(x + dx * r, y + dy * r, r * k, 0, 7); } g.fill(); } function drop(x, y, s, col, alpha = 1) { g.save(); g.globalAlpha = alpha; g.translate(x, y); const gr = g.createRadialGradient(-s * 0.3, s * 0.2, s * 0.1, 0, s * 0.4, s * 1.4); gr.addColorStop(0, col.a); gr.addColorStop(0.55, col.b); gr.addColorStop(1, col.c); g.fillStyle = gr; g.beginPath(); g.moveTo(0, -s * 1.6); g.bezierCurveTo(s * 0.5, -s * 0.7, s, -s * 0.1, s, s * 0.35); g.arc(0, s * 0.35, s, 0, Math.PI); g.bezierCurveTo(-s, -s * 0.1, -s * 0.5, -s * 0.7, 0, -s * 1.6); g.fill(); g.fillStyle = 'rgba(255,255,255,.75)'; g.beginPath(); g.ellipse(-s * 0.38, s * 0.05, s * 0.18, s * 0.34, 0.5, 0, 7); g.fill(); g.restore(); } function puddle(i, p) { const x = laneX(i), y = groundY(), w = W / LANES * 0.42, col = COLORS[p.color]; const wob = Math.sin(S.t * 30) * p.wob * 4; g.fillStyle = 'rgba(40,10,60,.35)'; g.beginPath(); g.ellipse(x, y + 10, w + 6, 16, 0, 0, 7); g.fill(); const gr = g.createLinearGradient(0, y - 14, 0, y + 14); gr.addColorStop(0, col.a); gr.addColorStop(0.5, col.b); gr.addColorStop(1, col.c); g.fillStyle = gr; g.beginPath(); g.ellipse(x, y, w + wob, 14 - wob * 0.3, 0, 0, 7); g.fill(); g.strokeStyle = 'rgba(255,255,255,.65)'; g.lineWidth = 2.5; g.beginPath(); g.ellipse(x - w * 0.2, y - 4, w * 0.45, 4, 0, Math.PI * 1.1, Math.PI * 1.9); g.stroke(); } function ground() { const y = groundY(); const gr = g.createLinearGradient(0, y - 30, 0, H); gr.addColorStop(0, '#3b2a8f'); gr.addColorStop(1, '#1d1450'); g.fillStyle = gr; g.beginPath(); g.moveTo(0, y - 18); for (let x = 0; x <= W; x += 20) g.lineTo(x, y - 18 + Math.sin(x / 37) * 5); g.lineTo(W, H); g.lineTo(0, H); g.fill(); S.puddles.forEach((p, i) => puddle(i, p)); // lane buttons for (let i = 0; i < LANES; i++) { const x = laneX(i), by = y + (H - y) * 0.52, r = Math.min(30, W / LANES * 0.26), col = COLORS[S.puddles[i].color]; g.fillStyle = 'rgba(0,0,0,.25)'; g.beginPath(); g.arc(x, by + 4, r, 0, 7); g.fill(); const bg = g.createLinearGradient(0, by - r, 0, by + r); bg.addColorStop(0, col.a); bg.addColorStop(1, col.c); g.fillStyle = bg; g.beginPath(); g.arc(x, by, r, 0, 7); g.fill(); g.strokeStyle = '#fff'; g.lineWidth = 3; g.beginPath(); g.arc(x, by, r * 0.45, -0.6, Math.PI * 1.4); g.stroke(); g.fillStyle = '#fff'; g.beginPath(); const ax = x + Math.cos(-0.6) * r * 0.45, ay = by + Math.sin(-0.6) * r * 0.45; g.moveTo(ax + 5, ay - 1); g.lineTo(ax - 3, ay - 6); g.lineTo(ax - 2, ay + 5); g.fill(); } } function pill(x, y, w, h, fill) { g.fillStyle = fill; g.beginPath(); g.roundRect(x, y, w, h, h / 2); g.fill(); } function text(s, x, y, size, color = '#fff', align = 'center', stroke = null) { g.font = `800 ${size}px ${FONT}`; g.textAlign = align; g.textBaseline = 'middle'; g.direction = RTL ? 'rtl' : 'ltr'; if (stroke) { g.lineWidth = size * 0.22; g.strokeStyle = stroke; g.lineJoin = 'round'; g.strokeText(s, x, y); } g.fillStyle = color; g.fillText(s, x, y); } function hud() { const top = 14; pill(14, top, 128, 36, 'rgba(0,0,0,.28)'); text(`${L.level} ${LEVEL}`, 78, top + 18, 16); const sw = 120; pill(W - sw - 14, top, sw, 36, 'rgba(0,0,0,.28)'); drop(W - sw + 6, top + 16, 8, COLORS[3]); text(String(S.score), W - sw / 2 - 4, top + 18, 18); // level meter const mx = 24, my = top + 52, mw = W - 48; pill(mx, my, mw, 12, 'rgba(0,0,0,.28)'); const mg = g.createLinearGradient(mx, 0, mx + mw, 0); COLORS.forEach((c, i) => mg.addColorStop(i / (COLORS.length - 1), c.b)); pill(mx, my, Math.max(12, mw * S.meter), 12, mg); if (S.combo >= 3) { const k = 1 + 0.08 * Math.sin(S.t * 10); g.save(); g.translate(W / 2, H * 0.3); g.scale(k, k); g.rotate(-0.06); text(`x${S.combo}`, 0, 0, Math.min(84, 40 + S.combo * 4), '#fff', 'center', '#5f2bb3'); g.restore(); } } function playScene() { sky(); for (const d of S.drops) { g.globalAlpha = 0.18; g.fillStyle = '#fff'; g.fillRect(laneX(d.lane) - 1, d.y - 70, 2, 50); g.globalAlpha = 1; drop(laneX(d.lane), d.y, d.size, COLORS[d.color]); } ground(); for (const r of S.rings) { const k = r.age / 0.8, col = COLORS[r.color]; g.strokeStyle = col.a; g.globalAlpha = 1 - k; g.lineWidth = r.big ? 6 : 4; g.beginPath(); g.ellipse(r.x, r.y, 20 + k * (r.big ? 120 : 70), 6 + k * (r.big ? 30 : 18), 0, 0, 7); g.stroke(); g.globalAlpha = 1; } for (const p of S.parts) { g.globalAlpha = 1 - p.age / p.life; drop(p.x, p.y, p.r, COLORS[p.color]); } g.globalAlpha = 1; for (const p of S.pops) { g.strokeStyle = `rgba(255,255,255,${1 - p.age / 0.35})`; g.lineWidth = 3; g.beginPath(); g.arc(p.x, p.y, 20 + p.age * 120, 0, 7); g.stroke(); } hud(); } function mapScene() { const gr = g.createLinearGradient(0, 0, 0, H); gr.addColorStop(0, '#1b4fd6'); gr.addColorStop(0.5, '#29a3ff'); gr.addColorStop(1, '#7be3c8'); g.fillStyle = gr; g.fillRect(0, 0, W, H); for (const c of clouds) cloud(c.x * W, c.y * H * 2.2, 50 * c.s); const gap = 92, base = H * 0.58 + (LEVEL - 1) * gap + S.mapScroll; const pts = []; for (let i = 0; i < LEVEL + 30; i++) pts.push({ n: i + 1, x: W / 2 + Math.sin(i * 0.9) * W * 0.27, y: base - i * gap }); g.strokeStyle = 'rgba(255,255,255,.55)'; g.lineWidth = 10; g.setLineDash([2, 16]); g.lineCap = 'round'; g.beginPath(); pts.forEach((p, i) => (i ? g.lineTo(p.x, p.y) : g.moveTo(p.x, p.y))); g.stroke(); g.setLineDash([]); for (const p of pts) { if (p.y < -60 || p.y > H + 60) continue; const done = p.n < LEVEL, cur = p.n === LEVEL, col = COLORS[p.n % COLORS.length]; const r = cur ? 34 + 3 * Math.sin(S.t * 5) : 27; g.fillStyle = 'rgba(0,0,0,.2)'; g.beginPath(); g.ellipse(p.x, p.y + r * 0.8, r, r * 0.35, 0, 0, 7); g.fill(); const bg = g.createLinearGradient(0, p.y - r, 0, p.y + r); bg.addColorStop(0, done || cur ? col.a : '#d7dcef'); bg.addColorStop(1, done || cur ? col.c : '#8d93b5'); g.fillStyle = bg; g.beginPath(); g.arc(p.x, p.y, r, 0, 7); g.fill(); g.strokeStyle = '#fff'; g.lineWidth = cur ? 5 : 3; g.stroke(); text(String(p.n), p.x, p.y + 1, cur ? 24 : 19, '#fff', 'center', done || cur ? col.c : '#6b7096'); if (done) for (let s = 0; s < 3; s++) star(p.x + (s - 1) * 15, p.y - r - 6 - (s === 1 ? 4 : 0), 7, s < 2 + (p.n % 2)); } pill(14, 14, W - 28, 44, 'rgba(0,0,0,.25)'); text(L.daily, W / 2, 36, 18); } function star(x, y, r, on) { g.beginPath(); for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + i * Math.PI / 5, k = i % 2 ? r * 0.48 : r; g.lineTo(x + Math.cos(a) * k, y + Math.sin(a) * k); } g.closePath(); g.fillStyle = on ? '#ffd23f' : 'rgba(255,255,255,.35)'; g.fill(); g.strokeStyle = on ? '#b86e00' : 'rgba(0,0,0,.2)'; g.lineWidth = Math.max(1.5, r * 0.18); g.stroke(); } function starsScene() { S.t = Math.max(S.t, 0); sky(); ground(); g.fillStyle = 'rgba(20,10,50,.45)'; g.fillRect(0, 0, W, H); const pw = Math.min(W - 40, 380), ph = 380, px = (W - pw) / 2, py = H * 0.2; for (let i = 0; i < 26; i++) { const a = rnd() * 7, d = 170 + rnd() * 160; drop(W / 2 + Math.cos(a) * d, py + 110 + Math.sin(a) * d * 0.8, 4 + rnd() * 6, COLORS[i % COLORS.length], 0.9); } g.fillStyle = 'rgba(0,0,0,.3)'; g.beginPath(); g.roundRect(px, py + 10, pw, ph, 30); g.fill(); const bg = g.createLinearGradient(0, py, 0, py + ph); bg.addColorStop(0, '#ffffff'); bg.addColorStop(1, '#ffe7f3'); g.fillStyle = bg; g.beginPath(); g.roundRect(px, py, pw, ph, 30); g.fill(); pill(px + 30, py - 22, pw - 60, 50, '#9b5de5'); text(L.clear, W / 2, py + 3, 24); const k = Math.min(1, S.t / 1.2); for (let s = 0; s < 3; s++) star(W / 2 + (s - 1) * 92, py + 110 - (s === 1 ? 18 : 0), (s === 1 ? 48 : 38) * (0.6 + 0.4 * k), true); text(String(S.score || 4280), W / 2, py + 200, 50, '#5f2bb3'); text(`${L.best} x${S.best || 14}`, W / 2, py + 248, 18, '#9b5de5'); pill(px + 40, py + ph - 78, pw - 80, 54, '#3ddc97'); text(L.next, W / 2, py + ph - 51, 22, '#fff', 'center', '#139a63'); } function draw() { g.clearRect(0, 0, W, H); if (SCENE === 'map') mapScene(); else if (SCENE === 'stars') starsScene(); else playScene(); } // combo scene = the play scene at a moment with a long streak and a big splash if (SCENE === 'combo') { S.combo = 7; } const target = FREEZE ?? (AUTO ? 0 : 6); for (let t = 0; t < target; t += 1 / 60) step(1 / 60); if (SCENE === 'combo') { S.combo = Math.max(S.combo, 9); splash(laneX(1), groundY(), S.puddles[1].color, true); splash(laneX(2), groundY(), S.puddles[2].color, true); for (let i = 0; i < 9; i++) step(1 / 60); } if (SCENE === 'stars') { S.score = 4280; S.best = 14; } draw(); const game = window.game = { ready: false, state: 'playing', scene: SCENE, get score() { return S.score; } }; requestAnimationFrame(() => { game.ready = true; }); if (AUTO || (FREEZE === null && SCENE !== 'stars')) { let last = performance.now(); const loop = now => { step(Math.min(0.05, (now - last) / 1000)); last = now; draw(); requestAnimationFrame(loop); }; requestAnimationFrame(loop); } cv.addEventListener('pointerdown', e => { const lane = Math.max(0, Math.min(LANES - 1, Math.floor(e.clientX / W * LANES))); S.puddles[lane].color = (S.puddles[lane].color + 1) % COLORS.length; S.puddles[lane].wob = 1; if (FREEZE !== null) draw(); }); })();