Add cinematic banner, real-site trailer, and first-visit intro
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m14s
377
app/cinematic-intro.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import "./support.css";
|
|||||||
import "./appearance.css";
|
import "./appearance.css";
|
||||||
import "./community.css";
|
import "./community.css";
|
||||||
import "./community-social.css";
|
import "./community-social.css";
|
||||||
|
import "./cinematic-intro.css";
|
||||||
import { themeInitScript } from "@/shared/theme";
|
import { themeInitScript } from "@/shared/theme";
|
||||||
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
|
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
|
||||||
import { initialSiteSettings } from "@/lib/site-settings";
|
import { initialSiteSettings } from "@/lib/site-settings";
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { catalog } from "@/lib/catalog";
|
|||||||
import { leaderboard } from "@/lib/leaderboard";
|
import { leaderboard } from "@/lib/leaderboard";
|
||||||
import { LeaderboardPreview } from "@/components/Leaderboard";
|
import { LeaderboardPreview } from "@/components/Leaderboard";
|
||||||
import desktopRelease from "@/shared/desktop-release.json";
|
import desktopRelease from "@/shared/desktop-release.json";
|
||||||
|
import { FirstVisitIntro } from "@/components/FirstVisitIntro";
|
||||||
|
import { SiteTrailer } from "@/components/SiteTrailer";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const { t, formatNumber } = await getI18n();
|
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);
|
const locations = maps.reduce((total, map) => total + map.panorama_count, 0);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<FirstVisitIntro />
|
||||||
<Header />
|
<Header />
|
||||||
<main className="home-page" id="main-content">
|
<main className="home-page" id="main-content">
|
||||||
<section className="hero">
|
<section className="hero">
|
||||||
@@ -171,6 +174,7 @@ export default async function Home() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
<SiteTrailer />
|
||||||
<LeaderboardPreview data={rankings} />
|
<LeaderboardPreview data={rankings} />
|
||||||
<section className="section home-panels">
|
<section className="section home-panels">
|
||||||
<article className="home-panel">
|
<article className="home-panel">
|
||||||
|
|||||||
43
cinematics/index.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>MineGeo cinematic studio</title>
|
||||||
|
<style>
|
||||||
|
@font-face {
|
||||||
|
font-family: Pixel;
|
||||||
|
src: url("/fonts/Monocraft.ttf");
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #08140f;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
#status {
|
||||||
|
position: fixed;
|
||||||
|
color: #dcf3d6;
|
||||||
|
font: 14px monospace;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script type="importmap">
|
||||||
|
{ "imports": { "three": "/three/three.module.js" } }
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="status">Building your world…</div>
|
||||||
|
<script type="module" src="/cinematics/scene.mjs"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
633
cinematics/scene.mjs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
BIN
cinematics/screenshots/catalog.jpg
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
cinematics/screenshots/game.jpg
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
cinematics/screenshots/home.jpg
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
cinematics/screenshots/map.jpg
Normal file
|
After Width: | Height: | Size: 162 KiB |
96
components/FirstVisitIntro.tsx
Normal file
@@ -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<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = dialog.current;
|
||||||
|
if (!element) return;
|
||||||
|
const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
|
let end: ReturnType<typeof setTimeout> | 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 (
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
className="first-visit-intro"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
>
|
||||||
|
<div className="intro-landscape" aria-hidden="true">
|
||||||
|
<div className="intro-horizon" />
|
||||||
|
<div className="intro-terrain">
|
||||||
|
{Array.from({ length: 15 }, (_, index) => (
|
||||||
|
<i key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="intro-compass">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<Compass strokeWidth={1} />
|
||||||
|
<MapPin className="intro-pin" strokeWidth={1.4} />
|
||||||
|
</div>
|
||||||
|
<span className="intro-coordinate intro-coordinate-left">X: −128</span>
|
||||||
|
<span className="intro-coordinate intro-coordinate-right">Z: +256</span>
|
||||||
|
</div>
|
||||||
|
<div className="intro-title">
|
||||||
|
<p className="eyebrow">{t("YOUR NEXT EXPEDITION")}</p>
|
||||||
|
<h2 id={titleId}>{t("MineGeo")}</h2>
|
||||||
|
<p>{t("Find your place in a blocky world.")}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="intro-skip button"
|
||||||
|
autoFocus
|
||||||
|
onClick={() => dialog.current?.close()}
|
||||||
|
>
|
||||||
|
{t("Skip intro")} <span aria-hidden="true">↵</span>
|
||||||
|
</button>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
components/SiteTrailer.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { getI18n } from "@/lib/i18n";
|
||||||
|
import { Download, Film } from "lucide-react";
|
||||||
|
|
||||||
|
export async function SiteTrailer() {
|
||||||
|
const { t } = await getI18n();
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="section site-trailer"
|
||||||
|
aria-labelledby="site-trailer-title"
|
||||||
|
>
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">
|
||||||
|
<Film size={14} aria-hidden="true" /> {t("THE EXPEDITION AWAITS")}
|
||||||
|
</span>
|
||||||
|
<h2 id="site-trailer-title">{t("A world worth getting lost in.")}</h2>
|
||||||
|
</div>
|
||||||
|
<span className="trailer-duration">
|
||||||
|
{t("30-second cinematic teaser")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<video
|
||||||
|
className="site-trailer-player"
|
||||||
|
controls
|
||||||
|
preload="none"
|
||||||
|
playsInline
|
||||||
|
poster="/media/minegeo-trailer-poster.jpg"
|
||||||
|
aria-label={t("MineGeo cinematic teaser")}
|
||||||
|
aria-describedby="site-trailer-description"
|
||||||
|
>
|
||||||
|
<source src="/media/minegeo-trailer.mp4" type="video/mp4" />
|
||||||
|
<track
|
||||||
|
kind="captions"
|
||||||
|
src="/media/minegeo-trailer.vtt"
|
||||||
|
srcLang="en"
|
||||||
|
label={t("English")}
|
||||||
|
/>
|
||||||
|
<p>
|
||||||
|
{t("Your browser cannot play this video.")}{" "}
|
||||||
|
<a href="/media/minegeo-trailer.mp4">{t("Download the trailer")}</a>
|
||||||
|
</p>
|
||||||
|
</video>
|
||||||
|
<div className="trailer-caption">
|
||||||
|
<p id="site-trailer-description">
|
||||||
|
{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.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<a className="text-link" href="/media/minegeo-trailer.mp4" download>
|
||||||
|
<Download size={15} aria-hidden="true" /> {t("Download the trailer")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
docs/cinematics.md
Normal file
@@ -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.
|
||||||
BIN
public/media/minegeo-discord-banner.gif
Normal file
|
After Width: | Height: | Size: 598 KiB |
BIN
public/media/minegeo-discord-banner.png
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
public/media/minegeo-trailer-poster.jpg
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
public/media/minegeo-trailer.mp4
Normal file
25
public/media/minegeo-trailer.vtt
Normal file
@@ -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.
|
||||||
303
scripts/render-cinematics.mjs
Normal file
@@ -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.
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -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": "Удалённый игрок",
|
"Deleted player": "Удалённый игрок",
|
||||||
"Open map": "Открыть карту",
|
"Open map": "Открыть карту",
|
||||||
"Account ID:": "ID аккаунта:",
|
"Account ID:": "ID аккаунта:",
|
||||||
|
|||||||