import { enter, shots } from "./motion.mjs"; const cream = "#f0efda", green = "#b7df8a", gold = "#dcc28a"; function text(ctx, value, x, y, size, color = cream) { ctx.font = `${size}px Pixel`; ctx.textAlign = "left"; ctx.fillStyle = color; ctx.fillText(value, x, y); } function label(ctx, value, x, y, size = 11, color = "#a8bda3") { ctx.font = `${size}px Arial`; ctx.fillStyle = color; ctx.textAlign = "left"; ctx.letterSpacing = "2px"; ctx.fillText(value, x, y); ctx.letterSpacing = "0px"; } function cross(ctx, x, y) { ctx.beginPath(); ctx.moveTo(x - 5, y); ctx.lineTo(x + 5, y); ctx.moveTo(x, y - 5); ctx.lineTo(x, y + 5); ctx.stroke(); } function word(ctx, value, x, y, size, time, start, end, color = cream) { // 50ms stagger: the word reads as one gesture, followed by a long quiet hold. let offset = 0; ctx.font = `${size}px Pixel`; for (let i = 0; i < value.length; i++) { const a = enter(time, start + i * 0.05); const out = enter(time, end - 0.25); ctx.save(); ctx.globalAlpha *= a * (1 - out); text(ctx, value[i], x + offset, y + (1 - a) * 12 + out * 12, size, color); ctx.restore(); offset += ctx.measureText(value[i]).width; } } function mark(ctx, x, y, size, time) { ctx.save(); ctx.translate(x, y); ctx.strokeStyle = "rgba(209,217,174,.5)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(0, 0, size, 0, Math.PI * 2); ctx.stroke(); for (let i = 0; i < 32; i++) { const a = (i / 32) * Math.PI * 2, r = size - (i % 8 === 0 ? 9 : 3); ctx.beginPath(); ctx.moveTo(Math.sin(a) * r, Math.cos(a) * r); ctx.lineTo(Math.sin(a) * size, Math.cos(a) * size); ctx.stroke(); } ctx.rotate(Math.sin(time) * 0.09 - 0.35); ctx.fillStyle = gold; ctx.beginPath(); ctx.moveTo(0, -size * 0.6); ctx.lineTo(size * 0.13, 0); ctx.lineTo(0, size * 0.1); ctx.lineTo(-size * 0.13, 0); ctx.closePath(); ctx.fill(); ctx.fillStyle = "#57725b"; ctx.beginPath(); ctx.moveTo(0, size * 0.6); ctx.lineTo(size * 0.13, 0); ctx.lineTo(0, -size * 0.1); ctx.lineTo(-size * 0.13, 0); ctx.closePath(); ctx.fill(); ctx.restore(); } function screenshot(ctx, image, x, y, w, h) { ctx.save(); ctx.fillStyle = "#07130e"; ctx.beginPath(); ctx.roundRect(x - 1, y - 1, w + 2, h + 2, 8); ctx.fill(); ctx.strokeStyle = "rgba(181,205,154,.5)"; ctx.stroke(); ctx.beginPath(); ctx.roundRect(x, y, w, h, 7); ctx.clip(); ctx.drawImage( image, 0, 0, image.naturalWidth, image.naturalHeight, x, y, w, h, ); ctx.restore(); } function screenshotShot(ctx, shot, image, t) { const a = enter(t, shot.start - 0.125), exit = enter(t, shot.end - 0.125); ctx.save(); ctx.globalAlpha = a * (1 - exit); label(ctx, shot.chapter, 64, 30, 10, gold); text(ctx, shot.title, 62, 76, 34, cream); ctx.font = "15px Arial"; ctx.fillStyle = "#aebfa6"; ctx.fillText(shot.subtitle, 65, 100); // Readable data stays still after a 250ms settle; no continuous pan or zoom. const settle = enter(t, shot.start + 0.05), leave = enter(t, shot.end - 0.125); const w = 1040, h = 585, x = 120, y = 121; ctx.save(); ctx.globalAlpha *= settle; ctx.translate(640, y + h / 2 + 8 * (1 - settle) + 8 * leave); const scale = 0.96 + 0.04 * settle; ctx.scale(scale, scale); ctx.translate(-640, -y - h / 2); screenshot(ctx, image, x, y, w, h); if (shot.image === "game") { const focus = enter(t, shot.start + 2, 0.25) * (1 - enter(t, shot.end - 0.4, 0.25)); ctx.globalAlpha *= focus; ctx.strokeStyle = gold; ctx.lineWidth = 2; ctx.strokeRect(x + w * 0.771, y + h * 0.496, w * 0.222, h * 0.482); ctx.fillStyle = "#0b1b13"; ctx.fillRect(x + 24, y + h - 66, 310, 38); label(ctx, "LOOK AROUND. PLACE YOUR PIN.", x + 37, y + h - 42, 10, gold); } ctx.restore(); ctx.restore(); } function brandShot(ctx, shot, t, closing) { const a = enter(t, shot.start), exit = enter(t, shot.end - 0.25); ctx.save(); ctx.globalAlpha = a * (1 - exit); label(ctx, shot.chapter, 66, 215, 11, gold); if (closing) { word(ctx, "MINEGEO", 60, 322, 80, t, shot.start + 0.05, shot.end, green); ctx.save(); ctx.globalAlpha *= enter(t, shot.start + 0.45); ctx.font = "20px Arial"; ctx.fillStyle = "#becdb3"; ctx.fillText(shot.subtitle, 66, 369); ctx.restore(); const cta = enter(t, shot.start + 0.7); ctx.save(); ctx.globalAlpha *= cta; ctx.translate(0, 8 * (1 - cta)); ctx.fillStyle = green; ctx.fillRect(65, 409, 330, 57); text(ctx, "MINEGEO.SITE", 88, 448, 25, "#112215"); label(ctx, "FREE TO EXPLORE / FIVE ROUNDS", 66, 500, 10); ctx.restore(); } else { word(ctx, "GET LOST.", 60, 313, 68, t, shot.start + 0.05, shot.end); word( ctx, "FIND YOUR WORLD.", 60, 392, 48, t, shot.start + 0.16, shot.end, green, ); ctx.save(); ctx.globalAlpha *= enter(t, shot.start + 0.7); ctx.font = "17px Arial"; ctx.fillStyle = "#becdb3"; ctx.fillText(shot.subtitle, 66, 445); ctx.restore(); } ctx.restore(); } function drawBanner(ctx, t, pin) { label(ctx, "YOUR NEXT EXPEDITION", 66, 108, 12, gold); // Always legible in Discord: only the scene and targeting sweep loop. text(ctx, "MINEGEO", 60, 233, 82, green); ctx.font = "18px Arial"; ctx.fillStyle = "#ccd7bf"; ctx.fillText("Get lost. Find your world.", 66, 280); ctx.strokeStyle = "rgba(184,207,160,.35)"; ctx.beginPath(); ctx.moveTo(66, 320); ctx.lineTo(430, 320); ctx.stroke(); label(ctx, "MINEGEO.SITE", 66, 350, 13, cream); label(ctx, "EXPLORE / GUESS / DISCOVER", 66, 388, 10); mark(ctx, 1126, 68, 25, (t / 3) * Math.PI * 2); if (pin) { ctx.save(); ctx.translate(pin.x, pin.y); const phase = (t / 3) * Math.PI * 2, pulse = 0.5 - 0.5 * Math.cos(phase); ctx.strokeStyle = `rgba(234,210,144,${0.25 + 0.25 * (1 - pulse)})`; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(0, 0, 20 + pulse * 13, 0, Math.PI * 2); ctx.stroke(); ctx.rotate(phase); ctx.strokeStyle = "rgba(234,210,144,.65)"; for (let i = 0; i < 4; i++) { ctx.rotate(Math.PI / 2); ctx.beginPath(); ctx.moveTo(28, -7); ctx.lineTo(28, 7); ctx.stroke(); } ctx.restore(); } } export function composeFrame( ctx, source, { width, height, banner, time: t, screenshots, pin }, ) { ctx.clearRect(0, 0, width, height); ctx.drawImage(source, 0, 0); const scale = width / (banner ? 1200 : 1280), w = width / scale, h = height / scale; ctx.save(); ctx.scale(scale, scale); const gradient = ctx.createLinearGradient(0, 0, w, 0); gradient.addColorStop(0, "rgba(5,15,10,.97)"); gradient.addColorStop(0.38, "rgba(5,15,10,.86)"); gradient.addColorStop(0.8, "rgba(5,15,10,0)"); ctx.fillStyle = gradient; ctx.fillRect(0, 0, w, h); const wash = ctx.createLinearGradient(0, 0, 0, h); wash.addColorStop(0, "rgba(5,15,10,.2)"); wash.addColorStop(0.65, "rgba(5,15,10,0)"); wash.addColorStop(1, "rgba(5,15,10,.55)"); ctx.fillStyle = wash; ctx.fillRect(0, 0, w, h); ctx.strokeStyle = "rgba(171,196,144,.25)"; ctx.lineWidth = 1; cross(ctx, 28, 28); cross(ctx, w - 28, 28); cross(ctx, 28, h - 28); cross(ctx, w - 28, h - 28); if (banner) drawBanner(ctx, t, pin ? { x: pin.x / scale, y: pin.y / scale } : null); else { for (const shot of shots) { if (t < shot.start - 0.125 || t > shot.end + 0.125) continue; if (shot.image) screenshotShot(ctx, shot, screenshots[shot.image], t); else brandShot(ctx, shot, t, shot.start === 23); } if (t < 3 || t >= 23) { mark(ctx, 1188, 81, 29, t * 0.12); label(ctx, "A MINECRAFT GUESSING GAME", 66, 65, 10); } const current = shots.findIndex((s) => t >= s.start && t < s.end); for (let i = 0; i < shots.length; i++) { ctx.fillStyle = i <= current ? "#bfd49b" : "rgba(165,186,143,.3)"; ctx.fillRect(28 + i * 16, h - 20, 10, 2); } ctx.fillStyle = `rgba(5,15,10,${1 - enter(t, 0)})`; ctx.fillRect(0, 0, w, h); ctx.fillStyle = `rgba(5,15,10,${enter(t, 29.75)})`; ctx.fillRect(0, 0, w, h); } ctx.restore(); }