278 lines
12 KiB
JavaScript
278 lines
12 KiB
JavaScript
// ==================== ГЕНЕРАЦИЯ 3D — GrechkaCraft v3 ====================
|
||
// Объёмный мир: столбцы (x,z), рельеф из 2D-шума, пещеры, руды, растительность.
|
||
// Та же система биомов, что в 2D-игре, но по двум координатам.
|
||
import { TILE, SEA_GY, BEDROCK_GY, GEN_MARGIN_X } from '../core/constants.js';
|
||
import { state } from '../core/state.js';
|
||
import { getBlock, setBlock, k3 } from './world-storage3d.js';
|
||
|
||
// Радиус мира по Z — насколько далеко вбок генерируем вокруг игрока
|
||
// Полу-ширина мира. 20 тайлов = мир 40x40 при глубине 140 — отсюда «мелкий
|
||
// мир»: игрок упирался в край за 20 шагов. 48 даёт комфортные 96x96 тайлов.
|
||
// Мир генерируется целиком один раз (pumpWorldGen) порциями по 200 колонок.
|
||
export const WORLD_DEPTH = 48;
|
||
const CHUNK = 8; // шаг генерации
|
||
|
||
// ========== ШУМ (детерминированный, от worldSeed) ==========
|
||
function hash2(x, z) {
|
||
let h = x * 374761393 + z * 668265263 + state.worldSeed * 1274126177;
|
||
h = (h ^ (h >> 13)) * 1274126177;
|
||
return ((h ^ (h >> 16)) >>> 0) / 4294967295;
|
||
}
|
||
|
||
function smooth(t) { return t * t * (3 - 2 * t); }
|
||
|
||
function valueNoise2(x, z, scale) {
|
||
const fx = x / scale, fz = z / scale;
|
||
const x0 = Math.floor(fx), z0 = Math.floor(fz);
|
||
const tx = smooth(fx - x0), tz = smooth(fz - z0);
|
||
const a = hash2(x0, z0), b = hash2(x0 + 1, z0);
|
||
const c = hash2(x0, z0 + 1), d = hash2(x0 + 1, z0 + 1);
|
||
return (a * (1 - tx) + b * tx) * (1 - tz) + (c * (1 - tx) + d * tx) * tz;
|
||
}
|
||
|
||
function fbm2(x, z, octaves = 4) {
|
||
let sum = 0, amp = 0.5, freq = 1, norm = 0;
|
||
for (let i = 0; i < octaves; i++) {
|
||
sum += valueNoise2(x * freq, z * freq, 1) * amp;
|
||
norm += amp;
|
||
amp *= 0.5;
|
||
freq *= 2;
|
||
}
|
||
return sum / norm;
|
||
}
|
||
|
||
// ========== БИОМЫ (те же пять, что в 2D) ==========
|
||
export const BIOMES = {
|
||
plains: { name:'Равнина', surface:'grass', subsurface:'dirt', trees:true, treeChance:0.11 },
|
||
desert: { name:'Пустыня', surface:'sand', subsurface:'sand', trees:false, treeChance:0 },
|
||
tundra: { name:'Тундра', surface:'snow', subsurface:'dirt', trees:true, treeChance:0.05 },
|
||
swamp: { name:'Болото', surface:'moss', subsurface:'dirt', trees:true, treeChance:0.09 },
|
||
mountains: { name:'Горы', surface:'stone', subsurface:'stone', trees:false, treeChance:0 },
|
||
};
|
||
|
||
export function getBiome(gx, gz) {
|
||
const s = state.worldSeed;
|
||
const temp = fbm2(gx * 0.6, gz * 0.6 + 11, 3) * 0.5 + Math.sin(gx * 0.003 + s * 0.01) * 0.25 + 0.4;
|
||
const humid = fbm2(gx * 0.5 + 500, gz * 0.5 + 700, 3) * 0.5 + Math.cos(gx * 0.006 + s * 0.02) * 0.2 + 0.4;
|
||
const mt = fbm2(gx * 0.25 + 900, gz * 0.25 + 300, 3);
|
||
if (mt > 0.78) return 'mountains';
|
||
if (temp > 0.66) return 'desert';
|
||
if (temp < 0.34) return 'tundra';
|
||
if (humid > 0.66) return 'swamp';
|
||
return 'plains';
|
||
}
|
||
|
||
const biomeCache = new Map();
|
||
export function getCachedBiome(gx, gz) {
|
||
const cx = Math.floor(gx / CHUNK), cz = Math.floor(gz / CHUNK);
|
||
const key = cx + ',' + cz;
|
||
if (!biomeCache.has(key)) biomeCache.set(key, getBiome(cx * CHUNK, cz * CHUNK));
|
||
return biomeCache.get(key);
|
||
}
|
||
|
||
// ========== РЕЛЬЕФ ==========
|
||
// Высота поверхности в колонке (x,z). Одна и та же функция на сервере и клиенте.
|
||
export function surfaceGyAt(gx, gz) {
|
||
const biome = getCachedBiome(gx, gz);
|
||
const n1 = Math.sin(gx * 0.025 + state.worldSeed * 0.001) * 8;
|
||
const n2 = Math.sin(gz * 0.021 - state.worldSeed * 0.0015) * 7;
|
||
const n3 = Math.sin((gx + gz) * 0.012 + state.worldSeed * 0.002) * 10;
|
||
const n4 = fbm2(gx * 0.5, gz * 0.5, 3) * 9;
|
||
let h;
|
||
switch (biome) {
|
||
case 'desert': h = 8 + n1 * 0.5 + n3 * 0.3 + n4 * 0.2; break;
|
||
case 'mountains': h = 16 + n3 * 1.1 + n4 * 1.2; break;
|
||
case 'tundra': h = 9 + n1 * 0.6 + n3 * 0.5 + n4 * 0.4; break;
|
||
case 'swamp': h = 4 + n1 * 0.4 + n2 * 0.6 + n4 * 0.3; break;
|
||
default: h = 10 + n1 * 0.7 + n2 * 0.7 + n3 * 0.4 + n4 * 0.4;
|
||
}
|
||
return Math.max(2, Math.min(BEDROCK_GY - 12, Math.round(h)));
|
||
}
|
||
|
||
export function seaLevel() { return SEA_GY; }
|
||
|
||
// ========== ПЕЩЕРЫ ==========
|
||
function inCave(gx, gy, gz) {
|
||
if (gy < 3) return false;
|
||
const c1 = fbm2(gx * 0.9 + 11, gz * 0.9 + gy * 1.7, 2);
|
||
const c2 = fbm2(gx * 0.9 - 40, gz * 0.9 + gy * 1.7 + 90, 2);
|
||
// две «трубки» шума: пересечение даёт объёмные полости
|
||
return c1 > 0.62 && c2 > 0.60 && gy < surfaceGyAt(gx, gz) - 3;
|
||
}
|
||
|
||
// ========== РУДЫ ==========
|
||
function oreAt(gx, gy, gz) {
|
||
if (gy < 2) return null;
|
||
const depth = gy / BEDROCK_GY;
|
||
const r = hash2(gx * 7 + gy, gz * 13);
|
||
const r2 = hash2(gx * 3 - gy, gz * 5 + 17);
|
||
if (depth < 0.30 && r > 0.982) return 'coal';
|
||
if (depth < 0.26 && r > 0.990 && r2 > 0.6) return 'copper_ore';
|
||
if (depth < 0.20 && r > 0.988 && r2 > 0.4) return 'iron_ore';
|
||
if (depth < 0.13 && r > 0.994) return 'gold_ore';
|
||
if (depth < 0.09 && r > 0.9965) return 'diamond_ore';
|
||
return null;
|
||
}
|
||
|
||
// ========== ДЕРЕВО (по XY, заполняет и Z) ==========
|
||
function growTree(gx, gy, gz, biome) {
|
||
const def = BIOMES[biome];
|
||
if (!def || !def.trees) return;
|
||
if (hash2(gx * 31, gz * 17) > def.treeChance) return;
|
||
const h = 4 + Math.floor(hash2(gx * 5, gz * 11) * 3);
|
||
const leafType = biome === 'tundra' || biome === 'swamp' ? 'spruce_leaves' : 'leaves';
|
||
for (let i = 1; i <= h; i++) {
|
||
if (!getBlock(gx, gy - i, gz)) setBlock(gx, gy - i, gz, 'wood', false, false);
|
||
}
|
||
// крона объёмом: псевдосфера
|
||
const cy = gy - h;
|
||
const rad = 2;
|
||
for (let dy = -2; dy <= 1; dy++) {
|
||
for (let dz = -rad; dz <= rad; dz++) {
|
||
for (let dx = -rad; dx <= rad; dx++) {
|
||
const dist = dx * dx + dy * dy * 2 + dz * dz;
|
||
if (dist > rad * rad + 1) continue;
|
||
const yy = cy + dy;
|
||
if (!getBlock(gx + dx, yy, gz + dz)) setBlock(gx + dx, yy, gz + dz, leafType, false, false);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function growDecor(gx, gy, gz, biome) {
|
||
const def = BIOMES[biome];
|
||
const r = hash2(gx * 23 + 5, gz * 29 + 7);
|
||
if (def.flowers && r > 0.96 && !getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'flower', false, false);
|
||
if ((biome === 'swamp' || biome === 'plains') && r > 0.985 && !getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'mushroom', false, false);
|
||
if (biome === 'desert' && r > 0.978 && !getBlock(gx, gy, gz) && gy + 1 < surfaceGyAt(gx, gz)) setBlock(gx, gy, gz, 'cactus', false, false);
|
||
if (biome === 'tundra' && r > 0.978 && !getBlock(gx, gy, gz) && gy + 2 < surfaceGyAt(gx, gz)) setBlock(gx, gy, gz, 'ice', false, false);
|
||
}
|
||
|
||
const generated = new Set(); // "cx,cz" — уже сгенерированные чанки
|
||
|
||
// ========== ГЕНЕРАЦИЯ ЧАНКА ==========
|
||
export function genChunk(cx, cz) {
|
||
const key = cx + ',' + cz;
|
||
if (generated.has(key)) return false;
|
||
generated.add(key);
|
||
const x0 = cx * CHUNK, z0 = cz * CHUNK;
|
||
for (let x = x0; x < x0 + CHUNK; x++) {
|
||
for (let z = z0; z < z0 + CHUNK; z++) {
|
||
genColumn(x, z);
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
export function genColumn(gx, gz) {
|
||
const biome = getCachedBiome(gx, gz);
|
||
const top = surfaceGyAt(gx, gz);
|
||
const sea = seaLevel();
|
||
|
||
// Генерируем до bedrock, но не ниже уровня «пещерной зоны» — экономим память
|
||
for (let gy = Math.min(BEDROCK_GY - 1, 44); gy >= 0; gy--) {
|
||
// бедрок внизу
|
||
if (gy >= BEDROCK_GY - 2) {
|
||
if (!getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'bedrock', false, false);
|
||
continue;
|
||
}
|
||
if (gy > top) {
|
||
// над поверхностью: вода до уровня моря
|
||
if (gy <= sea) {
|
||
const t = (biome === 'swamp') ? 'swamp_water' : 'water';
|
||
if (!getBlock(gx, gy, gz)) setBlock(gx, gy, gz, t, false, false);
|
||
}
|
||
continue;
|
||
}
|
||
// пещеры — только под поверхностью
|
||
if (gy < top - 2 && inCave(gx, gy, gz)) continue;
|
||
|
||
const ore = oreAt(gx, gy, gz);
|
||
if (ore) { setBlock(gx, gy, gz, ore, false, false); continue; }
|
||
|
||
const def = BIOMES[biome];
|
||
if (gy === top) setBlock(gx, gy, gz, def.surface, false, false);
|
||
else if (gy > top - (biome === 'desert' || biome === 'mountains' ? 5 : 3)) setBlock(gx, gy, gz, def.subsurface, false, false);
|
||
else setBlock(gx, gy, gz, 'stone', false, false);
|
||
}
|
||
|
||
// вода в пещерах не заливаем — это гравитация
|
||
// растительность
|
||
const above = getBlock(gx, top, gz);
|
||
if (above && !BLOCKS_NO_SOLID(above.t)) growDecor(gx, top, gz, biome);
|
||
if (above && !BLOCKS_NO_SOLID(above.t) && hash2(gx * 13, gz * 17) > 0.93) growTree(gx, top, gz, biome);
|
||
}
|
||
|
||
function BLOCKS_NO_SOLID(t) { return t === 'water' || t === 'swamp_water' || t === 'air'; }
|
||
|
||
// ========== ОДИН ФИКСИРОВАННЫЙ МИР ==========
|
||
// Раньше мир «стримился» вокруг игрока, а дальние чанки ВЫЧИЩАЛИСЬ. Из-за
|
||
// этого горизонт пустел, а мелкая дальняя генерация давала обрезанную землю —
|
||
// отсюда «остров, висящий в пространстве». Теперь мир генерируется ЦЕЛИКОМ
|
||
// один раз и больше никогда не вычищается. Чтобы вкладка не зависла на
|
||
// сотнях тысяч блоков, генерация идёт порциями: pumpWorldGen() зовётся из
|
||
// игрового цикла, а isWorldReady() показывает готовность для загрузочного экрана.
|
||
const genChunks = new Set();
|
||
let genQueue = null; // [gx,gz,gx,gz,...] — остаток работы
|
||
let genCursor = 0;
|
||
let worldReady = false; // мир сгенерирован целиком
|
||
|
||
// Всего колонок (2*WORLD_DEPTH+1)^2. При 48 это 97*97 = 9409 колонок по ~45
|
||
// блоков ≈ 420k блоков. За один кадр это зависание вкладки, отсюда очередь.
|
||
const WORLD_BATCH = 200; // колонок за один вызов (~9k блоков)
|
||
|
||
function buildQueue() {
|
||
const side = 2 * WORLD_DEPTH + 1;
|
||
const q = new Int32Array(side * side * 2);
|
||
let i = 0;
|
||
// Сначала колонки рядом с центром — игрок появляется уже с готовой землёй.
|
||
for (let gz = -WORLD_DEPTH; gz <= WORLD_DEPTH; gz++) {
|
||
for (let gx = -WORLD_DEPTH; gx <= WORLD_DEPTH; gx++) {
|
||
q[i++] = gx; q[i++] = gz;
|
||
}
|
||
}
|
||
return q;
|
||
}
|
||
|
||
// Прогресс 0..1 — для экрана загрузки.
|
||
export function worldGenProgress() {
|
||
if (worldReady) return 1;
|
||
if (!genQueue) genQueue = buildQueue();
|
||
return genCursor / genQueue.length;
|
||
}
|
||
|
||
// Возвращает true, когда мир готов целиком.
|
||
export function pumpWorldGen() {
|
||
if (worldReady) return true;
|
||
if (!genQueue) genQueue = buildQueue();
|
||
const total = genQueue.length;
|
||
const n = Math.min(WORLD_BATCH, (total - genCursor) >> 1);
|
||
for (let i = 0; i < n; i++) {
|
||
genColumn(genQueue[genCursor], genQueue[genCursor + 1]);
|
||
genCursor += 2;
|
||
}
|
||
if (genCursor >= total) {
|
||
genQueue = null;
|
||
worldReady = true;
|
||
state.worldGenReady = true;
|
||
}
|
||
return worldReady;
|
||
}
|
||
|
||
export function isWorldReady() { return worldReady; }
|
||
|
||
// Совместимость с вызовами из loop3d: мир не стримится, а достраивается.
|
||
export function ensureGenAroundCamera() { return pumpWorldGen(); }
|
||
|
||
export function resetGenerationCache() {
|
||
generated.clear();
|
||
genChunks.clear();
|
||
biomeCache.clear();
|
||
genQueue = null;
|
||
genCursor = 0;
|
||
worldReady = false;
|
||
state.worldGenReady = false;
|
||
}
|
||
|
||
export { CHUNK, k3, getBlock, setBlock };
|