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