diff --git a/app/cinematic-intro.css b/app/cinematic-intro.css new file mode 100644 index 0000000..6b4b422 --- /dev/null +++ b/app/cinematic-intro.css @@ -0,0 +1,377 @@ +.first-visit-intro { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + max-width: none; + max-height: none; + margin: 0; + padding: 0; + border: 0; + overflow: hidden; + color: #f5f0da; + background: radial-gradient( + ellipse at 50% 58%, + #264c38, + #0a1914 48%, + #050c09 100% + ); +} +.first-visit-intro[open] { + display: grid; + place-items: center; + animation: intro-reveal-site 0.55s 4.2s both; +} +.first-visit-intro::backdrop { + background: #050c09; + animation: intro-reveal-site 0.55s 4.2s both; +} +/* A modal lives above the custom cursor overlay, so restore a native cursor. */ +html.has-site-cursor dialog.first-visit-intro, +html.has-site-cursor dialog.first-visit-intro * { + cursor: auto !important; +} +html.has-site-cursor dialog.first-visit-intro button { + cursor: pointer !important; +} +.intro-landscape { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; +} +.intro-horizon { + position: absolute; + inset: 45% -40% -65%; + background: + repeating-linear-gradient(90deg, #b6d99210 0 1px, transparent 1px 90px), + repeating-linear-gradient(0deg, #b6d99210 0 1px, transparent 1px 90px); + transform: perspective(450px) rotateX(56deg); + mask-image: linear-gradient(transparent, #000 28%); +} +.intro-terrain { + position: absolute; + inset: 10% -5%; + transform: rotate(-12deg); +} +.intro-terrain i { + position: absolute; + width: 90px; + height: 90px; + background: #274b35; + border: 1px solid #74935e40; + box-shadow: + 18px 18px 0 #112d22, + 19px 19px 0 #74935e40; + transform: rotateX(52deg) rotateZ(45deg); + animation: intro-terrain-rise 4.75s both; +} +.intro-terrain i:nth-child(3n) { + width: 140px; + height: 140px; + background: #33573c; + animation-delay: 0.15s; +} +.intro-terrain i:nth-child(5n) { + width: 55px; + height: 55px; + background: #51714a; + animation-delay: 0.3s; +} +.intro-terrain i:nth-child(1) { + left: 8%; + top: 14%; +} +.intro-terrain i:nth-child(2) { + left: 20%; + top: 67%; +} +.intro-terrain i:nth-child(3) { + left: 77%; + top: 65%; +} +.intro-terrain i:nth-child(4) { + left: 89%; + top: 14%; +} +.intro-terrain i:nth-child(5) { + left: 63%; + top: 15%; +} +.intro-terrain i:nth-child(6) { + left: 0; + top: 75%; +} +.intro-terrain i:nth-child(7) { + left: 31%; + top: 82%; +} +.intro-terrain i:nth-child(8) { + left: 70%; + top: 86%; +} +.intro-terrain i:nth-child(9) { + left: 93%; + top: 60%; +} +.intro-terrain i:nth-child(10) { + left: 27%; + top: 13%; +} +.intro-terrain i:nth-child(11) { + left: 4%; + top: 45%; +} +.intro-terrain i:nth-child(12) { + left: 43%; + top: -15%; +} +.intro-terrain i:nth-child(13) { + left: 56%; + top: 95%; +} +.intro-terrain i:nth-child(14) { + left: 85%; + top: 43%; +} +.intro-terrain i:nth-child(15) { + left: 16%; + top: 40%; +} +.intro-compass { + position: absolute; + left: 50%; + top: 38%; + width: 230px; + height: 230px; + transform: translate(-50%, -50%); + color: #e9c67d; + animation: intro-compass-arrive 3.8s both; +} +.intro-compass > span { + position: absolute; + inset: -18px; + border: 1px solid #e9c67d60; + border-radius: 50%; +} +.intro-compass > span:nth-child(2) { + inset: -40px; + border-style: dashed; + opacity: 0.45; + animation: intro-ring-turn 15s linear infinite; +} +.intro-compass > svg:first-of-type { + width: 100%; + height: 100%; + opacity: 0.35; +} +.intro-compass .intro-pin { + position: absolute; + inset: 65px; + width: 100px; + height: 100px; + filter: drop-shadow(0 0 24px #cdb57766); +} +.intro-coordinate { + position: absolute; + top: 44%; + color: #e9c67d60; + font: 11px monospace; + letter-spacing: 3px; +} +.intro-coordinate-left { + left: 14%; +} +.intro-coordinate-right { + right: 14%; +} +.intro-title { + position: relative; + text-align: center; + padding: 220px 24px 0; + animation: intro-title-sequence 4.4s both; +} +.intro-title .eyebrow { + color: #e9c67d; + letter-spacing: 5px; + font-size: 10px; +} +.intro-title h2 { + font: + clamp(50px, 9vw, 100px) / 1.2 MineGeoPixel, + monospace; + letter-spacing: -4px; + margin: 0 0 12px; + text-shadow: + 0 5px #11291c, + 0 0 45px #9ac57725; +} +.intro-title > p:last-child { + color: #c6d4c5; + font-size: clamp(13px, 2vw, 18px); + letter-spacing: 2px; +} +.intro-skip.button { + position: absolute; + bottom: 30px; + right: 32px; + border: 1px solid #c7d5ba50; + background: #0d211bd9; + color: #e6eddd; + gap: 20px; +} +.intro-skip span { + color: #e9c67d; +} +.site-trailer { + padding-top: 28px; +} +.site-trailer .eyebrow { + display: flex; + align-items: center; + gap: 9px; +} +.trailer-duration { + color: var(--muted); + font: 12px monospace; + white-space: nowrap; +} +.site-trailer-player { + display: block; + width: 100%; + aspect-ratio: 16 / 9; + background: #08130e; + border: 1px solid var(--line); + border-radius: 15px; + object-fit: contain; +} +.trailer-caption { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 30px; + padding-top: 18px; +} +.trailer-caption p { + max-width: 710px; + margin: 0; + color: var(--muted); + font-size: 13px; +} +.trailer-caption .text-link { + white-space: nowrap; + font-size: 12px; +} +@keyframes intro-terrain-rise { + 0% { + opacity: 0; + translate: 0 95px; + } + 25%, + 70% { + opacity: 0.8; + } + 100% { + opacity: 0.15; + translate: 0 -30px; + } +} +@keyframes intro-compass-arrive { + 0% { + opacity: 0; + scale: 0.5; + rotate: -32deg; + } + 30%, + 80% { + opacity: 1; + scale: 1; + rotate: 0deg; + } + 100% { + opacity: 0.4; + scale: 1.08; + rotate: 6deg; + } +} +@keyframes intro-title-sequence { + 0%, + 17% { + opacity: 0; + translate: 0 22px; + filter: blur(8px); + } + 38%, + 78% { + opacity: 1; + translate: 0 0; + filter: blur(0); + } + 100% { + opacity: 0; + translate: 0 -12px; + filter: blur(4px); + } +} +@keyframes intro-reveal-site { + from { + opacity: 1; + } + to { + opacity: 0; + } +} +@keyframes intro-ring-turn { + to { + rotate: 360deg; + } +} +@media (max-width: 640px) { + .intro-compass { + width: 160px; + height: 160px; + top: 35%; + } + .intro-compass .intro-pin { + inset: 45px; + width: 70px; + height: 70px; + } + .intro-title { + padding-top: 150px; + } + .intro-title h2 { + letter-spacing: -2px; + } + .intro-title .eyebrow { + letter-spacing: 3px; + } + .intro-coordinate { + display: none; + } + .intro-skip.button { + bottom: 22px; + right: 20px; + } + .intro-terrain { + opacity: 0.6; + } + .trailer-caption { + flex-direction: column; + gap: 12px; + } + .site-trailer .section-heading { + align-items: flex-start; + gap: 15px; + flex-direction: column; + } +} +@media (prefers-reduced-motion: reduce) { + .first-visit-intro[open] { + display: none; + } + .first-visit-intro *, + .first-visit-intro::backdrop { + animation: none !important; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index d99c058..ea9019f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -19,6 +19,7 @@ import "./support.css"; import "./appearance.css"; import "./community.css"; import "./community-social.css"; +import "./cinematic-intro.css"; import { themeInitScript } from "@/shared/theme"; import { SiteSettingsProvider } from "@/components/SiteSettingsProvider"; import { initialSiteSettings } from "@/lib/site-settings"; diff --git a/app/page.tsx b/app/page.tsx index 836067f..eae0ea0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -16,6 +16,8 @@ import { catalog } from "@/lib/catalog"; import { leaderboard } from "@/lib/leaderboard"; import { LeaderboardPreview } from "@/components/Leaderboard"; import desktopRelease from "@/shared/desktop-release.json"; +import { FirstVisitIntro } from "@/components/FirstVisitIntro"; +import { SiteTrailer } from "@/components/SiteTrailer"; export const dynamic = "force-dynamic"; export default async function Home() { const { t, formatNumber } = await getI18n(); @@ -27,6 +29,7 @@ export default async function Home() { const locations = maps.reduce((total, map) => total + map.panorama_count, 0); return ( <> +
@@ -171,6 +174,7 @@ export default async function Home() { )}
+
diff --git a/cinematics/index.html b/cinematics/index.html new file mode 100644 index 0000000..4656910 --- /dev/null +++ b/cinematics/index.html @@ -0,0 +1,43 @@ + + + + + + MineGeo cinematic studio + + + + +
Building your world…
+ + + diff --git a/cinematics/scene.mjs b/cinematics/scene.mjs new file mode 100644 index 0000000..c808c6a --- /dev/null +++ b/cinematics/scene.mjs @@ -0,0 +1,633 @@ +import * as THREE from "three"; + +const params = new URLSearchParams(location.search); +const banner = params.get("mode") === "banner"; +const width = Number(params.get("width")) || (banner ? 1200 : 1280); +const height = Number(params.get("height")) || (banner ? 480 : 720); +const renderer = new THREE.WebGLRenderer({ + antialias: true, + preserveDrawingBuffer: true, +}); +renderer.setSize(width, height); +renderer.setPixelRatio(1); +renderer.outputColorSpace = THREE.SRGBColorSpace; +renderer.toneMapping = THREE.ACESFilmicToneMapping; +renderer.toneMappingExposure = 1.35; +renderer.shadowMap.enabled = true; +renderer.shadowMap.type = THREE.PCFSoftShadowMap; +const scene = new THREE.Scene(); +scene.background = new THREE.Color("#0c211c"); +scene.fog = new THREE.FogExp2("#0c211c", 0.008); +const camera = new THREE.PerspectiveCamera(38, width / height, 0.1, 350); +scene.add(new THREE.HemisphereLight(0xc5e4d4, 0x263522, 2.1)); +const sun = new THREE.DirectionalLight(0xffd8a2, 4.2); +sun.position.set(-24, 45, 22); +sun.castShadow = true; +sun.shadow.mapSize.set(2048, 2048); +sun.shadow.camera.left = -42; +sun.shadow.camera.right = 42; +sun.shadow.camera.top = 42; +sun.shadow.camera.bottom = -42; +sun.shadow.normalBias = 0.08; +scene.add(sun); +const rim = new THREE.DirectionalLight(0x74d6d3, 2.5); +rim.position.set(20, 8, -30); +scene.add(rim); +const world = new THREE.Group(); +scene.add(world); +const palettes = { + rock: [0x4b655d, 0x546e62, 0x3c514c, 0x668177], + dirt: [0x66563c, 0x786548, 0x8a7150], + grass: [0x77a653, 0x90b962, 0x648f45, 0xa0bd6d], + sand: [0xceb98a, 0xe1cd9e, 0xb9ad80], + snow: [0xcddbd1, 0xe4eee1], + leaves: [0x3c7449, 0x4d925b, 0x79a456, 0x9fac61], + wood: [0x69513a, 0x84654a], + water: [0x3b9190, 0x56aeaa], +}; +const instances = Object.fromEntries( + Object.keys(palettes).map((key) => [key, []]), +); +const hash = (x, z, salt = 0) => { + const n = Math.sin(x * 127.1 + z * 311.7 + salt * 74.7) * 43758.5453; + return n - Math.floor(n); +}; +function block(kind, x, y, z, sx = 1, sy = 1, sz = 1, variation = 0) { + instances[kind].push({ + x, + y, + z, + sx, + sy, + sz, + color: + palettes[kind][Math.floor(hash(x, z, variation) * palettes[kind].length)], + }); +} +function groundHeight(x, z) { + const hill = Math.exp(-((x - 6) ** 2 + (z + 5) ** 2) / 49) * 10; + const hill2 = Math.exp(-((x + 8) ** 2 + (z - 5) ** 2) / 48) * 3; + return Math.floor( + 2 + hill + hill2 + Math.sin(x * 0.32) * 0.8 + Math.cos(z * 0.38) * 0.7, + ); +} +for (let x = -23; x <= 23; x++) { + for (let z = -19; z <= 19; z++) { + const edge = (x / 23) ** 2 + (z / 19) ** 2; + if (edge > 0.92 + Math.sin(x * 0.7 + z * 0.3) * 0.07) continue; + const river = Math.abs(z - (Math.sin(x * 0.16) * 3 + 3)) < 1.7; + const h = river ? 1 : groundHeight(x, z); + const depth = Math.max(1, Math.floor((1 - edge) * 8 + hash(x, z) * 2)); + block("rock", x, -depth / 2, z, 0.99, depth, 0.99); + block("dirt", x, h / 2, z, 1, h, 1); + const kind = river + ? "water" + : h > 9 + ? "snow" + : edge > 0.73 + ? "sand" + : "grass"; + block(kind, x, h + 0.08, z, 1, 0.32, 1); + if ( + !river && + edge < 0.65 && + h < 7 && + hash(x, z, 7) > 0.958 && + (x % 3 === 0 || z % 4 === 0) + ) { + const tall = 3 + Math.floor(hash(x, z, 9) * 2); + block("wood", x, h + tall / 2, z, 0.55, tall, 0.55); + block("leaves", x, h + tall, z, 2.8, 1.8, 2.8); + block("leaves", x, h + tall + 1.1, z, 1.65, 1.2, 1.65, 2); + } + if (edge > 0.7 && edge < 0.91 && hash(x, z, 12) > 0.94) + block("leaves", x, -depth + 0.2, z, 0.24, 2 + hash(x, z, 10) * 3, 0.24); + } +} +// A warm-lit expedition hut and an old stone arch make the terrain recognizable. +const hutX = -8, + hutZ = -5, + hutY = groundHeight(hutX, hutZ) + 0.2; +block("wood", hutX, hutY + 1.15, hutZ, 3.2, 2.4, 3); +for (let i = 0; i < 3; i++) + block("rock", hutX, hutY + 2.5 + i * 0.4, hutZ, 4 - i, 0.5, 3.8); +for (const x of [9, 13]) for (let y = 0; y < 5; y++) block("rock", x, 4 + y, 8); +for (let x = 9; x <= 13; x++) block("rock", x, 9, 8); +const box = new THREE.BoxGeometry(1, 1, 1); +const dummy = new THREE.Object3D(); +for (const [kind, cells] of Object.entries(instances)) { + const material = new THREE.MeshStandardMaterial({ + color: 0xffffff, + roughness: kind === "water" ? 0.24 : 0.92, + metalness: kind === "water" ? 0.25 : 0.02, + }); + const mesh = new THREE.InstancedMesh(box, material, cells.length); + cells.forEach((b, i) => { + dummy.position.set(b.x, b.y, b.z); + dummy.scale.set(b.sx, b.sy, b.sz); + dummy.updateMatrix(); + mesh.setMatrixAt(i, dummy.matrix); + mesh.setColorAt(i, new THREE.Color(b.color)); + }); + mesh.instanceMatrix.needsUpdate = true; + mesh.instanceColor.needsUpdate = true; + mesh.castShadow = kind !== "water"; + mesh.receiveShadow = true; + world.add(mesh); +} +const windowLight = new THREE.Mesh( + new THREE.BoxGeometry(0.12, 0.7, 0.7), + new THREE.MeshBasicMaterial({ color: 0xffce77 }), +); +windowLight.position.set(hutX - 1.64, hutY + 1.25, hutZ); +world.add(windowLight); + +// Thin cartographic rings float beneath the cutaway, like a world waiting to be found. +const ringGroup = new THREE.Group(); +ringGroup.position.y = -8; +for (const radius of [25, 29, 33]) { + const points = Array.from( + { length: 161 }, + (_, i) => + new THREE.Vector3( + Math.cos((i / 160) * Math.PI * 2) * radius, + 0, + Math.sin((i / 160) * Math.PI * 2) * radius, + ), + ); + const line = new THREE.LineLoop( + new THREE.BufferGeometry().setFromPoints(points), + new THREE.LineBasicMaterial({ + color: 0x759b7b, + transparent: true, + opacity: 0.25, + }), + ); + ringGroup.add(line); +} +world.add(ringGroup); +const sweepPoints = Array.from( + { length: 49 }, + (_, i) => + new THREE.Vector3( + Math.cos((i / 48) * 0.7) * 29, + 0.04, + Math.sin((i / 48) * 0.7) * 29, + ), +); +ringGroup.add( + new THREE.Line( + new THREE.BufferGeometry().setFromPoints(sweepPoints), + new THREE.LineBasicMaterial({ + color: 0xd6bb76, + transparent: true, + opacity: 0.8, + }), + ), +); + +function pin(color, x, z) { + const group = new THREE.Group(); + const mat = new THREE.MeshStandardMaterial({ + color, + emissive: color, + emissiveIntensity: 0.25, + metalness: 0.35, + roughness: 0.3, + }); + const head = new THREE.Mesh(new THREE.OctahedronGeometry(1.4), mat); + head.position.y = 3; + const stem = new THREE.Mesh(new THREE.ConeGeometry(0.35, 2.2, 4), mat); + stem.rotation.z = Math.PI; + stem.position.y = 1.25; + group.add(head, stem); + const halo = new THREE.Mesh( + new THREE.TorusGeometry(2, 0.055, 6, 64), + new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.75 }), + ); + halo.rotation.x = -Math.PI / 2; + halo.position.y = 0.15; + group.add(halo); + group.position.set(x, groundHeight(x, z) + 0.5, z); + world.add(group); + return { group, halo, base: group.position.y }; +} +const pins = [ + pin(0xffcf70, 5, -5), + pin(0xa3e271, -12, 9), + pin(0xef7967, 12, 5), +]; +const moteGeo = new THREE.BufferGeometry(); +const motePositions = []; +for (let i = 0; i < 150; i++) + motePositions.push( + (hash(i, 4) - 0.5) * 130, + hash(i, 5) * 58 - 12, + (hash(i, 6) - 0.5) * 100, + ); +moteGeo.setAttribute( + "position", + new THREE.Float32BufferAttribute(motePositions, 3), +); +const motes = new THREE.Points( + moteGeo, + new THREE.PointsMaterial({ + color: 0xdce8b9, + size: 0.13, + transparent: true, + opacity: 0.6, + }), +); +scene.add(motes); + +const canvas = document.createElement("canvas"); +canvas.width = width; +canvas.height = height; +document.body.prepend(canvas); +const ctx = canvas.getContext("2d"); +await document.fonts.load("24px Pixel"); +await document.fonts.ready; +const screenshots = banner + ? {} + : Object.fromEntries( + await Promise.all( + ["home", "catalog", "map", "game"].map(async (name) => { + const image = new Image(); + image.src = `/shots/${name}.jpg`; + await image.decode(); + return [name, image]; + }), + ), + ); +const clamp = (n, a = 0, b = 1) => Math.max(a, Math.min(b, n)); +const smooth = (n) => { + const v = clamp(n); + return v * v * (3 - 2 * v); +}; +function text(value, x, y, size, color = "#f0f2da", align = "left") { + ctx.font = `${size}px Pixel`; + ctx.textAlign = align; + ctx.fillStyle = color; + ctx.fillText(value, x, y); +} +function label(value, x, y, size = 12, color = "#9cb59e") { + ctx.font = `${size}px Arial`; + ctx.textAlign = "left"; + ctx.fillStyle = color; + ctx.letterSpacing = "3px"; + ctx.fillText(value, x, y); + ctx.letterSpacing = "0px"; +} +function cross(x, y, size = 5) { + ctx.beginPath(); + ctx.moveTo(x - size, y); + ctx.lineTo(x + size, y); + ctx.moveTo(x, y - size); + ctx.lineTo(x, y + size); + ctx.stroke(); +} +function compass(x, y, radius, angle, opacity = 0.7) { + ctx.save(); + ctx.translate(x, y); + ctx.globalAlpha *= opacity; + ctx.strokeStyle = "#b5c8a3"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(0, 0, radius, 0, Math.PI * 2); + ctx.stroke(); + for (let i = 0; i < 48; i++) { + const a = (i / 48) * Math.PI * 2; + const inner = radius - (i % 12 === 0 ? 12 : i % 3 === 0 ? 7 : 3); + ctx.beginPath(); + ctx.moveTo(Math.cos(a) * inner, Math.sin(a) * inner); + ctx.lineTo(Math.cos(a) * radius, Math.sin(a) * radius); + ctx.stroke(); + } + ctx.rotate(angle); + ctx.fillStyle = "#dfb76e"; + ctx.beginPath(); + ctx.moveTo(0, -radius * 0.58); + ctx.lineTo(radius * 0.14, 0); + ctx.lineTo(0, radius * 0.12); + ctx.lineTo(-radius * 0.14, 0); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = "#526e60"; + ctx.beginPath(); + ctx.moveTo(0, radius * 0.58); + ctx.lineTo(radius * 0.14, 0); + ctx.lineTo(0, -radius * 0.12); + ctx.lineTo(-radius * 0.14, 0); + ctx.closePath(); + ctx.fill(); + ctx.restore(); +} +function overlay(t) { + ctx.clearRect(0, 0, width, height); + ctx.drawImage(renderer.domElement, 0, 0); + const scale = width / (banner ? 1200 : 1280); + ctx.save(); + ctx.scale(scale, scale); + const w = width / scale, + h = height / scale; + const gradient = ctx.createLinearGradient(0, 0, w, 0); + gradient.addColorStop(0, "rgba(5,16,12,.95)"); + gradient.addColorStop(0.38, "rgba(5,16,12,.78)"); + gradient.addColorStop(0.75, "rgba(5,16,12,0)"); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, w, h); + const vignette = ctx.createRadialGradient( + w * 0.6, + h * 0.42, + h * 0.2, + w * 0.5, + h * 0.5, + w * 0.7, + ); + vignette.addColorStop(0, "rgba(3,12,9,0)"); + vignette.addColorStop(1, "rgba(3,12,9,.7)"); + ctx.fillStyle = vignette; + ctx.fillRect(0, 0, w, h); + ctx.strokeStyle = "rgba(161,193,151,.25)"; + ctx.lineWidth = 1; + cross(32, 32); + cross(w - 32, 32); + cross(32, h - 32); + cross(w - 32, h - 32); + if (banner) { + label("A MINECRAFT GUESSING GAME", 62, 118, 13); + text("MINE", 58, 231, 78); + text("GEO", 297, 231, 78, "#b3dd85"); + text("GET LOST. FIND YOUR WORLD.", 62, 280, 16, "#c4d1b5"); + ctx.fillStyle = "#b6d790"; + ctx.fillRect(62, 326, 28, 2); + label("MINEGEO.SITE", 105, 333, 13, "#b6d790"); + compass(w - 70, 72, 28, -0.5 + Math.sin((t * Math.PI * 2) / 3) * 0.1); + label("N", w - 75, 30, 10, "#dfb76e"); + } else { + const scenes = [ + { + start: 0, + end: 4, + kicker: "SOMEWHERE IN THE OVERWORLD", + lines: ["EVERY WORLD", "HAS A PLACE."], + sub: "But can you find yours?", + size: 62, + }, + { + start: 4, + end: 9, + kicker: "01 / EXPLORE", + lines: ["YOUR EXPEDITION STARTS HERE."], + sub: "The real MineGeo website. A new way to see Minecraft.", + image: "home", + size: 62, + }, + { + start: 9, + end: 14, + kicker: "02 / DISCOVER", + lines: ["PICK YOUR WORLD."], + sub: "Browse published maps from the community.", + image: "catalog", + size: 68, + }, + { + start: 14, + end: 19, + kicker: "03 / MAKE YOUR GUESS", + lines: ["LOOK. GUESS. DISCOVER."], + sub: "Real in-game capture. Look around, then place your pin.", + image: "game", + size: 62, + }, + { + start: 19, + end: 24, + kicker: "04 / GO FURTHER", + lines: ["MAKE IT YOUR EXPEDITION."], + sub: "Choose your map, difficulty, and game mode.", + image: "map", + size: 62, + }, + { + start: 24, + end: 30, + kicker: "YOUR NEXT ADVENTURE STARTS HERE", + lines: ["MINEGEO"], + sub: "Get lost in a familiar world.", + size: 82, + }, + ]; + const shot = scenes.find((s) => t >= s.start && t < s.end) || scenes.at(-1); + const local = t - shot.start; + const alpha = + smooth(local / 0.55) * + (shot.end === 30 ? 1 : 1 - smooth((t - (shot.end - 0.5)) / 0.5)); + ctx.save(); + ctx.globalAlpha = alpha; + const rise = (1 - smooth(local / 0.8)) * 16; + if (shot.image) { + label(shot.kicker, 65, 58, 11, "#b0cb92"); + text(shot.lines[0], 62, 104 + rise, 30, "#e4edcf"); + ctx.font = "14px Arial"; + ctx.fillStyle = "#b9cbb4"; + ctx.fillText(shot.sub, 65, 128 + rise); + const image = screenshots[shot.image]; + const panelWidth = 950, + cropHeight = image.naturalHeight * 0.93; + const imageHeight = (cropHeight / image.naturalWidth) * panelWidth; + const x = (w - panelWidth) / 2, + y = 164 + (1 - smooth(local / 0.8)) * 18; + const grow = 0.98 + smooth(local / 5) * 0.02; + ctx.translate(w / 2, y + imageHeight / 2); + ctx.scale(grow, grow); + ctx.translate(-w / 2, -y - imageHeight / 2); + ctx.shadowColor = "rgba(0,0,0,.8)"; + ctx.shadowBlur = 36; + ctx.shadowOffsetY = 12; + ctx.fillStyle = "#14241e"; + ctx.beginPath(); + ctx.roundRect(x - 1, y - 25, panelWidth + 2, imageHeight + 26, 9); + ctx.fill(); + ctx.shadowBlur = 0; + ctx.shadowOffsetY = 0; + ctx.strokeStyle = "#658366"; + ctx.stroke(); + for (let i = 0; i < 3; i++) { + ctx.fillStyle = ["#ad7966", "#bda169", "#83a374"][i]; + ctx.beginPath(); + ctx.arc(x + 15 + i * 12, y - 12, 3, 0, Math.PI * 2); + ctx.fill(); + } + label( + `MINEGEO.SITE / ${shot.image === "game" ? "PLAY" : shot.image === "home" ? "HOME" : "WORLDS"}`, + x + 74, + y - 8, + 8, + "#b6c5af", + ); + ctx.save(); + ctx.beginPath(); + ctx.rect(x, y, panelWidth, imageHeight); + ctx.clip(); + const zoom = 1 + local * 0.004; + ctx.drawImage( + image, + 0, + 0, + image.naturalWidth, + cropHeight, + x - (panelWidth * (zoom - 1)) / 2, + y - (imageHeight * (zoom - 1)) / 2, + panelWidth * zoom, + imageHeight * zoom, + ); + ctx.restore(); + } else { + label(shot.kicker, 64, 225 + rise, 12, "#b0cb92"); + shot.lines.forEach((line, i) => + text( + line, + 60, + 320 + i * 76 + rise, + shot.size, + i === shot.lines.length - 1 ? "#b3dd85" : "#f0f2da", + ), + ); + const subY = shot.lines.length === 1 ? 390 : 444; + ctx.font = "18px Arial"; + ctx.textAlign = "left"; + ctx.fillStyle = "#becdb9"; + ctx.fillText(shot.sub, 65, subY + rise); + if (shot.start === 24) { + ctx.strokeStyle = "#87a768"; + ctx.strokeRect(65, 432, 260, 54); + text("MINEGEO.SITE", 88, 467, 20, "#d7e7c1"); + label("FREE TO EXPLORE", 65, 528, 10); + } + } + ctx.restore(); + if (!shot.image) label("MINEGEO / FIELD NOTES", 65, 65, 11); + label( + `${String(Math.floor(t)).padStart(2, "0")} : ${String(Math.floor((t % 1) * 24)).padStart(2, "0")} / 30`, + w - 245, + shot.image ? h - 9 : h - 49, + 10, + ); + text("N", w - 92, 62, 12, "#dbc18c"); + compass(w - 88, 115, 34, -t * 0.08); + ctx.strokeStyle = "rgba(168,195,147,.22)"; + ctx.beginPath(); + ctx.moveTo(65, h - (shot.image ? 27 : 72)); + ctx.lineTo(w - 65, h - (shot.image ? 27 : 72)); + ctx.stroke(); + ctx.strokeStyle = "#afce83"; + ctx.beginPath(); + ctx.moveTo(65, h - (shot.image ? 27 : 72)); + ctx.lineTo(65 + ((w - 130) * t) / 30, h - (shot.image ? 27 : 72)); + ctx.stroke(); + if (!shot.image && t > 14 && t < 19) { + const a = smooth((t - 14) / 0.5) * (1 - smooth((t - 18.5) / 0.5)); + ctx.globalAlpha = a; + label("PIN PLACED", w - 250, h - 120, 12, "#f0d19b"); + ctx.globalAlpha = 1; + } + // Very short dark dissolves separate the six camera moves. + const cut = [4, 9, 14, 19, 24].reduce( + (a, c) => Math.max(a, 1 - Math.abs(t - c) / 0.2), + 0, + ); + const fadeIn = 1 - smooth(t / 0.7), + fadeOut = smooth((t - 29.3) / 0.7); + ctx.fillStyle = `rgba(5,14,10,${Math.max(cut, fadeIn, fadeOut)})`; + ctx.fillRect(0, 0, w, h); + } + ctx.restore(); +} +function renderAt(t) { + const loop = (t / 3) * Math.PI * 2; + let azimuth, distance, elevation, lookY; + if (banner) { + azimuth = 0.64 + Math.sin(loop) * 0.035; + distance = 89 + Math.cos(loop) * 1.1; + elevation = 40; + lookY = 0; + } else if (t < 4) { + const u = smooth(t / 4); + azimuth = 0.55 + u * 0.2; + distance = 99 - u * 13; + elevation = 48 - u * 5; + lookY = 0; + } else if (t < 9) { + const u = (t - 4) / 5; + azimuth = 0.45 + u * 0.6; + distance = 82 - u * 5; + elevation = 35; + lookY = 2; + } else if (t < 14) { + const u = (t - 9) / 5; + azimuth = 1.8 + u * 0.45; + distance = 64 + u * 4; + elevation = 26; + lookY = 3; + } else if (t < 19) { + const u = (t - 14) / 5; + azimuth = 0.6 + u * 0.35; + distance = 67; + elevation = 70 - u * 10; + lookY = 0; + } else if (t < 24) { + const u = (t - 19) / 5; + azimuth = 2.9 + u * 0.55; + distance = 83; + elevation = 35 + u * 7; + lookY = 0; + } else { + const u = (t - 24) / 6; + azimuth = 0.68 + u * 0.12; + distance = 93 - u * 4; + elevation = 44; + lookY = 0; + } + camera.position.set( + Math.sin(azimuth) * distance, + elevation, + Math.cos(azimuth) * distance, + ); + camera.lookAt(0, lookY, 0); + // Move the landscape to the right to leave genuinely quiet space for titles. + camera.setViewOffset( + width, + height, + -width * (banner ? 0.21 : 0.16), + 0, + width, + height, + ); + world.position.y = Math.sin(banner ? loop : t * 0.5) * 0.2; + pins.forEach((p, i) => { + p.group.position.y = + p.base + Math.sin((banner ? loop : t * 1.6) + i * 2) * 0.24; + p.group.rotation.y = (banner ? loop : t * 0.4) + i; + p.halo.scale.setScalar(1 + Math.sin((banner ? loop : t * 2) + i) * 0.08); + p.group.visible = banner ? i === 0 : t > 13.8 + i * 2; + }); + motes.rotation.y = banner ? Math.sin(loop) * 0.005 : t * 0.005; + ringGroup.rotation.y = banner ? loop : t * 0.25; + renderer.render(scene, camera); + overlay(t); +} +window.renderCinematic = renderAt; +window.cinematicCanvas = canvas; +document.querySelector("#status").remove(); +renderAt(Number(params.get("time")) || 0); +window.cinematicReady = true; +if (!params.has("still")) { + const start = performance.now(); + function loopFrame(now) { + renderAt(((now - start) / 1000) % (banner ? 3 : 30)); + requestAnimationFrame(loopFrame); + } + requestAnimationFrame(loopFrame); +} diff --git a/cinematics/screenshots/catalog.jpg b/cinematics/screenshots/catalog.jpg new file mode 100644 index 0000000..79636bc Binary files /dev/null and b/cinematics/screenshots/catalog.jpg differ diff --git a/cinematics/screenshots/game.jpg b/cinematics/screenshots/game.jpg new file mode 100644 index 0000000..a988635 Binary files /dev/null and b/cinematics/screenshots/game.jpg differ diff --git a/cinematics/screenshots/home.jpg b/cinematics/screenshots/home.jpg new file mode 100644 index 0000000..abe305c Binary files /dev/null and b/cinematics/screenshots/home.jpg differ diff --git a/cinematics/screenshots/map.jpg b/cinematics/screenshots/map.jpg new file mode 100644 index 0000000..3a9b0a3 Binary files /dev/null and b/cinematics/screenshots/map.jpg differ diff --git a/components/FirstVisitIntro.tsx b/components/FirstVisitIntro.tsx new file mode 100644 index 0000000..21e34f9 --- /dev/null +++ b/components/FirstVisitIntro.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useId, useRef } from "react"; +import { Compass, MapPin } from "lucide-react"; +import { useI18n } from "./I18nProvider"; + +const storageKey = "minegeo:intro:v1"; +let seenThisSession = false; + +/** Home-only, decorative welcome. The underlying page always renders normally. */ +export function FirstVisitIntro() { + const { t } = useI18n(); + const titleId = useId(); + const dialog = useRef(null); + + useEffect(() => { + const element = dialog.current; + if (!element) return; + const motion = window.matchMedia("(prefers-reduced-motion: reduce)"); + let end: ReturnType | undefined; + const finish = () => { + clearTimeout(end); + if (element.open) element.close(); + }; + // Defer the browser-only decision; Strict Mode can clean up its first setup. + const start = setTimeout(() => { + if (seenThisSession) return; + try { + if (localStorage.getItem(storageKey)) return; + localStorage.setItem(storageKey, "seen"); + } catch { + try { + if (sessionStorage.getItem(storageKey)) return; + sessionStorage.setItem(storageKey, "seen"); + } catch { + // Both stores denied: keep the fallback for this page session in memory. + } + } + seenThisSession = true; + if (motion.matches || !element.isConnected) return; + // A welcome must never interrupt another modal, such as account notices. + if (document.querySelector("dialog[open]")) return; + element.showModal(); + end = setTimeout(finish, 4750); + }, 0); + const motionChanged = () => { + if (motion.matches) finish(); + }; + motion.addEventListener("change", motionChanged); + element.addEventListener("close", finish); + return () => { + clearTimeout(start); + finish(); + motion.removeEventListener("change", motionChanged); + element.removeEventListener("close", finish); + }; + }, []); + + return ( + + + ); +} diff --git a/components/SiteTrailer.tsx b/components/SiteTrailer.tsx new file mode 100644 index 0000000..2cca3d8 --- /dev/null +++ b/components/SiteTrailer.tsx @@ -0,0 +1,55 @@ +import { getI18n } from "@/lib/i18n"; +import { Download, Film } from "lucide-react"; + +export async function SiteTrailer() { + const { t } = await getI18n(); + return ( +
+
+
+ + +

{t("A world worth getting lost in.")}

+
+ + {t("30-second cinematic teaser")} + +
+ +
+

+ {t( + "A tour of the real MineGeo homepage, community maps, map setup, and in-game panorama, blended with animated voxel landscapes. Ambient music; no spoken dialogue.", + )} +

+ + +
+
+ ); +} diff --git a/docs/cinematics.md b/docs/cinematics.md new file mode 100644 index 0000000..bf90d7e --- /dev/null +++ b/docs/cinematics.md @@ -0,0 +1,31 @@ +# MineGeo cinematics + +## Deliverables + +- `public/media/minegeo-discord-banner.gif`: 600 × 240, 3-second looping banner at 24 fps. Created in Three.js; the camera, compass sweep, pins and terrain movement return to their initial state at the loop boundary. +- `public/media/minegeo-discord-banner.png`: 1200 × 480 still fallback. +- `public/media/minegeo-trailer.mp4`: 1280 × 720, 30 seconds at 24 fps, H.264/AAC, optimized for progressive web playback. Includes an original synthesized ambient soundtrack with no speech. +- `public/media/minegeo-trailer-poster.jpg` and `minegeo-trailer.vtt`: poster and English captions. + +The trailer combines a Three.js opening and closing with genuine screenshots of the homepage, map catalog, in-game panorama, and map setup, captured through the browser from the local app against the real published catalog. Screenshots are stored in `cinematics/screenshots/` and animated inside a framed window; they are still captures, not a gameplay recording. A guest game was opened for the panorama capture; no score was submitted. The intro/closing terrain, hut, arch, pins, particles, compass, typography and sound are generated locally; no third-party footage or music is used. Typography uses the project's existing licensed Monocraft font. + +The homepage provides native video controls, captions, a descriptive text alternative, and a download link. The video does not autoplay and uses `preload="none"`. + +## First visit + +`FirstVisitIntro` runs only on the homepage and lasts 4.75 seconds. It uses CSS terrain tiles, a compass/pin reveal, and text fading out before the backdrop reveals the site. The Skip button and Escape dismiss it early. It bypasses the animation when reduced motion is requested and closes if that preference changes during playback. + +The `minegeo:intro:v1` local-storage key remembers a visit per browser/origin. If local storage is unavailable, it falls back to session storage and then in-memory state. The regular page always renders on the server; JavaScript failure cannot leave it hidden. The welcome does not interrupt an already-open dialog. + +## Rebuild media + +The installed project dependencies supply Three.js, Sharp and Playwright. Install Playwright's Chromium runtime and a local FFmpeg executable if they are not already present. These are production tools; visitors receive only the compressed media and the lightweight CSS intro, without Three.js on the homepage. + +```sh +node scripts/render-cinematics.mjs +node scripts/render-cinematics.mjs --banner-only +node scripts/render-cinematics.mjs --trailer-only +node scripts/render-cinematics.mjs --preview +``` + +The preview binds to `127.0.0.1:3110`. Open `/?mode=banner` for the loop or `/?time=16&still=1` for a trailer still. Temporary frames and synthesized audio stay in ignored `.data/cinematic-render/`; deliverables are written to `public/media/`. The export uses an isolated headless graphics renderer, does not access user browser sessions, and needs no credentials or network assets. diff --git a/public/media/minegeo-discord-banner.gif b/public/media/minegeo-discord-banner.gif new file mode 100644 index 0000000..ab81811 Binary files /dev/null and b/public/media/minegeo-discord-banner.gif differ diff --git a/public/media/minegeo-discord-banner.png b/public/media/minegeo-discord-banner.png new file mode 100644 index 0000000..b9d88cf Binary files /dev/null and b/public/media/minegeo-discord-banner.png differ diff --git a/public/media/minegeo-trailer-poster.jpg b/public/media/minegeo-trailer-poster.jpg new file mode 100644 index 0000000..aabd8d2 Binary files /dev/null and b/public/media/minegeo-trailer-poster.jpg differ diff --git a/public/media/minegeo-trailer.mp4 b/public/media/minegeo-trailer.mp4 new file mode 100644 index 0000000..a5f724a Binary files /dev/null and b/public/media/minegeo-trailer.mp4 differ diff --git a/public/media/minegeo-trailer.vtt b/public/media/minegeo-trailer.vtt new file mode 100644 index 0000000..72c26f4 --- /dev/null +++ b/public/media/minegeo-trailer.vtt @@ -0,0 +1,25 @@ +WEBVTT + +00:00.000 --> 00:04.000 +[Ambient electronic music. A floating voxel island emerges.] +Every world has a place. But can you find yours? + +00:04.000 --> 00:09.000 +[Screenshot of the real MineGeo homepage.] +Your expedition starts here. A new way to see Minecraft. + +00:09.000 --> 00:14.000 +[Screenshot of the community map catalog.] +Pick your world. Browse published maps from the community. + +00:14.000 --> 00:19.000 +[Real in-game panorama with a pin selected on the map.] +Look. Guess. Discover. Look around, then place your pin. + +00:19.000 --> 00:24.000 +[Screenshot of map details and difficulty options.] +Make it your expedition. Choose your map, difficulty, and game mode. + +00:24.000 --> 00:30.000 +MineGeo. Get lost in a familiar world. +Your next adventure starts at minegeo.site. diff --git a/scripts/render-cinematics.mjs b/scripts/render-cinematics.mjs new file mode 100644 index 0000000..a2f155a --- /dev/null +++ b/scripts/render-cinematics.mjs @@ -0,0 +1,303 @@ +// Offline media production. This uses an isolated renderer, not a user's browser. +import { chromium } from "@playwright/test"; +import { createServer } from "node:http"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { resolve } from "node:path"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import sharp from "sharp"; + +const root = process.cwd(); +const output = resolve(root, "public/media"); +const work = resolve(root, ".data/cinematic-render"); +const previewOnly = process.argv.includes("--preview"); +const bannerOnly = process.argv.includes("--banner-only"); +const trailerOnly = process.argv.includes("--trailer-only"); +await mkdir(output, { recursive: true }); +await mkdir(work, { recursive: true }); +const routes = new Map([ + ["/", "cinematics/index.html"], + ["/cinematics/scene.mjs", "cinematics/scene.mjs"], + ["/three/three.module.js", "node_modules/three/build/three.module.js"], + ["/three/three.core.js", "node_modules/three/build/three.core.js"], + ["/fonts/Monocraft.ttf", "public/fonts/Monocraft.ttf"], + ...["home", "catalog", "map", "game"].map((name) => [ + `/shots/${name}.jpg`, + `cinematics/screenshots/${name}.jpg`, + ]), +]); +const server = createServer(async (req, res) => { + const path = routes.get(new URL(req.url, "http://localhost").pathname); + if (!path) { + res.writeHead(404); + res.end(); + return; + } + try { + const type = + path.endsWith(".mjs") || path.endsWith(".js") + ? "text/javascript" + : path.endsWith(".ttf") + ? "font/ttf" + : path.endsWith(".jpg") + ? "image/jpeg" + : "text/html"; + res.setHeader("Content-Type", type); + res.end(await readFile(resolve(root, path))); + } catch { + res.writeHead(500); + res.end("Renderer asset unavailable"); + } +}); +await new Promise((r) => server.listen(previewOnly ? 3110 : 0, "127.0.0.1", r)); +const origin = `http://127.0.0.1:${server.address().port}`; +if (previewOnly) { + console.log( + `Cinematic preview: ${origin}/?mode=banner or ${origin}/?time=25&still=1`, + ); +} else { + let browser; + try { + browser = await chromium.launch({ + headless: true, + args: [ + "--enable-gpu", + "--enable-unsafe-swiftshader", + "--disable-background-timer-throttling", + ], + }); + const page = await browser.newPage({ + viewport: { width: 1280, height: 720 }, + deviceScaleFactor: 1, + }); + page.on("pageerror", (error) => { + console.error("Renderer error:", error.message); + }); + const open = async (mode, width, height) => { + await page.setViewportSize({ width, height }); + await page.goto( + `${origin}/?mode=${mode}&width=${width}&height=${height}&still=1`, + ); + await page.waitForFunction( + () => window.cinematicReady === true, + undefined, + { timeout: 30000 }, + ); + }; + if (!trailerOnly) { + await open("banner", 1200, 480); + const dir = resolve(work, "banner"); + await mkdir(dir, { recursive: true }); + for (let frame = 0; frame < 72; frame++) { + await page.evaluate((t) => window.renderCinematic(t), frame / 24); + await page.screenshot({ + path: resolve(dir, `frame-${String(frame).padStart(3, "0")}.png`), + type: "png", + }); + if (frame % 24 === 0) console.log(`Banner: ${frame}/72 frames`); + } + await runFfmpeg([ + "-framerate", + "24", + "-i", + resolve(dir, "frame-%03d.png"), + "-filter_complex", + "scale=600:240:flags=lanczos,split[a][b];[a]palettegen=max_colors=128:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=3:diff_mode=rectangle", + "-loop", + "0", + resolve(output, "minegeo-discord-banner.gif"), + ]); + await page.evaluate(() => window.renderCinematic(0.75)); + await sharp(await page.screenshot()) + .resize(1200, 480) + .png() + .toFile(resolve(output, "minegeo-discord-banner.png")); + console.log("Banner complete: 600 × 240, 3 seconds, seamless loop"); + } + if (!bannerOnly) { + await open("trailer", 1280, 720); + await page.evaluate(() => window.renderCinematic(6.5)); + await sharp(await page.screenshot()) + .jpeg({ quality: 90 }) + .toFile(resolve(output, "minegeo-trailer-poster.jpg")); + const soundtrack = resolve(work, "soundtrack.wav"); + await writeFile(soundtrack, createSoundtrack()); + const ffmpeg = spawn( + "ffmpeg", + [ + "-y", + "-hide_banner", + "-loglevel", + "warning", + "-f", + "image2pipe", + "-vcodec", + "png", + "-framerate", + "24", + "-i", + "pipe:0", + "-i", + soundtrack, + "-c:v", + "libx264", + "-preset", + "medium", + "-crf", + "20", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-b:a", + "128k", + "-t", + "30", + "-movflags", + "+faststart", + resolve(output, "minegeo-trailer.mp4"), + ], + { windowsHide: true, stdio: ["pipe", "ignore", "pipe"] }, + ); + let stderr = ""; + ffmpeg.stderr.on("data", (b) => { + stderr += b.toString(); + }); + let encoderError; + ffmpeg.on("error", (e) => { + encoderError = e; + }); + const done = once(ffmpeg, "close"); + for (let frame = 0; frame < 720; frame++) { + if (encoderError) throw encoderError; + await page.evaluate((t) => window.renderCinematic(t), frame / 24); + const png = await page.screenshot({ type: "png" }); + if (!ffmpeg.stdin.write(png)) await once(ffmpeg.stdin, "drain"); + if (frame % 48 === 0) + console.log( + `Trailer: ${frame}/720 frames (${Math.round((frame / 720) * 100)}%)`, + ); + } + ffmpeg.stdin.end(); + const [code] = await done; + if (code !== 0) throw new Error(`FFmpeg failed: ${stderr}`); + await writeFile(resolve(output, "minegeo-trailer.vtt"), createCaptions()); + console.log( + "Trailer complete: 1280 × 720, 30 seconds, original ambient soundtrack", + ); + } + } finally { + await browser?.close(); + await new Promise((r) => server.close(r)); + } +} + +async function runFfmpeg(args) { + const child = spawn( + "ffmpeg", + ["-y", "-hide_banner", "-loglevel", "warning", ...args], + { windowsHide: true, stdio: ["ignore", "ignore", "pipe"] }, + ); + let stderr = ""; + child.stderr.on("data", (b) => { + stderr += b.toString(); + }); + const [code] = await once(child, "close"); + if (code !== 0) throw new Error(`FFmpeg failed: ${stderr}`); +} + +function createSoundtrack() { + const rate = 48000, + seconds = 30, + length = rate * seconds; + const buffer = Buffer.alloc(44 + length * 4); + buffer.write("RIFF", 0); + buffer.writeUInt32LE(buffer.length - 8, 4); + buffer.write("WAVEfmt ", 8); + buffer.writeUInt32LE(16, 16); + buffer.writeUInt16LE(1, 20); + buffer.writeUInt16LE(2, 22); + buffer.writeUInt32LE(rate, 24); + buffer.writeUInt32LE(rate * 4, 28); + buffer.writeUInt16LE(4, 32); + buffer.writeUInt16LE(16, 34); + buffer.write("data", 36); + buffer.writeUInt32LE(length * 4, 40); + const chords = [ + [146.832, 220, 293.665, 349.228], + [130.813, 196, 261.626, 329.628], + [116.541, 174.614, 233.082, 293.665], + [130.813, 196, 261.626, 349.228], + ]; + for (let i = 0; i < length; i++) { + const t = i / rate, + chord = chords[Math.floor(t / 4) % 4]; + const fade = Math.min(1, t / 2, (30 - t) / 2); + const local = t % 4, + change = Math.min(1, local / 0.4, (4 - local) / 0.4); + let pad = 0; + for (const f of chord) + pad += + (Math.sin(2 * Math.PI * f * t) + + 0.25 * Math.sin(2 * Math.PI * f * 1.002 * t)) * + 0.017; + const step = Math.floor(t / 0.5), + pluckTime = t % 0.5; + const freq = chord[[0, 2, 1, 3, 2, 1, 3, 2][step % 8]] * 2; + const pluck = + Math.sin(2 * Math.PI * freq * t) * Math.exp(-pluckTime * 8) * 0.07; + const beat = t % 1; + const bass = + Math.sin(2 * Math.PI * 55 * beat - 7 * Math.exp(-beat * 25)) * + Math.exp(-beat * 12) * + 0.045; + const swell = [4, 9, 14, 19, 24].reduce( + (n, c) => n + Math.exp(-(((t - c) / 0.45) ** 2)), + 0, + ); + const air = + (Math.sin(t * 4131) + Math.sin(t * 5999) + Math.sin(t * 7123)) * + 0.004 * + swell; + const sample = (pad * (0.65 + 0.35 * change) + pluck + bass + air) * fade; + const left = sample * (0.94 + 0.06 * Math.sin(t * 0.4)), + right = sample * (0.94 - 0.06 * Math.sin(t * 0.4)); + buffer.writeInt16LE( + Math.round(Math.max(-1, Math.min(1, left)) * 32767), + 44 + i * 4, + ); + buffer.writeInt16LE( + Math.round(Math.max(-1, Math.min(1, right)) * 32767), + 46 + i * 4, + ); + } + return buffer; +} +function createCaptions() { + return `WEBVTT + +00:00.000 --> 00:04.000 +[Ambient electronic music. A floating voxel island emerges.] +Every world has a place. But can you find yours? + +00:04.000 --> 00:09.000 +[Screenshot of the real MineGeo homepage.] +Your expedition starts here. A new way to see Minecraft. + +00:09.000 --> 00:14.000 +[Screenshot of the community map catalog.] +Pick your world. Browse published maps from the community. + +00:14.000 --> 00:19.000 +[Real in-game panorama with a pin selected on the map.] +Look. Guess. Discover. Look around, then place your pin. + +00:19.000 --> 00:24.000 +[Screenshot of map details and difficulty options.] +Make it your expedition. Choose your map, difficulty, and game mode. + +00:24.000 --> 00:30.000 +MineGeo. Get lost in a familiar world. +Your next adventure starts at minegeo.site. +`; +} diff --git a/shared/locales/ru.json b/shared/locales/ru.json index 28e8455..eb8dc19 100644 --- a/shared/locales/ru.json +++ b/shared/locales/ru.json @@ -1,4 +1,14 @@ { + "MineGeo": "MineGeo", + "Find your place in a blocky world.": "Найди своё место в мире блоков.", + "Skip intro": "Пропустить вступление", + "THE EXPEDITION AWAITS": "ЭКСПЕДИЦИЯ ЖДЁТ", + "A world worth getting lost in.": "Мир, в котором стоит потеряться.", + "30-second cinematic teaser": "Кинематографичный тизер · 30 секунд", + "MineGeo cinematic teaser": "Кинематографичный тизер MineGeo", + "Your browser cannot play this video.": "Ваш браузер не поддерживает воспроизведение этого видео.", + "Download the trailer": "Скачать трейлер", + "A tour of the real MineGeo homepage, community maps, map setup, and in-game panorama, blended with animated voxel landscapes. Ambient music; no spoken dialogue.": "Обзор настоящей главной страницы MineGeo, карт сообщества, выбора сложности и игровой панорамы с анимированными блочными пейзажами. Атмосферная музыка, без речи.", "Deleted player": "Удалённый игрок", "Open map": "Открыть карту", "Account ID:": "ID аккаунта:",