grechka-game/srcv3/world/generation3d.js

278 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ==================== ГЕНЕРАЦИЯ 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 };