298 lines
16 KiB
JavaScript
298 lines
16 KiB
JavaScript
// 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=<n> fixed randomness t=<seconds> freeze at this moment (screenshots; with auto=1: start there)
|
|
// auto=1 autoplay, runs live (video clips) level=<n> 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();
|
|
});
|
|
})();
|