storebound/examples/demo-game/web/game.js

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();
});
})();