Improve MineGeo UI and application flows
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
This commit is contained in:
274
cinematics/composition.mjs
Normal file
274
cinematics/composition.mjs
Normal file
@@ -0,0 +1,274 @@
|
||||
import { enter, shots } from "./motion.mjs";
|
||||
|
||||
const cream = "#f0efda",
|
||||
green = "#b7df8a",
|
||||
gold = "#dcc28a";
|
||||
function text(ctx, value, x, y, size, color = cream) {
|
||||
ctx.font = `${size}px Pixel`;
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(value, x, y);
|
||||
}
|
||||
function label(ctx, value, x, y, size = 11, color = "#a8bda3") {
|
||||
ctx.font = `${size}px Arial`;
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = "left";
|
||||
ctx.letterSpacing = "2px";
|
||||
ctx.fillText(value, x, y);
|
||||
ctx.letterSpacing = "0px";
|
||||
}
|
||||
function cross(ctx, x, y) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - 5, y);
|
||||
ctx.lineTo(x + 5, y);
|
||||
ctx.moveTo(x, y - 5);
|
||||
ctx.lineTo(x, y + 5);
|
||||
ctx.stroke();
|
||||
}
|
||||
function word(ctx, value, x, y, size, time, start, end, color = cream) {
|
||||
// 50ms stagger: the word reads as one gesture, followed by a long quiet hold.
|
||||
let offset = 0;
|
||||
ctx.font = `${size}px Pixel`;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const a = enter(time, start + i * 0.05);
|
||||
const out = enter(time, end - 0.25);
|
||||
ctx.save();
|
||||
ctx.globalAlpha *= a * (1 - out);
|
||||
text(ctx, value[i], x + offset, y + (1 - a) * 12 + out * 12, size, color);
|
||||
ctx.restore();
|
||||
offset += ctx.measureText(value[i]).width;
|
||||
}
|
||||
}
|
||||
function mark(ctx, x, y, size, time) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.strokeStyle = "rgba(209,217,174,.5)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, size, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const a = (i / 32) * Math.PI * 2,
|
||||
r = size - (i % 8 === 0 ? 9 : 3);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(Math.sin(a) * r, Math.cos(a) * r);
|
||||
ctx.lineTo(Math.sin(a) * size, Math.cos(a) * size);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.rotate(Math.sin(time) * 0.09 - 0.35);
|
||||
ctx.fillStyle = gold;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -size * 0.6);
|
||||
ctx.lineTo(size * 0.13, 0);
|
||||
ctx.lineTo(0, size * 0.1);
|
||||
ctx.lineTo(-size * 0.13, 0);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "#57725b";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, size * 0.6);
|
||||
ctx.lineTo(size * 0.13, 0);
|
||||
ctx.lineTo(0, -size * 0.1);
|
||||
ctx.lineTo(-size * 0.13, 0);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
function screenshot(ctx, image, x, y, w, h) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = "#07130e";
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x - 1, y - 1, w + 2, h + 2, 8);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "rgba(181,205,154,.5)";
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, w, h, 7);
|
||||
ctx.clip();
|
||||
ctx.drawImage(
|
||||
image,
|
||||
0,
|
||||
0,
|
||||
image.naturalWidth,
|
||||
image.naturalHeight,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
function screenshotShot(ctx, shot, image, t) {
|
||||
const a = enter(t, shot.start - 0.125),
|
||||
exit = enter(t, shot.end - 0.125);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = a * (1 - exit);
|
||||
label(ctx, shot.chapter, 64, 30, 10, gold);
|
||||
text(ctx, shot.title, 62, 76, 34, cream);
|
||||
ctx.font = "15px Arial";
|
||||
ctx.fillStyle = "#aebfa6";
|
||||
ctx.fillText(shot.subtitle, 65, 100);
|
||||
// Readable data stays still after a 250ms settle; no continuous pan or zoom.
|
||||
const settle = enter(t, shot.start + 0.05),
|
||||
leave = enter(t, shot.end - 0.125);
|
||||
const w = 1040,
|
||||
h = 585,
|
||||
x = 120,
|
||||
y = 121;
|
||||
ctx.save();
|
||||
ctx.globalAlpha *= settle;
|
||||
ctx.translate(640, y + h / 2 + 8 * (1 - settle) + 8 * leave);
|
||||
const scale = 0.96 + 0.04 * settle;
|
||||
ctx.scale(scale, scale);
|
||||
ctx.translate(-640, -y - h / 2);
|
||||
screenshot(ctx, image, x, y, w, h);
|
||||
if (shot.image === "game") {
|
||||
const focus =
|
||||
enter(t, shot.start + 2, 0.25) * (1 - enter(t, shot.end - 0.4, 0.25));
|
||||
ctx.globalAlpha *= focus;
|
||||
ctx.strokeStyle = gold;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(x + w * 0.771, y + h * 0.496, w * 0.222, h * 0.482);
|
||||
ctx.fillStyle = "#0b1b13";
|
||||
ctx.fillRect(x + 24, y + h - 66, 310, 38);
|
||||
label(ctx, "LOOK AROUND. PLACE YOUR PIN.", x + 37, y + h - 42, 10, gold);
|
||||
}
|
||||
ctx.restore();
|
||||
ctx.restore();
|
||||
}
|
||||
function brandShot(ctx, shot, t, closing) {
|
||||
const a = enter(t, shot.start),
|
||||
exit = enter(t, shot.end - 0.25);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = a * (1 - exit);
|
||||
label(ctx, shot.chapter, 66, 215, 11, gold);
|
||||
if (closing) {
|
||||
word(ctx, "MINEGEO", 60, 322, 80, t, shot.start + 0.05, shot.end, green);
|
||||
ctx.save();
|
||||
ctx.globalAlpha *= enter(t, shot.start + 0.45);
|
||||
ctx.font = "20px Arial";
|
||||
ctx.fillStyle = "#becdb3";
|
||||
ctx.fillText(shot.subtitle, 66, 369);
|
||||
ctx.restore();
|
||||
const cta = enter(t, shot.start + 0.7);
|
||||
ctx.save();
|
||||
ctx.globalAlpha *= cta;
|
||||
ctx.translate(0, 8 * (1 - cta));
|
||||
ctx.fillStyle = green;
|
||||
ctx.fillRect(65, 409, 330, 57);
|
||||
text(ctx, "MINEGEO.SITE", 88, 448, 25, "#112215");
|
||||
label(ctx, "FREE TO EXPLORE / FIVE ROUNDS", 66, 500, 10);
|
||||
ctx.restore();
|
||||
} else {
|
||||
word(ctx, "GET LOST.", 60, 313, 68, t, shot.start + 0.05, shot.end);
|
||||
word(
|
||||
ctx,
|
||||
"FIND YOUR WORLD.",
|
||||
60,
|
||||
392,
|
||||
48,
|
||||
t,
|
||||
shot.start + 0.16,
|
||||
shot.end,
|
||||
green,
|
||||
);
|
||||
ctx.save();
|
||||
ctx.globalAlpha *= enter(t, shot.start + 0.7);
|
||||
ctx.font = "17px Arial";
|
||||
ctx.fillStyle = "#becdb3";
|
||||
ctx.fillText(shot.subtitle, 66, 445);
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
function drawBanner(ctx, t, pin) {
|
||||
label(ctx, "YOUR NEXT EXPEDITION", 66, 108, 12, gold);
|
||||
// Always legible in Discord: only the scene and targeting sweep loop.
|
||||
text(ctx, "MINEGEO", 60, 233, 82, green);
|
||||
ctx.font = "18px Arial";
|
||||
ctx.fillStyle = "#ccd7bf";
|
||||
ctx.fillText("Get lost. Find your world.", 66, 280);
|
||||
ctx.strokeStyle = "rgba(184,207,160,.35)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(66, 320);
|
||||
ctx.lineTo(430, 320);
|
||||
ctx.stroke();
|
||||
label(ctx, "MINEGEO.SITE", 66, 350, 13, cream);
|
||||
label(ctx, "EXPLORE / GUESS / DISCOVER", 66, 388, 10);
|
||||
mark(ctx, 1126, 68, 25, (t / 3) * Math.PI * 2);
|
||||
if (pin) {
|
||||
ctx.save();
|
||||
ctx.translate(pin.x, pin.y);
|
||||
const phase = (t / 3) * Math.PI * 2,
|
||||
pulse = 0.5 - 0.5 * Math.cos(phase);
|
||||
ctx.strokeStyle = `rgba(234,210,144,${0.25 + 0.25 * (1 - pulse)})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 20 + pulse * 13, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.rotate(phase);
|
||||
ctx.strokeStyle = "rgba(234,210,144,.65)";
|
||||
for (let i = 0; i < 4; i++) {
|
||||
ctx.rotate(Math.PI / 2);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(28, -7);
|
||||
ctx.lineTo(28, 7);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
export function composeFrame(
|
||||
ctx,
|
||||
source,
|
||||
{ width, height, banner, time: t, screenshots, pin },
|
||||
) {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.drawImage(source, 0, 0);
|
||||
const scale = width / (banner ? 1200 : 1280),
|
||||
w = width / scale,
|
||||
h = height / scale;
|
||||
ctx.save();
|
||||
ctx.scale(scale, scale);
|
||||
const gradient = ctx.createLinearGradient(0, 0, w, 0);
|
||||
gradient.addColorStop(0, "rgba(5,15,10,.97)");
|
||||
gradient.addColorStop(0.38, "rgba(5,15,10,.86)");
|
||||
gradient.addColorStop(0.8, "rgba(5,15,10,0)");
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
const wash = ctx.createLinearGradient(0, 0, 0, h);
|
||||
wash.addColorStop(0, "rgba(5,15,10,.2)");
|
||||
wash.addColorStop(0.65, "rgba(5,15,10,0)");
|
||||
wash.addColorStop(1, "rgba(5,15,10,.55)");
|
||||
ctx.fillStyle = wash;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.strokeStyle = "rgba(171,196,144,.25)";
|
||||
ctx.lineWidth = 1;
|
||||
cross(ctx, 28, 28);
|
||||
cross(ctx, w - 28, 28);
|
||||
cross(ctx, 28, h - 28);
|
||||
cross(ctx, w - 28, h - 28);
|
||||
if (banner)
|
||||
drawBanner(ctx, t, pin ? { x: pin.x / scale, y: pin.y / scale } : null);
|
||||
else {
|
||||
for (const shot of shots) {
|
||||
if (t < shot.start - 0.125 || t > shot.end + 0.125) continue;
|
||||
if (shot.image) screenshotShot(ctx, shot, screenshots[shot.image], t);
|
||||
else brandShot(ctx, shot, t, shot.start === 23);
|
||||
}
|
||||
if (t < 3 || t >= 23) {
|
||||
mark(ctx, 1188, 81, 29, t * 0.12);
|
||||
label(ctx, "A MINECRAFT GUESSING GAME", 66, 65, 10);
|
||||
}
|
||||
const current = shots.findIndex((s) => t >= s.start && t < s.end);
|
||||
for (let i = 0; i < shots.length; i++) {
|
||||
ctx.fillStyle = i <= current ? "#bfd49b" : "rgba(165,186,143,.3)";
|
||||
ctx.fillRect(28 + i * 16, h - 20, 10, 2);
|
||||
}
|
||||
ctx.fillStyle = `rgba(5,15,10,${1 - enter(t, 0)})`;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.fillStyle = `rgba(5,15,10,${enter(t, 29.75)})`;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
72
cinematics/motion.mjs
Normal file
72
cinematics/motion.mjs
Normal file
@@ -0,0 +1,72 @@
|
||||
// Animate skill's canonical curves, shared by the deterministic media timeline.
|
||||
export const clamp01 = (value) => Math.max(0, Math.min(1, value));
|
||||
export function cubicBezier(x1, y1, x2, y2) {
|
||||
const sample = (t, a, b) =>
|
||||
3 * (1 - t) ** 2 * t * a + 3 * (1 - t) * t * t * b + t ** 3;
|
||||
return (value) => {
|
||||
const x = clamp01(value);
|
||||
if (x === 0 || x === 1) return x;
|
||||
let lo = 0,
|
||||
hi = 1;
|
||||
for (let step = 0; step < 24; step++) {
|
||||
const t = (lo + hi) / 2;
|
||||
if (sample(t, x1, x2) < x) lo = t;
|
||||
else hi = t;
|
||||
}
|
||||
return sample((lo + hi) / 2, y1, y2);
|
||||
};
|
||||
}
|
||||
export const easeOut = cubicBezier(0.23, 1, 0.32, 1);
|
||||
export const easeInOut = cubicBezier(0.77, 0, 0.175, 1);
|
||||
export const enter = (time, start, duration = 0.25) =>
|
||||
easeOut((time - start) / duration);
|
||||
export const visibility = (time, start, end, duration = 0.25) =>
|
||||
enter(time, start, duration) * (1 - enter(time, end - duration, duration));
|
||||
export const shots = [
|
||||
{
|
||||
start: 0,
|
||||
end: 3,
|
||||
title: "GET LOST.",
|
||||
subtitle: "Find a familiar world from a different perspective.",
|
||||
chapter: "THE OVERWORLD IS CALLING",
|
||||
},
|
||||
{
|
||||
start: 3,
|
||||
end: 8,
|
||||
title: "YOUR NEXT EXPEDITION.",
|
||||
subtitle: "A Minecraft guessing game. A new way to explore.",
|
||||
chapter: "01 / DISCOVER MINEGEO",
|
||||
image: "home",
|
||||
},
|
||||
{
|
||||
start: 8,
|
||||
end: 13,
|
||||
title: "PICK YOUR WORLD.",
|
||||
subtitle: "Explore maps made by the community.",
|
||||
chapter: "02 / CHOOSE A MAP",
|
||||
image: "catalog",
|
||||
},
|
||||
{
|
||||
start: 13,
|
||||
end: 18,
|
||||
title: "LOOK. GUESS. DISCOVER.",
|
||||
subtitle: "Read the landscape. Place your pin.",
|
||||
chapter: "03 / REAL GAMEPLAY",
|
||||
image: "game",
|
||||
},
|
||||
{
|
||||
start: 18,
|
||||
end: 23,
|
||||
title: "PLAY YOUR WAY.",
|
||||
subtitle: "Your map. Your difficulty. Solo or together.",
|
||||
chapter: "04 / MAKE IT YOURS",
|
||||
image: "map",
|
||||
},
|
||||
{
|
||||
start: 23,
|
||||
end: 30,
|
||||
title: "MINEGEO",
|
||||
subtitle: "Get lost. Find your world.",
|
||||
chapter: "YOUR NEXT ADVENTURE STARTS HERE",
|
||||
},
|
||||
];
|
||||
@@ -1,4 +1,6 @@
|
||||
import * as THREE from "three";
|
||||
import { composeFrame } from "./composition.mjs";
|
||||
import { easeInOut, enter } from "./motion.mjs";
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const banner = params.get("mode") === "banner";
|
||||
@@ -259,364 +261,61 @@ const screenshots = banner
|
||||
}),
|
||||
),
|
||||
);
|
||||
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) {
|
||||
|
||||
// Terrain and lighting are static: cache their shadow map, not a moving world.
|
||||
renderer.shadowMap.autoUpdate = false;
|
||||
renderer.shadowMap.needsUpdate = true;
|
||||
const projectedPin = new THREE.Vector3();
|
||||
function renderAt(time) {
|
||||
const t = banner ? ((time % 3) + 3) % 3 : Math.max(0, Math.min(30, time));
|
||||
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;
|
||||
}
|
||||
const travel = banner
|
||||
? (1 - Math.cos(loop)) / 2
|
||||
: easeInOut(Math.min(t, 3) / 3);
|
||||
const azimuth = banner ? 0.66 + 0.045 * Math.sin(loop) : 0.56 + 0.13 * travel;
|
||||
const distance = banner ? 79 + Math.sin(loop) * 1.6 : 100 - 13 * travel;
|
||||
camera.position.set(
|
||||
Math.sin(azimuth) * distance,
|
||||
elevation,
|
||||
banner ? 38 : 43,
|
||||
Math.cos(azimuth) * distance,
|
||||
);
|
||||
camera.lookAt(0, lookY, 0);
|
||||
// Move the landscape to the right to leave genuinely quiet space for titles.
|
||||
camera.lookAt(0, 0, 0);
|
||||
camera.setViewOffset(
|
||||
width,
|
||||
height,
|
||||
-width * (banner ? 0.21 : 0.16),
|
||||
-width * (banner ? 0.23 : 0.17),
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
world.position.y = Math.sin(banner ? loop : t * 0.5) * 0.2;
|
||||
world.position.y = 0;
|
||||
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;
|
||||
p.base +
|
||||
(banner
|
||||
? Math.sin(loop) * 0.1
|
||||
: (1 - enter(t, 23 + i * 0.08, 0.25)) * 1.5);
|
||||
p.group.rotation.y = banner ? Math.sin(loop) * 0.12 : 0.15;
|
||||
p.halo.scale.setScalar(1 + (banner ? (1 - Math.cos(loop)) * 0.035 : 0));
|
||||
p.group.visible = i === 0 && (banner || t < 3 || t >= 23);
|
||||
});
|
||||
motes.rotation.y = banner ? Math.sin(loop) * 0.005 : t * 0.005;
|
||||
ringGroup.rotation.y = banner ? loop : t * 0.25;
|
||||
motes.rotation.y = banner ? Math.sin(loop) * 0.006 : 0;
|
||||
ringGroup.rotation.y = banner ? loop : t * 0.12;
|
||||
renderer.render(scene, camera);
|
||||
overlay(t);
|
||||
projectedPin.copy(pins[0].group.position);
|
||||
projectedPin.y += 3;
|
||||
projectedPin.project(camera);
|
||||
composeFrame(ctx, renderer.domElement, {
|
||||
width,
|
||||
height,
|
||||
banner,
|
||||
time: t,
|
||||
screenshots,
|
||||
pin: {
|
||||
x: (projectedPin.x * 0.5 + 0.5) * width,
|
||||
y: (-projectedPin.y * 0.5 + 0.5) * height,
|
||||
},
|
||||
});
|
||||
}
|
||||
window.renderCinematic = renderAt;
|
||||
window.cinematicCanvas = canvas;
|
||||
|
||||
Reference in New Issue
Block a user