// Cloned by MENGTE ZHU on 21 Mar 2024 from World "New World" by Michael Walsh
// Please leave this clone trail here.
'use strict';
// imported from https://threejs.org/examples/misc_controls_pointerlock.html
import * as THREE from "/uploads/mjwalsh/three.module.js";
import { GLTFLoader } from '/uploads/mjwalsh/GLTFLoader.js';
import * as SkeletonUtils from '/uploads/mjwalsh/SkeletonUtils.js';
const SKYDISTANCE = 900; // Distance of stars and sun
const NBSTARS = 100; //number of stars
const PI_2 = Math.PI / 2;
const LEVEL = 4;
const STANDING = 'Idle';
const JUMPING = 'TPose';
const RUNNING = 'Run';
const WALKING = 'Walk';
const FADE_DURATION = 0.2;
let gWorldId = 0;
let gGLTF = null;
let gSoldier = null
let gSocketStarted = false;
let gPlayers = {};
let gPlayerObjects = {};
let gPlayerId;
let gLastMessage = 0;
let gInitialised = false;
let gCamera;
let gPlayerOne;
let gControls;
let gScene;
let gWorld;
let gGoForward = false;
let gLookLeft = false;
let gLookRight = false;
let gCanJump = false;
let gLookUp = false;
let gLookDown = false;
const gGltfLoader = new GLTFLoader();
class World
{
renderer;
objects = [];
sun;
stars;
hour;
gltf;
clock = new THREE.Clock(true);
velocity = new THREE.Vector3();
vertex = new THREE.Vector3();
constructor(ws) {
gWorld = this;
this.gltf = ws;
AB.msg('<ul style="padding-left: 2ch; user-select: none">'
+ '<li>Move back and forward using up and down keys</li>'
+ '<li>Rotate view using left and right keys</li>'
+ '<li>Look up and down using your A and Z keys</li>'
+ '<li id="lockUnlock">Click the screen to lock in your mouse</li>');
const color = new THREE.Color();
gCamera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 1, 1000);
gCamera.position.y = LEVEL;
gScene = new THREE.Scene();
gScene.add(gCamera);
gControls = new Controls();
gPlayerOne = new Player();
document.addEventListener('click', () => {
gControls.lock();
});
// grass
let floorGeometry = new THREE.PlaneGeometry(2000, 2000, 100, 100);
floorGeometry.rotateX(- Math.PI / 2);
let position = floorGeometry.attributes.position;
for (let i = 0, l = position.count; i < l; i ++) {
this.vertex.fromBufferAttribute(position, i);
this.vertex.x += THREE.MathUtils.seededRandom() * 20 - 10;
this.vertex.y += THREE.MathUtils.seededRandom() * 2;
this.vertex.z += THREE.MathUtils.seededRandom() * 20 - 10;
position.setXYZ(i, this.vertex.x, this.vertex.y, this.vertex.z);
}
floorGeometry = floorGeometry.toNonIndexed(); // ensure each face has unique vertices
position = floorGeometry.attributes.position;
const colorsFloor = [];
for (let i = 0, l = position.count; i < l; i ++) {
switch(Math.floor(THREE.MathUtils.seededRandom() * 3)) {
case 0:
colorsFloor.push(0.6, 0.98, 0.6);
break;
case 1:
colorsFloor.push(0.13, 0.55, 0.13);
break;
case 2:
colorsFloor.push(0.2, 0.8, 0.2);
break;
}
}
floorGeometry.setAttribute('color', new THREE.Float32BufferAttribute(colorsFloor, 3));
const floorMaterial = new THREE.MeshLambertMaterial({ vertexColors: true });
const floor = new THREE.Mesh(floorGeometry, floorMaterial);
floor.receiveShadow = true;
gScene.add(floor);
// calculate the dimensions of the city model
const box = new THREE.Box3(new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, 0));
box.expandByObject(this.gltf.scene);
const dim = new THREE.Vector3();
box.getSize(dim);
// add nine cities
for(let i = 0; i < 3; i++) {
for(let j = 0; j < 3; j++) {
const nCity = SkeletonUtils.clone(this.gltf.scene);
nCity.position.set(dim.x * i, 2, dim.z * j);
gScene.add(nCity);
}
}
// add the sun
this.sun = new Sun(30, 3);
this.stars = new Stars(30);
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.shadowMap.enabled = true;
document.body.appendChild(this.renderer.domElement);
// capture resize events
window.addEventListener('resize', this.onWindowResize);
}
onWindowResize = () => {
gCamera.aspect = window.innerWidth / window.innerHeight;
gCamera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
};
animate = () => {
this.hour = ((new Date().getTime() % 120000) / 120000) * 24;
requestAnimationFrame(this.animate);
const delta = this.clock.getDelta();
this.velocity.x -= this.velocity.x * 5 * delta;
this.velocity.z -= this.velocity.z * 5 * delta;
this.velocity.y -= 490 * delta;
let z = Number(gGoForward);
let x = Number(gLookRight) - Number(gLookLeft);
let y = Number(gLookDown) - Number(gLookUp);
if (z != 0) this.velocity.z -= z * 200 * delta;
// pretend to be a mouse
gControls.changeAngles(20 * x, 20 * y);
// moving around on the flat
gControls.moveForward(- this.velocity.z * (delta / 2));
// change height
gPlayerOne.model.position.y += (this.velocity.y * (delta / 10));
gCamera.position.y += (this.velocity.y * (delta / 10));
if (gPlayerOne.model.position.y < LEVEL - 2) {
this.velocity.y = 0;
gPlayerOne.model.position.y = LEVEL - 2;
gCanJump = true;
}
// set model speed
let pose;
const speed = Math.abs(this.velocity.z);
if(gPlayerOne.model.position.y > 4) pose = JUMPING;
else if(speed < 1) pose = STANDING;
else if(speed > 20) pose = RUNNING;
else pose = WALKING;
AB.socketOut({
action: 'player_pos',
worldId: gWorldId,
player: gPlayerId,
position: {
x: gPlayerOne.model.position.x,
y: gPlayerOne.model.position.y,
z: gPlayerOne.model.position.z,
},
pose: pose,
quaternion: gPlayerOne.model.quaternion.toArray(),
});
for(const p in gPlayers) {
if(!gPlayerObjects[p])
gPlayerObjects[p] = new Player();
gPlayerObjects[p].animate(gPlayers[p]);
}
this.sun.animate();
this.stars.animate();
this.renderer.render(gScene, gCamera);
};
}
class Controls extends THREE.EventDispatcher
{
changeEvent = { type: 'change' };
lockEvent = { type: 'lock' };
unlockEvent = { type: 'unlock' };
startY;
startX;
constructor() {
super();
// Set to constrain the pitch of the camera
// Range is 0 to Math.PI radians
this.maxPolarAngle = Math.PI; // radians
this.pointerSpeed = 1.0;
document.addEventListener('keydown', this.onKeyDown);
document.addEventListener('keyup', this.onKeyUp);
document.addEventListener('touchstart', this.onTouchStart);
document.addEventListener('touchmove', this.onTouchMove);
this.connect();
}
connect = () => {
document.addEventListener('pointerlockchange', this.onPointerlockChange);
document.addEventListener('pointerlockerror', this.onPointerlockError);
document.addEventListener('mousedown', this.onMouseDown);
document.addEventListener('mouseup', this.onMouseUp);
};
disconnect = () => {
document.removeEventListener('pointerlockchange', this.onPointerlockChange);
document.removeEventListener('pointerlockerror', this.onPointerlockError);
document.removeEventListener('mousedown', this.onMouseDown);
document.removeEventListener('mouseup', this.onMouseUp);
};
dispose = () => {
this.disconnect();
};
getDirection = (v) => {
return v.set(0, 0, - 1).applyQuaternion(gPlayerOne.model.quaternion);
};
moveForward = (distance) => {
// move forward parallel to the xz-plane
// assumes camera.up is y-up
let vector = new THREE.Vector3();
vector.setFromMatrixColumn(gPlayerOne.model.matrix, 0);
vector.crossVectors(gPlayerOne.model.up, vector);
gPlayerOne.animate2(vector, distance);
const v = gPlayerOne.model.position.clone();
vector.setLength(4);
v.sub(vector);
gCamera.position.x = v.x;
gCamera.position.y = gPlayerOne.model.position.y + 2;
gCamera.position.z = v.z;
};
lock = () => {
document.body.requestPointerLock();
};
unlock = () => {
document.exitPointerLock();
};
// event listeners
onMouseDown = event => {
gGoForward = true;
}
onMouseUp = event => {
gGoForward = false;
}
onMouseMove = (event) => {
this.changeAngles(event.movementX, event.movementY);
}
onTouchStart = (e) => {
if(e.touches.length != 1) return;
this.startY = e.touches[0].pageY;
this.startX = e.touches[0].pageX;
};
onTouchMove = (e) => {
if(e.touches.length != 1) return;
const x = this.startX - e.touches[0].pageX;
const y = this.startY - e.touches[0].pageY;
this.startY = e.touches[0].pageY;
this.startX = e.touches[0].pageX;
this.changeAngles(x, y);
};
onKeyDown = (event) => {
switch (event.code) {
case 'KeyA': gLookUp = true; break;
case 'KeyZ': gLookDown = true; break;
case 'ArrowUp': gGoForward = true; break;
case 'ArrowLeft': gLookLeft = true; break;
case 'ArrowRight': gLookRight = true; break;
case 'Space':
if (gCanJump === true) gWorld.velocity.y += 350;
gCanJump = false;
break;
}
};
onKeyUp = (event) => {
switch (event.code) {
case 'KeyA': gLookUp = false; break;
case 'KeyZ': gLookDown = false; break;
case 'ArrowUp': gGoForward = false; break;
case 'ArrowLeft': gLookLeft = false; break;
case 'ArrowRight': gLookRight = false; break;
}
};
changeAngles = (x, y) => {
// do the camera first
const euler = new THREE.Euler(0, 0, 0, 'YXZ');
euler.setFromQuaternion(gCamera.quaternion);
euler.y -= x * 0.002 * this.pointerSpeed;
euler.x -= y * 0.002 * this.pointerSpeed;
euler.x = THREE.MathUtils.clamp(euler.x, -PI_2, PI_2);
gCamera.quaternion.setFromEuler(euler);
// don't recline the soldier
euler.x = 0;
gPlayerOne.model.quaternion.setFromEuler(euler);
};
onPointerlockChange = () => {
if (document.pointerLockElement === document.body) {
document.addEventListener('mousemove', this.onMouseMove);
} else {
document.removeEventListener('mousemove', this.onMouseMove);
}
};
onPointerlockError = () => {
console.error('Unable to use Pointer Lock API');
};
}
class Sun
{
size;
speed;
angle = 0;
d = 1000;
sunlight = new THREE.DirectionalLight(0xffffff, 0.1);
sunball;
// size (number): the size of the sun & stars
// speed (number): the speed of the rotation of the sun
constructor(size, speed) {
this.size = size;
this.speed = speed;
this.sunlight.position.set(0, -SKYDISTANCE, 0);
this.sunlight.castShadow = true;
this.sunball = new THREE.Mesh(new THREE.SphereGeometry(this.size, 32, 32),
new THREE.MeshBasicMaterial({color : "yellow", fog: false}));
gScene.add(this.sunlight);
gScene.add(this.sunball);
}
map(n, start1, stop1, start2, stop2)
{
return ((n - start1) / (stop1 - start1)) * (stop2 - start2) + start2;
}
//Use this in nextStep to make the sun move
animate = () =>
{
// a day lasts two minutes
// this way time is the same in different computers
this.angle = gWorld.hour / 12;
const sunPos = new THREE.Vector3(
0,
Math.sin(this.angle * Math.PI) * SKYDISTANCE,
Math.cos(this.angle * Math.PI) * SKYDISTANCE);
this.sunball.position.copy(sunPos);
// place the light source a little higher
sunPos.y += 8500;
this.sunlight.position.copy(sunPos);
this.sunlight.intensity = this.getSunIntensity();
gScene.background = new THREE.Color(this.getSkyColor());
};
/**
* A linear interpolator for hexadecimal colors
* @param {Int} a
* @param {Int} b
* @param {Number} amount
* @example
* // returns 0x7F7F7F
* lerpColor(0x000000, 0xffffff, 0.5)
* @returns {Int}
*/
lerpColor(a, b, amount) {
let ah = a,
ar = ah >> 16, ag = ah >> 8 & 0xff, ab = ah & 0xff,
bh = b,
br = bh >> 16, bg = bh >> 8 & 0xff, bb = bh & 0xff,
rr = ar + amount * (br - ar),
rg = ag + amount * (bg - ag),
rb = ab + amount * (bb - ab);
return ((1 << 24) + (rr << 16) + (rg << 8) + rb | 0);
}
//return the color of the sky depending of the position of the sun (to get the sunrise)
getSkyColor()
{
// range from 0 to 16
let x = this.angle * 8;
switch(Math.floor(x)) {
case 0:
return this.lerpColor(0xfd5e53, 0x7ec0ee, this.map(x, 0, 1, 0, 1));
case 1:
case 2:
case 3:
case 4:
case 5:
default:
case 6:
return 0x7ec0ee;
case 7:
return this.lerpColor(0x7ec0ee, 0xfd5e53, this.map(x, 7, 8, 0, 1));
case 8:
return this.lerpColor(0xfd5e53, 0x0c3166, this.map(x, 8, 9, 0, 1));
case 9:
case 10:
case 11:
case 12:
case 13:
case 14:
return 0x0c3166;
case 15:
case 16:
return this.lerpColor(0x0c3166, 0xfd5e53, this.map(x, 15, 16, 0, 1));
}
}
//return intensity of the sun
getSunIntensity()
{
// range from 0 to 16
let x = this.angle * 8;
if (x > 1 && x <= 7)
{
return 2;
}
else if (x > 7 && x <= 8)
{
return this.map(x, 7, 8, 2, 1);
}
else if (x > 8 && x <= 9)
{
return this.map(x, 8, 9, 1, 0);
}
else if (x > 9 || x <= 15)
{
return 0;
}
else if (x > 15)
{
return this.map(x, 15, 16, 0, 1);
}
else if (x <= 1)
{
return this.map(x, 0, 1, 1, 2);
}
}
}
class Stars
{
stars = [];
starMaterial;
moonMaterial;
starGyroscope = new THREE.Mesh();
moonlight = new THREE.DirectionalLight(0xffffff, 0.1);;
size;
constructor(size) {
this.starMaterial = new THREE.MeshBasicMaterial({color : "white", transparent: true, fog: false}) ;
this.starMaterial.transparent = true;
this.starMaterial.opacity = 0.2;
this.size = size;
this.moonlight.castShadow = true;
this.moonlight.shadow.mapSize.width = 1024;
this.moonlight.shadow.mapSize.height = 1024;
this.moonlight.shadow.camera.near = 10;
this.moonlight.shadow.camera.far = 4000;
this.moonlight.shadow.camera.left = -this.d;
this.moonlight.shadow.camera.right = this.d;
this.moonlight.shadow.camera.top = this.d;
this.moonlight.shadow.camera.bottom = -this.d;
this.moonlight.shadow.bias = -0.0001;
this.moonlight.shadow.radius = 0.05;
this.moonlight.shadow.normalBias = 0.05;
this.moonMaterial = new THREE.MeshBasicMaterial({color: "lightyellow", transparent: true, fog: false})
let moon = new THREE.Mesh(new THREE.SphereGeometry(50, 32, 32), this.moonMaterial);
moon.position.set(-658, 376, -484);
this.starGyroscope.add(moon);
this.moonlight.position.copy(moon.position);
gScene.add(this.moonlight);
gScene.add(this.starGyroscope);
//Create all this.stars
for (let i = 0; i < NBSTARS; i++)
{
let radius = Stars.randomFloatAtoB (this.size/25, this.size/8);
let star = new THREE.Mesh(new THREE.SphereGeometry(radius, 8, 8), this.starMaterial);
let s = Stars.randomFloatAtoB (0, Math.PI * 2);
let t = Stars.randomFloatAtoB (0, Math.PI / 2);
star.position.set(SKYDISTANCE*Math.cos(s)*Math.sin(t), SKYDISTANCE*Math.cos(t), SKYDISTANCE*Math.sin(s)*Math.sin(t));
this.starGyroscope.add(star);
}
}
static randomFloatAtoB(t, n) {
return t + THREE.MathUtils.seededRandom() * (n - t);
}
map(n, start1, stop1, start2, stop2)
{
return ((n - start1) / (stop1 - start1)) * (stop2 - start2) + start2;
}
// show or hide the moon and stars depending on the hour
animate = () =>
{
if (gWorld.hour > 0.5 && gWorld.hour < 11.5)
{
// day time
this.moonMaterial.opacity = this.starMaterial.opacity = 0;
//this.moonlight.visible = false;
}
else if (gWorld.hour >= 11.5 && gWorld.hour <= 12.5)
{
this.moonMaterial.opacity = this.starMaterial.opacity = gWorld.hour - 11.5;
//this.moonlight.visible = false;
}
else if (gWorld.hour > 12.5 && gWorld.hour < 23.5)
{
// night time
this.moonMaterial.opacity = this.starMaterial.opacity = 1;
//this.moonlight.visible = true;
}
else
{
if(gWorld.hour <= 0.5) gWorld.hour += 24;
// night time
this.moonMaterial.opacity = this.starMaterial.opacity = 24.5 - gWorld.hour;
//this.moonlight.visible = false;
}
}
}
class Player
{
model = null;
mixer = null;
animations = {};
currentPose = STANDING;
static clock = new THREE.Clock();
constructor() {
this.model = SkeletonUtils.clone(gSoldier.scene);
console.log("creating player");
this.mixer = new THREE.AnimationMixer(this.model);
for(const animation of gSoldier.animations) {
this.animations[animation.name] = this.mixer.clipAction(animation);
}
this.animations[this.currentPose].play();
console.log("loaded a soldier");
gScene.add(this.model);
this.mixer.update(Player.clock.getDelta());
this.model.position.set(20, 2, 0);
}
animate(d) {
this.model.position.copy(d.position);
this.model.quaternion.fromArray(d.quaternion);
if(d.pose != this.currentPose) {
this.animations[this.currentPose].fadeOut(FADE_DURATION);
this.animations[d.pose].reset().fadeIn(FADE_DURATION).play();
this.currentPose = d.pose;
}
this.mixer.update(Player.clock.getDelta());
}
animate2(vector, distance) {
this.model.position.addScaledVector(vector, distance);
// set model speed
let pose;
if(this.model.position.y > 4) pose = JUMPING;
else if(Math.abs(distance) < 0.01) pose = STANDING;
else if(Math.abs(distance) > 1) pose = RUNNING;
else pose = WALKING;
if(pose != this.currentPose) {
this.animations[this.currentPose].fadeOut(FADE_DURATION);
this.animations[pose].reset().fadeIn(FADE_DURATION).play();
this.currentPose = pose;
}
this.mixer.update(Player.clock.getDelta());
}
}
function startUp() {
if(gInitialised || !gGLTF || !AB.socket || !gSoldier) return;
gInitialised = true;
if(gWorldId == 0) {
//create a new world since we have not received one
gWorldId = Math.random();
console.log("creating a new world", gWorldId);
} else {
console.log("joining an existing new world", gWorldId);
}
// this will create and identical world based on the same seed
THREE.MathUtils.seededRandom(gWorldId);
gPlayerId = Math.random();
let w = new World(gGLTF);
w.animate();
}
// https://stackoverflow.com/questions/7307983/while-variable-is-not-defined-wait
// This nifty piece of code waits for the socket to be available
// I couldn't find a event handler for this
AB.socketStart();
AB.runReady = true;
Object.defineProperty(AB, "socket", {
configurable: true,
set(v) {
Object.defineProperty(AB, "socket", { value: v });
console.log("Socket has started");
setTimeout(startUp, 2000);
}
});
gGltfLoader.load('/uploads/mjwalsh/ccity_building_set_1.glb', (gltf) => {
gGLTF = gltf;
gGLTF.scene.traverse((object) => {
if (object.isMesh) object.castShadow = true;
});
gGLTF.scene.scale.set(0.01, 0.01, 0.01);
setTimeout(startUp, 2000);
}, null, null);
gGltfLoader.load('/uploads/mjwalsh/Soldier.glb', (gltf) => {
gSoldier = gltf;
gSoldier.scene.traverse((object) => {
if (object.isMesh) object.castShadow = true;
});
gSoldier.scene.scale.set(1.75, 1.75, 1.75);
setTimeout(startUp, 2000);
}, null, null);
AB.socketIn = function(data)
{
if (!AB.runReady) return;
if(gWorldId == 0) gWorldId = data.worldId;
switch(data.action) {
case 'player_pos':
if(gWorldId == data.worldId)
gPlayers[data.player] = data;
break;
}
};