Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
333 lines
10 KiB
JavaScript
333 lines
10 KiB
JavaScript
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";
|
|
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];
|
|
}),
|
|
),
|
|
);
|
|
|
|
// 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;
|
|
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,
|
|
banner ? 38 : 43,
|
|
Math.cos(azimuth) * distance,
|
|
);
|
|
camera.lookAt(0, 0, 0);
|
|
camera.setViewOffset(
|
|
width,
|
|
height,
|
|
-width * (banner ? 0.23 : 0.17),
|
|
0,
|
|
width,
|
|
height,
|
|
);
|
|
world.position.y = 0;
|
|
pins.forEach((p, i) => {
|
|
p.group.position.y =
|
|
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.006 : 0;
|
|
ringGroup.rotation.y = banner ? loop : t * 0.12;
|
|
renderer.render(scene, camera);
|
|
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;
|
|
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);
|
|
}
|