Files
MineGeo/cinematics/scene.mjs
pavel444-byte ad73132f15
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m14s
Add cinematic banner, real-site trailer, and first-visit intro
2026-10-17 19:54:23 +05:00

634 lines
19 KiB
JavaScript

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