import { makeFace } from 'wmc-common/geometry'; import * as linalg from './linalg'; import { loadObjModel } from './obj'; import * as se3 from 'wmc-common/se3'; import { computeOrbit, findSoi, getCartesianState, updateBodyPhysics, isInSoi } from './orbit'; import { getBodyGeometry, castRay, blockLookup, BlockType } from './chunk'; import { draw, getOrbitDrawContext, initWorldGl } from './draw'; import * as quat from './quat'; const kEpoch = 0; function closeToPlanet(context) { const body = findSoi(context.universe, context.player.position); const relativePos = linalg.diff(context.player.position, body.position); return linalg.norm(relativePos) < 20; } function getSolarSystem(seed: number) { /// XXX: only returns 1 body for now return { name: 'Tat', mass: 1000.0, spin: [0, 0, 0.2], geometry: getBodyGeometry(0), glowColor: [0.5, 0.5, 0.46], children: [ { name: 'Quicksilver', seed: 1336, mass: 0.1, spin: [0.0, 0.0, 0.05], geometry: makeCube([0, 4]), orbit: { excentricity: 0.0, semimajorAxis: 200, inclination: 0.8, ascendingNodeLongitude: 0, periapsisArgument: 0, t0: 0, }, }, { name: 'Satourne', seed: 1338, mass: 0.1, spin: [0.0, 0.5, 0.0], geometry: makeCube([0, 5]), orbit: { excentricity: 0.0, semimajorAxis: 900, inclination: 0.0, ascendingNodeLongitude: 0, periapsisArgument: 0, t0: 0, }, children: [ { name: 'Kyoujin', seed: 13381, mass: 0.01, spin: [0.0, 0.0, 0.05], geometry: makeCube([0, 6]), orbit: { excentricity: 0.0, semimajorAxis: 20, inclination: Math.PI / 2, ascendingNodeLongitude: 0, periapsisArgument: 0, t0: 0, }, }, ], }, { name: 'Tataooine', seed: 1337, mass: 50, spin: [0.0, 0.0, 0.05], geometry: getBodyGeometry(1337), orbit: { excentricity: 0.3, semimajorAxis: 500, inclination: 0.0, ascendingNodeLongitude: 0, periapsisArgument: 0, t0: 0, }, children: [ { name: 'Mun', seed: 13371, mass: 0.01, spin: [0.0, 0.0, 0.05], geometry: makeCube([0, 7]), orbit: { excentricity: 0.0, semimajorAxis: 50, inclination: Math.PI / 2, ascendingNodeLongitude: 0, periapsisArgument: 0, t0: 0, }, }, ], }, ], }; } function initUiListeners(canvas: HTMLCanvasElement, context) { const canvasClickHandler = () => { canvas.requestPointerLock(); canvas.onclick = null; // const clickListener = e => { // switch(e.button) { // case 0: // left click // destroySelectedBlock(context); // break; // case 2: // right click // makeDirBlock(context); // break; // } // }; const clickListener = e => {}; const keyListener = e => { if (e.type === 'keydown') { if (e.repeat) return; context.keys.add(e.code); switch (e.code) { case 'KeyF': context.flying = !context.flying; context.player.velocity = [0, 0, 0]; delete context.orbit; return false; case 'KeyL': context.landing = !context.landing; return false; case 'KeyP': context.pause = !context.pause; return false; case 'Space': if (!context.flying) { jump(context); } return false; } } else { context.keys.delete(e.code); } }; const moveListener = e => { const cam = context.camera; if (context.landed) { cam.orientation[1] -= e.movementX / 500; cam.orientation[0] -= e.movementY / 500; cam.tf = [ se3.roty(cam.orientation[1]), se3.rotx(cam.orientation[0]), ].reduce(se3.product); return; } cam.tf =[ cam.tf, se3.roty(-e.movementX / 500), se3.rotx(-e.movementY / 500), ].reduce(se3.product); }; const changeListener = () => { if (document.pointerLockElement === canvas) { return; } document.removeEventListener('pointerdown', clickListener); document.removeEventListener('pointerlockchange', changeListener); document.removeEventListener('pointermove', moveListener); document.removeEventListener('keydown', keyListener); document.removeEventListener('keyup', keyListener); canvas.onclick = canvasClickHandler; }; document.addEventListener('pointerdown', clickListener); document.addEventListener('pointerlockchange', changeListener); document.addEventListener('pointermove', moveListener); document.addEventListener('keydown', keyListener); document.addEventListener('keyup', keyListener); }; canvas.onclick = canvasClickHandler; document.addEventListener('keydown', e => { if (e.repeat) return; switch (e.code) { case 'F11': canvas.requestFullscreen(); break; } }); } function jump(context) { if (context.jumpAmount > 0) { const amount = context.jumpForce; context.jumpAmount -= 1; const gravity = landedGravity(context); const up = linalg.normalize(linalg.scale(gravity, -1)); context.player.velocity = linalg.scale(up, amount); } } function moveShip(context, forward, right) { if (context.keys.has('ShiftLeft')) { forward *= 10; right *= 10; } const tf = se3.product( se3.orientationOnly(context.player.tf), context.camera.tf, ); const dir = [right, 0, -forward, 10]; if (context.flying) { context.player.tf = [ context.player.tf, se3.translation(...dir), ].reduce(se3.product); } else { const vel = context.player.velocity; const dv = linalg.scale(se3.apply(tf, dir), 1/dir[3]); context.player.velocity = linalg.add(vel, dv); delete context.orbit; } } function moveOnGround(context, forward, right) { if (context.keys.has('ShiftLeft')) { forward *= 2; right *= 2; } const tf = se3.product( se3.orientationOnly(context.player.tf), context.camera.tf, ); const dir = se3.apply(tf, [right, 0, -forward, 0]); if (context.flying) { context.player.tf = [ se3.translation(...dir), context.player.tf, ].reduce(se3.product); return; } const dv = linalg.scale(dir, 10); const maxSpeed = 8; const airMovement = 0.08; if (context.isOnGround) { const up = upDirection(context); const vertical = linalg.dot(dv, up); const horizontal = linalg.diff(dv, linalg.scale(up, vertical)); context.player.velocity = horizontal; return; } // steering in the air context.player.velocity = linalg.add(context.player.velocity, linalg.scale(dv, airMovement)); const curVel = linalg.norm(context.player.velocity); if (curVel > maxSpeed) { context.player.velocity = linalg.scale(context.player.velocity, maxSpeed / curVel); } } function handleInput(context) { const move = (f, r) => (context.landed ? moveOnGround : moveShip)(context, f, r); const roll = (amount: number) => { if (context.keys.has('ShiftLeft')) { amount *= 10; } context.camera.tf =[ context.camera.tf, se3.rotz(amount), ].reduce(se3.product); }; context.keys.forEach(key => { switch (key) { case 'KeyW': move(0.5, 0.0); return; case 'KeyA': move(0.0, -0.5); return; case 'KeyS': move(-0.5, 0.0); return; case 'KeyD': move(0.0, 0.5); return; case 'KeyQ': roll(0.02); return; case 'KeyE': roll(-0.02); return; case 'KeyR': context.timeOffset += 1; return; } }); } function slerp(current: linalg.Mat4, target: linalg.Mat4, maxVelocity: number) : linalg.Mat4 { const q0 = quat.mat2Quat(current); let q1 = quat.mat2Quat(target); const dq = quat.diff(q1, q0); const maxt = maxVelocity / quat.norm(dq); //const q = quat.normalize(quat.add(q0, quat.scale(dq, Math.min(1, maxt)))); const t = maxVelocity; if (quat.dot(q0, q1) < 0) { q1 = quat.scale(q1, -1); } const q = quat.normalize(quat.add(quat.scale(q0, 1 - t), quat.scale(q1, t))); return quat.quat2Mat(q); } function distanceToGround(body: Body, position: number[], direction: number[]) : number { const inChunkOrientation = se3.inverse(body.orientation); const toChunk = vec => se3.apply(inChunkOrientation, vec.concat([0])); const directionInChunk = toChunk(direction); const positionInChunk = toChunk(linalg.diff(position, body.position)); const hit = castRay(body.geometry.chunkMap, positionInChunk, directionInChunk); if (hit === undefined || hit.block.type === BlockType.UNDEFINED || hit.block.type === undefined) { const ray = linalg.diff(position, body.position); return { distance: linalg.norm(ray), normal: linalg.normalize(ray), }; } const distance = linalg.norm(linalg.diff(positionInChunk, hit.block.centerPosition)); const normal = se3.apply(body.orientation, hit.normal.concat([0])); return {distance, normal}; } function finishLanding(context, body) { const p = context.player; context.landed = true; context.landedBody = body; p.tf = [ se3.inverse(body.orientation), se3.inverse(se3.translation(...body.position)), p.tf, ].reduce(se3.product); context.shipTf = p.tf; p.tf = se3.product(p.tf, se3.translation(1, 1, 0)); p.velocity = [0, 0, 0]; p.position = se3.apply(p.tf, [0, 0, 0, 1]); } function alignUp(playerTf, surfaceNormal) { const zaxis = se3.apply(playerTf, [0, 0, 1, 0]); const ny = surfaceNormal; const nx = linalg.normalize(linalg.cross(ny, zaxis)); const nz = linalg.normalize(linalg.cross(nx, ny)); return se3.fromBases(nx, ny, nz); } function calculateLandingDv(verticalSpeed, altitude, dt) { // pretty crude logic, could be improved const maxThrust = 2; const final = verticalSpeed**2 / (2*(altitude - 1.0)); if (verticalSpeed > 0 || final < maxThrust * 0.8) { // can still accelerate forward return -maxThrust * dt; } else if (final < maxThrust) { // coast return 0; } return final; } function autoLand(context, dt) { const p = context.player; // 0. check prereqs: // - none // 1. adjust tangential speed to match body rotational // 2. cast ray towards center // 3. while high, go towards center // 4. when low, go towards ground // 5. stop when on ground const kShipRotateSpeed = 0.1; const body = findSoi(context.universe, p.position); const toBodyCenter = linalg.diff(body.position, p.position); const {distance: altitude, normal: surfaceNormal} = distanceToGround(body, p.position, linalg.normalize(toBodyCenter)); const surfaceVelocity = linalg.add(body.velocity, linalg.cross(body.spin, linalg.scale(toBodyCenter, -1))); const relativeVelocity = linalg.diff(p.velocity, surfaceVelocity); const upward = linalg.dot(relativeVelocity, surfaceNormal); const vertical = linalg.scale(surfaceNormal, upward); const horizontal = linalg.diff(relativeVelocity, vertical); const smallDv = linalg.scale(horizontal, -0.01); p.velocity = linalg.add(p.velocity, smallDv); // up is up const currentOrientation = slerp(p.tf, alignUp(p.tf, surfaceNormal), kShipRotateSpeed); p.tf = se3.setOrientation(p.tf, currentOrientation); if (altitude < 1.5) { if (upward < -2) { // going too fast, bounce p.velocity = linalg.add(p.velocity, linalg.scale(surfaceNormal, -2*upward)); context.landing = false; } else if (upward < 0) { finishLanding(context, body); return; } } // accelerate towards the ground and then slow down to land const dvup = calculateLandingDv(upward, altitude, dt); p.velocity = linalg.add(p.velocity, linalg.scale(surfaceNormal, dvup)); } function effectiveGravity(position: number[], rootBody: Body) : number[] { let body = rootBody; let acceleration = [0, 0, 0]; while (body !== undefined) { const toBodyCenter = linalg.diff(body.position, position); const d = linalg.norm(toBodyCenter); const gravity = body.mass / d**3; acceleration = linalg.add(acceleration, linalg.scale(toBodyCenter, gravity)); const parentMass = body.mass; const children = body.children || []; body = undefined; for (const child of children) { if (!isInSoi(child, parentMass, position)) { continue; } body = child; break; } } return acceleration; } function upDirection(context) { const gravity = landedGravity(context); return linalg.normalize(linalg.scale(gravity, -1)); } function landedGravity(context) { // just clamp "down" to one of our six directions const pos = context.player.position; const altitude2 = pos[0]**2 + pos[1]**2 + pos[2]**2; const g = context.landedBody.mass / altitude2; if (pos[0] > 0 && Math.abs(pos[1]) < pos[0] && Math.abs(pos[2]) < pos[0]) { return [-g, 0, 0]; } if (pos[0] < 0 && Math.abs(pos[1]) < -pos[0] && Math.abs(pos[2]) < -pos[0]) { return [+g, 0, 0]; } if (pos[1] > 0 && Math.abs(pos[0]) < pos[1] && Math.abs(pos[2]) < pos[1]) { return [0, -g, 0]; } if (pos[1] < 0 && Math.abs(pos[0]) < -pos[1] && Math.abs(pos[2]) < -pos[1]) { return [0, +g, 0]; } if (pos[2] > 0 && Math.abs(pos[1]) < pos[2] && Math.abs(pos[1]) < pos[2]) { return [0, 0, -g]; } if (pos[2] < 0 && Math.abs(pos[1]) < -pos[2] && Math.abs(pos[1]) < -pos[2]) { return [0, 0, +g]; } // blarg } function checkCollision(curPos, newPos, chunkMap, orientation) { const upDir = se3.apply(orientation, [0, 1, 0, 0]); // I guess Gaspard is about 1.7 m tall? // he also has a 60x60 cm axis-aligned square section '^_^ // box is centered around the camera const gaspardBB = [ [-0.3, 0.2, -0.3], [-0.3, 0.2, 0.3], [0.3, 0.2, -0.3], [0.3, 0.2, 0.3], [-0.3, -1.5, -0.3], [-0.3, -1.5, 0.3], [0.3, -1.5, -0.3], [0.3, -1.5, 0.3], ].map(v => se3.apply(orientation, v.concat([0]))); let dp = linalg.diff(newPos, curPos); let isOnGround = false; for (let i = 0; i < 3; i++) { const newGaspard = v => v.map((x, j) => i === j ? x + newPos[j] : x + curPos[j]); for (const point of gaspardBB.map(newGaspard)) { const block = blockLookup(chunkMap, ...point); if (block.type !== BlockType.AIR) { const dir = [...Array(3).keys()].map(j => j === i ? 1 : 0); if (Math.abs(linalg.dot(dir, upDir)) > 0.5) { isOnGround = true; } dp[i] = 0; } } } for (let i = 0; i < 3; i++) { const newGaspard = v => linalg.add(linalg.add(curPos, v), dp); for (const point of gaspardBB.map(newGaspard)) { const block = blockLookup(chunkMap, ...point); if (block.type !== BlockType.AIR) { dp[i] = 0; } } } return { newPos: linalg.add(curPos, dp), isOnGround, }; } function updateLandedPhysics(context, dt) { const gravity = landedGravity(context); context.player.velocity = linalg.add(context.player.velocity, linalg.scale(gravity, dt)); const oldPos = context.player.position; const taregtPos = linalg.add(context.player.position, linalg.scale(context.player.velocity, dt)); // from wmc const {isOnGround, newPos} = checkCollision(oldPos, taregtPos, context.landedBody.geometry.chunkMap, se3.orientationOnly(context.player.tf)); context.player.position = newPos; context.player.velocity = newPos.map((p, i) => (p - oldPos[i]) / dt); if (isOnGround) { context.jumpAmount = 2; context.player.velocity = context.player.velocity.map(v => v * 0.7); } context.isOnGround = isOnGround; context.player.tf = se3.setPosition(context.player.tf, newPos); // up is up const p = context.player; const currentOrientation = slerp(p.tf, alignUp(p.tf, upDirection(context)), 0.1); p.tf = se3.setOrientation(p.tf, currentOrientation); } function updatePhysics(time: number, context) { const {player} = context; const dt = time - (context.lastTime || 0); context.lastTime = time; player.position = se3.apply(player.tf, [0, 0, 0, 1]); if (!context.pause) { updateBodyPhysics(time, context.universe); } const kShipRotateSpeed = 0.1; if (context.landed) { return updateLandedPhysics(context, dt); } if (context.landing) { autoLand(context, dt); } else { // allow adjusting camera const dr = slerp(se3.identity(), context.camera.tf, kShipRotateSpeed); player.tf = se3.product(player.tf, dr); context.camera.tf = se3.product(context.camera.tf, se3.inverse(dr)); } if (context.flying) { // no physics, just magically moving around return; } const gravity = effectiveGravity(player.position, context.universe); player.velocity = linalg.add(player.velocity, linalg.scale(gravity, dt)); const newPos = linalg.add(player.position, linalg.scale(player.velocity, dt)); player.tf = se3.setPosition(player.tf, newPos); if (context.orbit === undefined) { const body = findSoi(context.universe, context.player.position); context.orbit = computeOrbit(player, body, time); console.log(`orbiting ${body.name}, excentricity: ${context.orbit.excentricity}`); context.orbitBody = body; } } function updateGeometry(context, timeout_ms = 10) { } function tick(time: number, context) { handleInput(context); const simTime = time * 0.001 + context.timeOffset; updatePhysics(simTime, context); // world generation / geometry update { // frame time is typically 16.7ms, so this may lag a bit let timeLeft = 10; const start = performance.now(); updateGeometry(context, timeLeft); } draw(context); const dt = (time - context.lastFrameTime) * 0.001; context.lastFrameTime = time; document.querySelector('#fps')!.textContent = `${1.0 / dt} fps`; requestAnimationFrame(time => tick(time, context)); } function makeCube(texture) { return { faces: [ makeFace('-x', texture, [-0.5, 0, 0]), makeFace('+x', texture, [+0.5, 0, 0]), makeFace('-y', texture, [0, -0.5, 0]), makeFace('+y', texture, [0, +0.5, 0]), makeFace('-z', texture, [0, 0, -0.5]), makeFace('+z', texture, [0, 0, +0.5]), ] }; } async function main() { const canvas = document.querySelector('#game')! as HTMLCanvasElement; // adjust canvas aspect ratio to that of the screen canvas.height = screen.height / screen.width * canvas.width; const gl = canvas.getContext('webgl'); if (gl === null) { console.error('webgl not available') return; } // TODO // [ ] loading bar // [x] spaceship // [x] landing // [ ] huge planets // [x] lighting // [x] better lighting // [ ] betterer lighting // [x] optimize geometry generation const modelPromise = loadObjModel('spaceship.obj'); const context = { gl, projMatrix: se3.perspective(Math.PI / 3, canvas.clientWidth / canvas.clientHeight, 0.1, 10000.0), player: { tf: se3.translation(0.0, 0.0, 80.0), position: [0.0, 0.0, 80.0], velocity: [0, 0, 0], }, camera: { orientation: [0, 0, 0], tf: se3.identity(), }, keys: new Set(), lightDirection: [-0.2, -0.5, 0.4], skyColor: [0.10, 0.15, 0.2], ambiantLight: 0.4, blockSelectDistance: 8, flying: true, isOnGround: false, gravity: -17, jumpForce: 6.5, universe: getSolarSystem(0), timeOffset: 0, }; context.glContext = await initWorldGl(gl); context.orbitGlContext = getOrbitDrawContext(gl); initUiListeners(canvas, context); const starshipGeom = await modelPromise; context.spaceship = starshipGeom; requestAnimationFrame(time => tick(time, context)); } window.onload = main;