diff --git a/game.js b/game.js index 2e78d5a..d2f0474 100644 --- a/game.js +++ b/game.js @@ -1,6 +1,15 @@ import { makeBufferFromFaces } from "./geometry"; import * as se3 from './se3'; -import { checkCollision, destroySelectedBlock, generateMissingChunks, markBlock, updateWorldGeometry } from './world'; +import { + BlockType, + castRay, + checkCollision, + destroyBlock, + generateMissingChunks, + makeBlock, + markBlock, + updateWorldGeometry, +} from './world'; /** Draw. * @@ -116,7 +125,7 @@ function handleKeys(params) { } function getObjects(world, z, x, glContext) { - return world.chunks + const drawedChunks = world.chunks .filter(chunk => { if (chunk.position.z < z - 8 * 16) return false; if (chunk.position.z > z + 7 * 16) return false; @@ -128,13 +137,16 @@ function getObjects(world, z, x, glContext) { } return true; - }) - .map(chunk => ({ - position: [0.0, 0.0, 0.0], - orientation: [0.0, 0.0, 0.0], - geometry: chunk.buffer, - glContext, - })); + }); + const buffers = drawedChunks + .map(chunk => chunk.buffer) + .concat(drawedChunks.map(chunk => chunk.transparentBuffer)); + return buffers.map(buffer => ({ + position: [0.0, 0.0, 0.0], + orientation: [0.0, 0.0, 0.0], + geometry: buffer, + glContext, + })); } function updatePhysics(params) { @@ -180,32 +192,25 @@ function tagABlock(gl, params, objects) { objects.push(obj); } -// Mine & place -// ------------ -// [x] ray casting -// [x] block outline -// [ ] crosshair -// [ ] dynamic terrain re-rendering -// [ ] should use a linked list of air contact blocks -// --> might not be needed. Only need to re-render a single chunk, -// should be fast enough. We render 16 of them every time we -// walk 16 blocks in any direction. - // Stuff I need to do: // [x] a skybox // [x] a movable camera // [x] some kind of gravity // [x] collision detection // [x] more blocks -// [ ] ability to mine & place +// [x] ability to mine & place // [x] generating & loading of more chunks // [x] distance fog +// [x] better controls // [ ] different biomes (with different noise stats) // [ ] non-flowy water // [ ] flowy water -// [ ] ALIGN CHUNK WITH WORLD COORDS -// [x] better controls // [ ] save the world (yay) to local storage (bah) +// [ ] mines +// [ ] crafting +// [ ] fix bugs +// [ ] better light +// [ ] inventory export function setupParamPanel(params) { document.querySelector('#lightx').oninput = e => { @@ -234,6 +239,33 @@ export function setupParamPanel(params) { } } +function viewDirection(params) { + const dir = [0, 0, -1, 1.0]; + const camori = params.camera.orientation; + const ori = se3.inverse(se3.rotxyz(-camori[0], -camori[1], -camori[2])); + return se3.apply(ori, dir).slice(0, 3); +} + +function destroySelectedBlock(params) { + const hit = castRay(params.world, params.camera.position, viewDirection(params), params.blockSelectDistance); + if (hit === undefined || hit.block.type === BlockType.UNDEFINED) { + return; + } + if (hit.block.type === BlockType.WATER) { + return; + } + destroyBlock(params.world, hit.block); +} + +function makeDirBlock(params) { + const hit = castRay(params.world, params.camera.position, viewDirection(params), params.blockSelectDistance); + if (hit === undefined || hit.block.type === BlockType.UNDEFINED) { + return; + } + const newBlockPosition = hit.block.centerPosition.map((v, i) => v + hit.normal[i]); + makeBlock(params.world, newBlockPosition, BlockType.DIRT); +} + export function initUiListeners(params, canvas) { const canvasClickHandler = () => { canvas.requestPointerLock(); @@ -244,6 +276,7 @@ export function initUiListeners(params, canvas) { destroySelectedBlock(params); break; case 2: // right click + makeDirBlock(params); break; } }; @@ -298,7 +331,7 @@ export function tick(time, gl, params) { // frame time is typically 16.7ms, so this may lag a bit let timeLeft = 30; const start = performance.now(); - generateMissingChunks(params.world, campos[2], campos[0], timeLeft); + generateMissingChunks(params.world, campos[2], campos[0], 5); timeLeft -= performance.now() - start; updateWorldGeometry(gl, params.world, campos[2], campos[0], timeLeft); diff --git a/terrain.js b/terrain.js new file mode 100644 index 0000000..5090b54 --- /dev/null +++ b/terrain.js @@ -0,0 +1,150 @@ +const smoothstep = x => x*x*(3 - 2*x); +function interpolate(a, b, x, f = smoothstep) { + const val = a + f(x) * (b - a); + return val; +} +const sigmoid = (a, b, f = smoothstep) => x => { + if (x < a) return 0; + if (x > b) return 1; + return f((x - a) / (b - a)); +}; + +function ghettoPerlinNoise(seed, x, y, gridSize = 16) { + const dot = (vx, vy) => vx[0] * vy[0] + vx[1] * vy[1]; + // super ghetto random + const xorshift = (x) => { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + return x; + }; + + const randGrad = (x0, y0) => { + const rand = xorshift(1337 * x0 + seed + 80085 * y0); + return [Math.sin(rand), Math.cos(rand)]; + }; + + const x0 = Math.floor(x / gridSize); + const y0 = Math.floor(y / gridSize); + const sx = x / gridSize - x0; + const sy = y / gridSize - y0; + + const n0 = dot(randGrad(x0, y0), [sx, sy]); + const n1 = dot(randGrad(x0 + 1, y0), [sx - 1, sy]); + const n2 = dot(randGrad(x0, y0 + 1), [sx, sy - 1]); + const n3 = dot(randGrad(x0 + 1, y0 + 1), [sx - 1, sy - 1]); + + return 1.5 * interpolate(interpolate(n0, n1, sx), interpolate(n2, n3, sx), sy); +} + +function cliffPerlin(seed, x, y) { + const noise1 = ghettoPerlinNoise(seed, x, y); + const noise2 = ghettoPerlinNoise(seed+1, x, y); + + const softerEdge = x => { + if (x < 0.1) return 0.5 - 5 * x; + if (x > 0.8) return 1.0 - 2.5 * (x - 0.8); + return (x - 0.1) / 0.8; + }; + + return interpolate(-1, 1, 0.5 * (1 + Math.atan2(noise1, noise2) / Math.PI), softerEdge); +} + +export function makeTerrain(x, y) { + const seed = 1337; + const lacunarity = 2.1; + const persistence = 0.35; + const noiseMap = (x, y) => cliffPerlin(seed, x / 2, y / 2); + + const fractalNoise = (x, y) => { + let value = 0; + let power = 0.6; + let scale = 0.1; + const noises = [ghettoPerlinNoise, ghettoPerlinNoise, cliffPerlin, cliffPerlin]; + + for (const noiseFun of noises) { + const noise = noiseFun(seed, scale * x, scale * y); + + value += noise * power; + + power *= persistence; + scale *= lacunarity; + } + + return interpolate(-0.2, 0.6, value);//, x=>x*x); // between 0 and 1 + } + + const scaledNoise = (x, y) => noiseMap(x / 1, y / 1); + const outputNoise = fractalNoise; +// const outputNoise = scaledNoise; + + const terrain = new Uint8Array(16 * 16); + for (let i = 0; i < 16; i++) { + for (let j = 0; j < 16; j++) { + terrain[i * 16 + j] = Math.floor(64 + 64 * outputNoise(x + i, y + j)); + } + } + + return terrain; +} + +function colorInterp(x) { + const ocean = [0.0, 0.0, 0.5]; + const grass = [0.2, 0.7, 0.2]; + const mountain = [0.4, 0.3, 0.1]; + const snow = [0.9, 0.9, 0.9]; + + const grassHeight = 75 / 255; + const mountainHeight = 100 / 255; + + const interp = dim => { + return x => { + if (x < grassHeight) { + return interpolate(ocean[dim], grass[dim], x / grassHeight, sigmoid(0.6, 0.95)); + } else if (x < mountainHeight) { + return interpolate(grass[dim], mountain[dim], (x - grassHeight) / (mountainHeight - grassHeight)); + } else { + return interpolate(mountain[dim], snow[dim], (x - mountainHeight) / (1.0 - mountainHeight), sigmoid(0, 0.2)); + } + }; + }; + + return { + r: 255 * interp(0)(x/255), + g: 255 * interp(1)(x/255), + b: 255 * interp(2)(x/255), + }; +} + +function main() { + const canvas = document.querySelector('#canvas'); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = 'gray'; + ctx.fill(); + + for (let i = 0; i < canvas.width; i += 16) { + for (let j = 0; j < canvas.height; j += 16) { + + const imgdat = ctx.createImageData(16, 16); + const dat = imgdat.data; + + const chunk = makeTerrain(i - canvas.width / 2, j - canvas.height / 2); + for (let k = 0; k < 16; k++) { + for (let l = 0; l < 16; l++) { + const offset = 4 * (16 * (15 - l) + k); + const val = chunk[16 * k + l]; + + dat[offset + 0] = colorInterp(val).r; + dat[offset + 1] = colorInterp(val).g; + dat[offset + 2] = colorInterp(val).b; + dat[offset + 3] = 255; + } + + } + ctx.putImageData(imgdat, i, canvas.height - 16 - j); + + } + } +} + +//window.onload = main; diff --git a/texture.png b/texture.png index 72efbd4..4795d44 100644 Binary files a/texture.png and b/texture.png differ diff --git a/world.js b/world.js index d2a6d7c..5a8a69d 100644 --- a/world.js +++ b/world.js @@ -1,6 +1,7 @@ import { makeBufferFromFaces, makeFace} from "./geometry"; import { loadTexture, makeProgram } from "./gl"; import * as se3 from './se3'; +import { makeTerrain } from "./terrain"; const VSHADER = ` attribute vec3 aPosition; @@ -54,59 +55,9 @@ export const BlockType = { DIRT: 2, GRASS: 3, STONE: 4, + WATER: 5, }; -function ghettoPerlinNoise(seed, x, y, gridSize = 16) { - const dot = (vx, vy) => vx[0] * vy[0] + vx[1] * vy[1]; - // super ghetto random - const xorshift = (x) => { - x ^= x << 13; - x ^= x >> 7; - x ^= x << 17; - return x; - }; - - const randGrad = (x0, y0) => { - const rand = xorshift(1337 * x0 + seed + 80085 * y0); - return [Math.sin(rand), Math.cos(rand)]; - }; - - const interpol = (a, b, x) => a + (x*x*(3 - 2*x)) * (b - a); - - const x0 = Math.floor(x / gridSize); - const y0 = Math.floor(y / gridSize); - const sx = x / gridSize - x0; - const sy = y / gridSize - y0; - - const n0 = dot(randGrad(x0, y0), [sx, sy]); - const n1 = dot(randGrad(x0 + 1, y0), [sx - 1, sy]); - const n2 = dot(randGrad(x0, y0 + 1), [sx, sy - 1]); - const n3 = dot(randGrad(x0 + 1, y0 + 1), [sx - 1, sy - 1]); - - return interpol(interpol(n0, n1, sx), interpol(n2, n3, sx), sy); -} - -function makeTerrain(x, y) { - const seed = 1337; - - const fractalNoise = (x, y) => ( - ghettoPerlinNoise(seed, x, y, 8) * 0.06 - + ghettoPerlinNoise(seed, x, y, 16) * 0.125 - + ghettoPerlinNoise(seed, x, y, 32) * 0.25 - + ghettoPerlinNoise(seed, x, y, 64) * 0.5 - ); - - const terrain = Array(16 * 16); - - for (let i = 0; i < 16; i++) { - for (let j = 0; j < 16; j++) { - terrain[i * 16 + j] = fractalNoise(x + i, y + j); - } - } - - return terrain; -} - function makeChunk(z, x) { const terrain = makeTerrain(z, x); @@ -114,18 +65,23 @@ function makeChunk(z, x) { for (let i = 0; i < 16; i++) { for (let j = 0; j < 16; j++) { - const height = Math.floor(64 + 64 * terrain[i * 16 + j]); + const height = terrain[i * 16 + j]; // everything above is air // that block is grass // everything below is dirt const offset = i * (16 * 256) + j * 256; - const stoneHeight = Math.min(52, height); + const stoneHeight = Math.max(48, height); + const grassHeight = stoneHeight + 8; + const waterHeight = 67; data.set(Array(stoneHeight).fill(BlockType.STONE), offset); - if (stoneHeight < height) { - data.set(Array(height - 1 - stoneHeight).fill(BlockType.DIRT), offset + stoneHeight); - data[offset + height - 1] = BlockType.GRASS; + data.set(Array(grassHeight - 1 - stoneHeight).fill(BlockType.DIRT), offset + stoneHeight); + if (grassHeight < waterHeight) { + data.set(Array(waterHeight - grassHeight + 1).fill(BlockType.WATER), offset + grassHeight - 1); + } else { + data[offset + grassHeight - 1] = BlockType.GRASS; } - data.set(Array(256 - height).fill(BlockType.AIR), offset + height); + const surfaceHeight = Math.max(waterHeight, grassHeight); + data.set(Array(256 - surfaceHeight).fill(BlockType.AIR), offset + surfaceHeight); } } @@ -183,6 +139,7 @@ function faceTexture(type, dir) { } case BlockType.DIRT: return [2, 15]; case BlockType.STONE: return [3, 15]; + case BlockType.WATER: return [4, 15]; default: return [0, 0]; } } @@ -218,7 +175,8 @@ function makeFaceList(data, chunkz, chunkx, blockLookup) { { block: lookup(i, j, k + 1), dir: '+y', faceCenter: [chunkx + j, k + 0.5, chunkz + i] }, ]; - const faces = []; + const solidFaces = []; + const waterFaces = []; for (let i = 0; i < 16; i++) { for (let j = 0; j < 16; j++) { @@ -227,17 +185,31 @@ function makeFaceList(data, chunkz, chunkx, blockLookup) { if (data[bi] === BlockType.AIR) { continue; } - for (const {block, dir, faceCenter} of neighbors(i, j, k).filter(({block}) => block === BlockType.AIR)) { - faces.push({ - blockIndex: bi, - face: makeFace(dir, faceTexture(data[bi], dir), faceCenter), - }); + for (const {dir, faceCenter} of neighbors(i, j, k) + .filter(({ block }) => block === BlockType.AIR || (block === BlockType.WATER && data[bi] !== BlockType.WATER))) { + if (data[bi] !== BlockType.WATER) { + solidFaces.push({ + blockIndex: bi, + dir, + face: makeFace(dir, faceTexture(data[bi], dir), faceCenter), + }); + } else { + faceCenter[1] -= 0.15; + waterFaces.push({ + blockIndex: bi, + dir, + face: makeFace(dir, faceTexture(data[bi], dir), faceCenter), + }) + } } } } } - return faces; + return { + solidFaces, + waterFaces, + }; } // - data <-- need to generate the first time, update when m&p @@ -315,6 +287,7 @@ function invalidateChunkGeometry(world, i, j) { export function createChunkFace(block, dir) { return { + dir, blockIndex: block.blockIndex, face: makeFace(dir, faceTexture(block.type, dir), faceCenter(block.centerPosition, dir)), }; @@ -342,10 +315,13 @@ export function updateWorldGeometry(gl, world, z, x, timeLimit = 10000) { const chunkx = 16 * j; const lookup = (i, j, k) => blockLookup(world, j + chunkx, k, i + chunkz).type; - chunk.faces = makeFaceList(chunk.data, chunk.position.z, chunk.position.x, lookup); + const faces = makeFaceList(chunk.data, chunk.position.z, chunk.position.x, lookup); + chunk.faces = faces.solidFaces; + chunk.transparentFaces = faces.waterFaces; } chunk.buffer = makeBufferFromFaces(gl, chunk.faces.map(f => f.face)); + chunk.transparentBuffer = makeBufferFromFaces(gl, chunk.transparentFaces.map(f => f.face)); // throttle this for fluidity if (performance.now() - start > timeLimit) { @@ -435,7 +411,7 @@ function rayThroughGrid(origin, direction, maxDistance) { return {position, normal, distance: rayLength}; } -function castRay(world, origin, direction, maxDistance) { +export function castRay(world, origin, direction, maxDistance) { let currentPoint = origin; while (maxDistance > 0) { @@ -488,49 +464,33 @@ export function markBlock(world, cameraPosition, direction, maxDistance) { } -function viewDirection(params) { - const dir = [0, 0, -1, 1.0]; - const camori = params.camera.orientation; - const ori = se3.inverse(se3.rotxyz(-camori[0], -camori[1], -camori[2])); - return se3.apply(ori, dir).slice(0, 3); -} - -export function destroySelectedBlock(params) { - const hit = castRay(params.world, params.camera.position, viewDirection(params), params.blockSelectDistance); - if (hit === undefined || hit.block.type === BlockType.UNDEFINED) { - return; - } - +export function destroyBlock(world, block) { const trimFaces = chunk => { chunk.faces = chunk.faces.filter(({blockIndex}) => chunk.data[blockIndex] !== BlockType.AIR); } - hit.block.chunk.data[hit.block.blockIndex] = BlockType.AIR; - if (hit.block.chunk.buffer !== undefined) { - hit.block.chunk.buffer.delete(); - delete hit.block.chunk.buffer; + block.chunk.data[block.blockIndex] = BlockType.AIR; + if (block.chunk.buffer !== undefined) { + block.chunk.buffer.delete(); + delete block.chunk.buffer; } - trimFaces(hit.block.chunk); + trimFaces(block.chunk); - const [bx, by, bz] = hit.block.centerPosition; + const [bx, by, bz] = block.centerPosition; const neighbors = [ - { block: blockLookup(params.world, bx - 1, by, bz), dir: '+x' }, - { block: blockLookup(params.world, bx + 1, by, bz), dir: '-x' }, - { block: blockLookup(params.world, bx, by - 1, bz), dir: '+y' }, - { block: blockLookup(params.world, bx, by + 1, bz), dir: '-y' }, - { block: blockLookup(params.world, bx, by, bz - 1), dir: '+z' }, - { block: blockLookup(params.world, bx, by, bz + 1), dir: '-z' }, + { block: blockLookup(world, bx - 1, by, bz), dir: '+x' }, + { block: blockLookup(world, bx + 1, by, bz), dir: '-x' }, + { block: blockLookup(world, bx, by - 1, bz), dir: '+y' }, + { block: blockLookup(world, bx, by + 1, bz), dir: '-y' }, + { block: blockLookup(world, bx, by, bz - 1), dir: '+z' }, + { block: blockLookup(world, bx, by, bz + 1), dir: '-z' }, ]; neighbors .filter(({ block }) => block.type !== BlockType.AIR && block.type !== BlockType.UNDEFINED) .forEach(({ block, dir }) => { - const blocki = Math.floor(block.blockIndex / (16 * 256)); - const blockj = Math.floor(block.blockIndex / 256) - 16 * blocki; - const blockk = block.blockIndex % 256; - block.chunk.faces.push(createChunkFace(block, dir)); trimFaces(block.chunk); @@ -541,6 +501,48 @@ export function destroySelectedBlock(params) { }); } +export function makeBlock(world, position, type) { + const block = blockLookup(world, ...position); + console.assert(block.type === BlockType.AIR); + + block.chunk.data[block.blockIndex] = type; + block.type = type; + + const [bx, by, bz] = block.centerPosition; + + const neighbors = [ + { block: blockLookup(world, bx - 1, by, bz), dir: '-x', ndir: '+x' }, + { block: blockLookup(world, bx + 1, by, bz), dir: '+x', ndir: '-x' }, + { block: blockLookup(world, bx, by - 1, bz), dir: '-y', ndir: '+y' }, + { block: blockLookup(world, bx, by + 1, bz), dir: '+y', ndir: '-y' }, + { block: blockLookup(world, bx, by, bz - 1), dir: '-z', ndir: '+z' }, + { block: blockLookup(world, bx, by, bz + 1), dir: '+z', ndir: '-z' }, + ]; + + const refresh = chunk => { + if (chunk.buffer !== undefined) { + chunk.buffer.delete(); + delete chunk.buffer; + } + } + + neighbors + .filter(({ block }) => block.type !== BlockType.UNDEFINED) + .forEach(({ block: nblock, dir, ndir }) => { + if (nblock.type === BlockType.AIR) { + block.chunk.faces.push(createChunkFace(block, dir)); + } else { + nblock.chunk.faces = nblock.chunk.faces.filter(f => ( + f.blockIndex !== nblock.blockIndex || + f.dir !== ndir + )); + refresh(nblock.chunk); + } + }); + + refresh(block.chunk); +} + export async function initWorldGl(gl) { const program = makeProgram(gl, VSHADER, FSHADER); const texture = await loadTexture(gl, 'texture.png');