Published
Edited
Dec 2, 2021
1 fork
1 star
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
aspectRatio = window.screen.width / window.screen.height
Insert cell
height = ~~(width / aspectRatio)
Insert cell
Insert cell
// Creates a key for a world cell
function key(cell) {
return cell.toString();
}
Insert cell
// Obtains a cell address from a world position (stored as a THREE.Vector3)
function positionToCell(pos) {
return pos.toArray().map((x) => Math.round(x));
}
Insert cell
// Obtains a world position from a cell
function cellToPosition(cell) {
return new THREE.Vector3(...cell);
}
Insert cell
// The world map
mutable worldMap = {
reset;
let cells = new Map();
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
cells.set(key([i, 0, j]), { address: [i, 0, j], type: "block" });
}
}
cells.set(key([1, 1, 1]), {
address: [1, 1, 1],
type: "player",
quat: [0, 0, 0, 1]
});
return cells;
}
Insert cell
// Function to set the world map to the configuration stored in the given string
function loadWorldMap(string) {
let array = JSON.parse(string);
const newMap = new Map();
for (let [key, value] of array) newMap.set(key, value);
blocksGroup.remove(...blocksGroup.children);
mutable worldMap = newMap;
}
Insert cell
// Function that can be called to write a file with a text representation of the world map
saveWorldMap = {
var a = document.createElement("a");
document.body.appendChild(a);
a.style = "display: none";
return function (fileName) {
let copyMap = new Map();
{
// Add player info to map that will be saved
let address = camera.position.toArray();
let quat = camera.quaternion.toArray();
let type = "player";
copyMap.set(key(address), { address, quat, type });
}
for (let [key, value] of worldMap) {
if (value.type == "player") continue; // Will disregard the player
let copyValue = { ...value }; // A copy of the value
delete copyValue.mesh; // Remove the three mesh object, if any
copyMap.set(key, copyValue);
}
let text = JSON.stringify([...copyMap]);
let blob = new Blob([text], {
type: "text/plain;charset=utf-8"
});
var url = window.URL.createObjectURL(blob);
a.href = url;
a.download = fileName;
a.click();
window.URL.revokeObjectURL(url);
};
}
Insert cell
Insert cell
//
// Buttons to save the world to text file, or load the world from a text file
//
loadSaveButtons = {
let save = html`<button>Save</button>`;
let savename = html`<input type=text value=world.txt></text>`;
save.onclick = () => saveWorldMap(savename.value);
let load = html`<button>Load</button>`;
let hiddenLoad = html`<input type="file" style="display:none;"/>`;
hiddenLoad.onchange = (f) => {
let components = hiddenLoad.value.split(/\/|\\/);
savename.value = components[components.length - 1];
Files.text(hiddenLoad.files[0]).then((txt) => {
loadWorldMap(txt);
});
hiddenLoad.value = ""; // Clear for the next load
};
load.onclick = () => {
hiddenLoad.click();
};
return html`${load} ${save} ${savename}`;
}
Insert cell
// A reset button for resetting the scene to the default
viewof reset = html`<input type=button style="display:inline-block;margin-right:5px" value="Reset">`
Insert cell
Insert cell
//
// Animation utilities
//
animationManager = {
// Map of animations still being played out
let animMap = new Map();

// Starts animation to apply rotation quat to obj3d.
// When animation is finished, calls callback() if defined.
// The animation has duration steps * stepTime.
function rotate(obj3d, quat, callback, steps = 20, stepTime = 20) {
if (animMap.has(obj3d)) return; // Ignore if not finished animating

let oldRot = obj3d.quaternion.clone();
let newRot = obj3d.quaternion.clone().multiply(quat);
let counter = 0;
let id;
// Clean up code
const atEnd = () => {
clearInterval(id);
animMap.delete(obj3d);
if (callback) callback();
};
// Animation callback
const animation = () => {
counter += 1;
THREE.Quaternion.slerp(oldRot, newRot, obj3d.quaternion, counter / steps);
if (counter == steps) {
atEnd();
}
};
// Mark the animation map
animMap.set(obj3d, 1);

// start animation
id = setInterval(animation, stepTime);
}

// Starts animation to translate obj3d to a new position.
// When animation is finished, calls callback() if defined.
// The animation has duration steps * stepTime.
function translate(obj3d, newPos, callback, steps = 20, stepTime = 20) {
if (animMap.has(obj3d)) return; // Ignore if not finished animating

let oldPos = obj3d.position.clone();
let counter = 0;
let id;
// Clean up code
const atEnd = () => {
clearInterval(id);
animMap.delete(obj3d);
if (callback) callback();
};
// Animation callback
const animation = () => {
counter += 1;
obj3d.position.lerpVectors(oldPos, newPos, counter / steps);
if (counter == steps) atEnd();
};
// Mark the animation map
animMap.set(obj3d, 1);
// start animation
id = setInterval(animation, stepTime);
}

// Starts animation to rotate and translate obj3d
// When animation is finished, calls callback() if defined.
// The animation has duration steps * stepTime.
function translateRotate(
obj3d,
newPos,
quat,
callback,
steps = 20,
stepTime = 20
) {
if (animMap.has(obj3d)) return; // Ignore if not finished animating

let oldPos = obj3d.position.clone();
let oldRot = obj3d.quaternion.clone();
let newRot = obj3d.quaternion.clone().multiply(quat);
let counter = 0;
let id;
// Clean up code
const atEnd = () => {
clearInterval(id);
animMap.delete(obj3d);
if (callback) callback();
};
// Animation callback
const animation = () => {
counter += 1;
obj3d.position.lerpVectors(oldPos, newPos, counter / steps);
THREE.Quaternion.slerp(oldRot, newRot, obj3d.quaternion, counter / steps);
if (counter == steps) atEnd();
};
// Mark the animation map
animMap.set(obj3d, 1);
// start animation
id = setInterval(animation, stepTime);
}

return { animMap, rotate, translate, translateRotate };
}
Insert cell
//
// Sets up callbacks for mouse and keyboard events. Renders the scene while
// animations are taking place
//
interaction = {
reset;

// Quaternions for the 4 3d rotations
const halfTurn = Math.PI / 2;
const rot = (dx, dy) =>
new THREE.Quaternion().setFromEuler(
new THREE.Euler(dx * halfTurn, dy * halfTurn, 0)
);
const rotations = {
left: rot(0, 1),
right: rot(0, -1),
up: rot(1, 0),
down: rot(-1, 0)
};

// Returns the local axes of a 3d object in world coordinates
const localAxes = (obj3d) => {
let axes = [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()];
obj3d.matrix.extractBasis(...axes);
return axes;
};

// The cell in front of the user
const inFront = () =>
positionToCell(camera.position.clone().sub(localAxes(camera)[2]));

// The cell behind the user
const behind = () =>
positionToCell(camera.position.clone().add(localAxes(camera)[2]));

// The cell in front and below the user
const inFrontBelow = () => {
let axes = localAxes(camera);
return positionToCell(camera.position.clone().sub(axes[1]).sub(axes[2]));
};

// The cell behind and below the user
const behindBelow = () => {
let axes = localAxes(camera);
return positionToCell(camera.position.clone().sub(axes[1]).add(axes[2]));
};

// Actions
const turnLeft = () => {
animationManager.rotate(camera, rotations.left, render);
};
const turnRight = () => {
animationManager.rotate(camera, rotations.right, render);
};
const moveAhead = () => {
if (worldMap.has(key(inFrontBelow())) && !worldMap.has(key(inFront()))) {
// Straight ahead
animationManager.translate(camera, cellToPosition(inFront()), render);
} else if (worldMap.has(key(inFront()))) {
// Walk on wall ahead
animationManager.rotate(camera, rotations.up, render);
} else if (!worldMap.has(key(inFrontBelow()))) {
// Walk around ledge ahead
let A = cellToPosition(inFront());
let B = cellToPosition(inFrontBelow());
let pos2 = camera.position.clone().lerp(A, 0.5);
let pos3 = A.clone().lerp(B, 0.5);
animationManager.translate(
camera,
pos2,
() => {
animationManager.translateRotate(
camera,
pos3,
rotations.down,
() => {
animationManager.translate(camera, B, render, 10);
},
10
);
},
10
);
}
};
const moveBack = () => {
if (worldMap.has(key(behindBelow())) && !worldMap.has(key(behind()))) {
// Straight back
animationManager.translate(camera, cellToPosition(behind()), render);
} else if (worldMap.has(key(behind()))) {
// Walk on wall behind
animationManager.rotate(camera, rotations.down, render);
} else if (!worldMap.has(key(behindBelow()))) {
// Walk around ledge behind
let A = cellToPosition(behind());
let B = cellToPosition(behindBelow());
let pos2 = camera.position.clone().lerp(A, 0.5);
let pos3 = A.clone().lerp(B, 0.5);
animationManager.translate(
camera,
pos2,
() => {
animationManager.translateRotate(
camera,
pos3,
rotations.up,
() => {
animationManager.translate(camera, B, render, 10);
},
10
);
},
10
);
}
};
const makeBlock = (cell) => {
if (!worldMap.has(key(cell))) {
worldMap.set(key(cell), { address: cell, type: "block" });
makeWorldBlocks();
}
};
const removeBlock = (cell) => {
if (worldMap.has(key(cell))) {
worldMap.delete(key(cell));
makeWorldBlocks();
}
};
const makeLetter = (cell, char) => {
if (!worldMap.has(key(cell))) {
worldMap.set(key(cell), {
address: cell,
type: "char",
quat: camera.quaternion.toArray(),
char
});
makeWorldBlocks();
}
};
const switchCameras = () => {
controls.enabled = !controls.enabled;
render();
};

// Keyboard callback
const keyboardCallback = (e) => {
e.preventDefault();
switch (e.key) {
case "ArrowLeft":
turnLeft();
break;
case "ArrowRight":
turnRight();
break;
case "ArrowUp":
moveAhead();
break;
case "ArrowDown":
moveBack();
break;
case "Tab":
switchCameras();
break;
case "Delete":
case "Backspace": {
let cell = inFront();
if (worldMap.has(key(cell))) removeBlock(cell);
else {
cell = inFrontBelow();
if (worldMap.has(key(cell))) removeBlock(cell);
}
render();
break;
}
case " ": {
let cell = inFrontBelow();
if (!worldMap.has(key(cell))) makeBlock(cell);
else {
cell = inFront();
if (!worldMap.has(key(cell))) makeBlock(cell);
}
render();
break;
}
default:
if (e.key.length == 1) {
let cell = inFrontBelow();
if (!worldMap.has(key(cell))) makeLetter(cell, e.key);
else {
cell = inFront();
if (!worldMap.has(key(cell))) makeLetter(cell, e.key);
}
render();
}
break;
}
};

// Mouse callback
const mouseCallback = (e) => {
if (controls.enabled) return;
if (e.offsetX < canvas.width / 3) turnLeft();
else if (e.offsetX > (canvas.width / 3) * 2) turnRight();
else if (e.offsetY < canvas.height / 2) moveAhead();
else moveBack();
};

// Register callbacks
canvas.addEventListener("keydown", keyboardCallback);
canvas.addEventListener("mousedown", mouseCallback);

// House cleaning for when this cell is redefined
invalidation.then(() => {
canvas.removeEventListener("keydown", keyboardCallback);
canvas.removeEventListener("mousedown", mouseCallback);
});

// Loop to render frames while animations are taking place
for (let frame = 1; ; frame++) {
if (animationManager.animMap.size > 0) {
render();
yield `${frame} rendered`;
} else yield `${frame} (no render)`;
}
}
Insert cell
Insert cell
//
// Import the three.js library
//
THREE = {
const THREE = (window.THREE = await require("three@0.119.1"));
await require("three@0.119.1/examples/js/controls/TrackballControls.js").catch(
() => {}
);
await require("three@0.119.1/examples/js/controls/OrbitControls.js").catch(
() => {}
);
return THREE;
}
Insert cell
import { loadFont, textGeometry } from "@esperanc/3d-fonts"
Insert cell
//font = loadFont("droid sans mono")
Insert cell
renderer = new THREE.WebGLRenderer({ canvas })
Insert cell
//
// A camera mounted on the player
//
camera = {
let camera = new THREE.PerspectiveCamera(90, aspectRatio, 0.1, 1000);
camera.position.set(1, 1, 1);
// Obtain camera from worldMap if set
for (let obj of worldMap.values()) {
if (obj.type == "player") {
camera.position.set(...obj.address);
camera.quaternion.set(...obj.quat);
worldMap.delete(key(obj.address));
break;
}
}
return camera;
}
Insert cell
//
// An overview camera for watching the world from afar
//
overviewCamera = {
reset;
let overview = new THREE.PerspectiveCamera(60, aspectRatio, 0.1, 1000);
overview.position.set(0, 0, 15);
return overview;
}
Insert cell
//
// Set of mouse controls for moving around the scene with the overview camera
//
controls = {
let controls = new THREE.OrbitControls(overviewCamera, canvas);
// Disable keyboard controls
controls.keys = {
LEFT: null, //left arrow
UP: null, // up arrow
RIGHT: null, // right arrow
BOTTOM: null // down arrow
};
// Disable the overview by default. Enable by pressing the 'tab' key
controls.enabled = false;
invalidation.then(() => controls.dispose());
return controls;
}
Insert cell
//
// Arrange so that when the contols are enabled, any change in the camera settings triggers a render
//
controlsRenderCallback = {
controls.addEventListener("change", render);
invalidation.then(() => controls.removeEventListener("change", render));
}
Insert cell
//
// An arrow representing the player, attached to the camera object
//
player = {
// const geometry = new THREE.ConeGeometry(0.5, 1, 16);
const shape = new THREE.Shape();
shape.moveTo(0, 0.5);
shape.lineTo(0.5, 0.25);
shape.lineTo(0.25, 0.25);
shape.lineTo(0.25, -0.5);
shape.lineTo(-0.25, -0.5);
shape.lineTo(-0.25, 0.25);
shape.lineTo(-0.5, 0.25);
const extrudeSettings = {
steps: 1,
depth: 0.1,
bevelEnabled: true,
bevelThickness: 0.1,
bevelSize: 0.1,
bevelOffset: 0,
bevelSegments: 10
};
const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
const material = new THREE.MeshStandardMaterial({ color: 0x00aaaa });
const cone = new THREE.Mesh(geometry, material);
cone.rotation.set(-Math.PI / 2, 0, 0);
cone.position.set(0, -0.4, 0);
cone.scale.set(0.6, 0.6, 0.6);
const group = new THREE.Group();
group.add(cone);
group.visible = false;
camera.add(group);
invalidation.then(() => camera.remove(group));
return group;
}
Insert cell
blocksGroup = {
reset;
return new THREE.Group();
}
Insert cell
blockGeometry = new THREE.BoxGeometry(0.95, 0.95, 0.95)
Insert cell
blockMaterial = new THREE.MeshStandardMaterial({ color: 0xff0000 })
Insert cell
letterMaterial = new THREE.MeshStandardMaterial({ color: 0xaaaaaa })
Insert cell
function makeWorldBlocks() {
for (let obj of blocksGroup.children) {
if (!worldMap.has(obj.key)) blocksGroup.remove(obj);
}
for (let [k, v] of worldMap) {
if (!v.mesh) {
if (v.type == "block") {
v.mesh = new THREE.Mesh(blockGeometry, blockMaterial);
} else if (v.type == "char") {
let geom = textGeometry(v.char);
// new THREE.TextGeometry(v.char, {
// font,
// size: 1,
// height: 0.3,
// curveSegments: 20,
// bevelEnabled: true,
// bevelThickness: 0.02,
// bevelSize: 0.02,
// bevelOffset: 0,
// bevelSegments: 20
// });
geom.computeBoundingBox();
//geom.computeVertexNormals();
let center = geom.boundingBox.getCenter(new THREE.Vector3());
let letter = new THREE.Mesh(geom, letterMaterial);
letter.position.set(-center.x, -0.5, -center.z);
v.mesh = new THREE.Group();
v.mesh.add(letter);
} else continue;
v.mesh.position.set(...v.address);
if (v.quat) v.mesh.quaternion.set(...v.quat);
v.mesh.key = key(v.address);
blocksGroup.add(v.mesh);
}
}
}
Insert cell
//
// Array of images for building a cubemap
//
cubemapImages = [
await FileAttachment("px@2.png").image(),
await FileAttachment("nx@3.png").image(),
await FileAttachment("py@2.png").image(),
await FileAttachment("ny@2.png").image(),
await FileAttachment("pz@2.png").image(),
await FileAttachment("nz@2.png").image()
]
// [
// await FileAttachment("px@1.png").image(),
// await FileAttachment("nx@1.png").image(),
// await FileAttachment("py@1.png").image(),
// await FileAttachment("ny@1.png").image(),
// await FileAttachment("pz@1.png").image(),
// await FileAttachment("nz@1.png").image()
// ]
Insert cell
cubemap = {
let map = new THREE.CubeTexture(cubemapImages);
map.needsUpdate = true;
return map;
}
Insert cell
light = {
let group = new THREE.Group();
let dLight = new THREE.DirectionalLight(0xffffff, 0.8);
dLight.position.set(5, 10, 20);
let dLight2 = new THREE.DirectionalLight(0xffffff, 1);
dLight2.position.set(-5, -10, -20);
group.add(dLight, dLight2);
return group;
}
Insert cell
scene = {
reset;
let scene = new THREE.Scene();
scene.background = cubemap;
makeWorldBlocks();
scene.add(blocksGroup);
scene.add(light);
scene.add(camera);
return scene;
}
Insert cell
function render() {
if (controls.enabled) {
player.visible = true;
renderer.render(scene, overviewCamera);
} else {
player.visible = false;
renderer.render(scene, camera);
}
}
Insert cell
initialRender = {
reset;
canvas.focus();
render();
}
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more