multiplayer start

This commit is contained in:
Evert Prants 2023-06-25 14:51:16 +03:00
parent 7aaf62bdc5
commit c4c25f554c
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
20 changed files with 441 additions and 22 deletions

View File

@ -18,7 +18,7 @@ const resize = () =>
onMounted(() => {
editorRef.value.mount(wrapperRef.value);
// TODO: for dev
editorRef.value.loadLevel('https://lunasqu.ee/freeblox/test-level.json');
// editorRef.value.loadLevel('https://lunasqu.ee/freeblox/test-level.json');
window.addEventListener('resize', resize);
});

View File

@ -4,10 +4,15 @@ import {
instanceCharacterObject,
getCharacterController,
Humanoid,
GameSocket,
EventEmitter,
Renderer,
randomUUID,
} from '@freeblox/engine';
import { Vector3 } from 'three';
import { Quaternion, Vector3 } from 'three';
import { ThirdPersonCamera } from './camera';
import { GameEvents, SpawnEvent } from '../types/events';
/**
* Gameplay manager.
@ -32,10 +37,22 @@ export class GameplayComponent extends EngineComponent {
private move = new Vector3();
private look = new Vector3();
private server = new GameSocket(this.events);
public uuid = randomUUID();
constructor(
protected renderer: Renderer,
protected events: EventEmitter<GameEvents>
) {
super(renderer, events);
}
override initialize(): void {
this.world = this.renderer.scene.getObjectByName('World') as World;
this.cleanUpEvents = this.bindEvents();
this.server.track(this.world);
this.server.connect('ws://localhost:8256', this.uuid, 'testing');
}
override update(delta: number): void {
@ -55,9 +72,14 @@ export class GameplayComponent extends EngineComponent {
this.character?.localToWorld(this.look);
this.character?.setVelocity(this.move);
const look = this.move.clone().normalize();
if (this.move.length()) {
this.character?.setLook(this.move.clone().normalize());
this.character?.setLook(look);
}
this.events.emit('sendPlayer', {
velocity: this.move,
lookAt: look,
});
if (this.jump) {
this.jump = false;
this.character?.jump();
@ -68,12 +90,22 @@ export class GameplayComponent extends EngineComponent {
this.cleanUpEvents?.();
}
public async loadCharacter(name: string, uuid?: string) {
const char = await instanceCharacterObject(name);
if (uuid) char.uuid = uuid;
public async loadCharacter(
name: string,
pos?: Vector3,
rot?: Quaternion,
uuid?: string
) {
const char = await instanceCharacterObject(name, pos, uuid);
const ctrl = getCharacterController(char);
this.world.add(char);
this.characters.push(ctrl);
if (rot) char.quaternion.copy(rot);
this.events.emit('sceneJoin', char);
if (uuid !== this.uuid) return;
this.character = ctrl;
this.controls = new ThirdPersonCamera(
@ -82,7 +114,6 @@ export class GameplayComponent extends EngineComponent {
this.renderer.renderer.domElement
);
this.controls.initialize();
this.events.emit('sceneJoin', char);
}
private bindEvents() {
@ -122,8 +153,13 @@ export class GameplayComponent extends EngineComponent {
}
};
const worldLoadedEvent = () => {
this.loadCharacter('Diamond');
const createCharacterEvent = (event: SpawnEvent) => {
this.loadCharacter(
event.playerName,
event.position,
event.rotation,
event.playerId
);
};
const physicsLoadedEvent = () => {
@ -132,12 +168,12 @@ export class GameplayComponent extends EngineComponent {
window.addEventListener('keydown', keyDownEvent);
window.addEventListener('keyup', keyUpEvent);
this.events.addListener('loadComplete', worldLoadedEvent);
this.events.addListener('spawnCharacter', createCharacterEvent);
this.events.addListener('physicsComplete', physicsLoadedEvent);
return () => {
window.removeEventListener('keydown', keyDownEvent);
window.removeEventListener('keyup', keyUpEvent);
this.events.removeEventListener('loadComplete', worldLoadedEvent);
this.events.removeEventListener('spawnCharacter', createCharacterEvent);
this.events.removeEventListener('physicsComplete', physicsLoadedEvent);
};
}

View File

@ -1,4 +1,5 @@
import { EngineEvents } from '@freeblox/engine';
import { Vector3 } from 'three';
export type Events = {};

View File

@ -159,6 +159,7 @@ const explorerOperation = (
};
register('initialized', () => createSceneMap());
register('loadComplete', () => createSceneMap());
register('sceneJoin', () => createSceneMap());
register('sceneLeave', () => createSceneMap());
register('selected', (event) => updateSelectionMap(event));

View File

@ -10,6 +10,12 @@
"dev": "tsc --watch",
"prepare": "npm run build"
},
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"keywords": [
"game",
"engine",
@ -19,11 +25,15 @@
"license": "MIT",
"devDependencies": {
"@types/three": "^0.152.1",
"@types/uuid": "^9.0.2",
"typescript": "^5.0.4"
},
"dependencies": {
"@dimforge/rapier3d": "^0.11.2",
"buffer": "^6.0.3",
"reflect-metadata": "^0.1.13",
"three": "^0.153.0"
"smart-buffer": "^4.2.0",
"three": "^0.153.0",
"uuid": "^9.0.0"
}
}

View File

@ -161,8 +161,18 @@ export class LevelComponent extends EngineComponent {
});
};
const instanceEvent = (event: InstanceEvent) =>
this.createObject(event.type, event.parent);
const instanceEvent = (event: InstanceEvent) => {
const parent =
typeof event.parent === 'string'
? this.world.getObjectByProperty('uuid', event.parent)
: event.parent;
const object = this.createObject(event.type, parent, !!event.data);
if (event.data && object) {
object.deserialize(event.data);
this.events.emit('sceneJoin', object);
}
};
const resetEvent = () => {
this.world.clear();

View File

@ -6,6 +6,7 @@ import { Humanoid } from '../gameobjects/humanoid.object';
import { PhysicsObject } from '../gameobjects/physics.object';
import { GameObject } from '../types/game-object';
import type Rapier from '@dimforge/rapier3d';
import { ServerTransformEvent } from '..';
export class PhysicsWorldComponent extends EngineComponent {
public name = PhysicsWorldComponent.name;
@ -58,14 +59,28 @@ export class PhysicsWorldComponent extends EngineComponent {
this.removePhysics(object);
};
const serverTransformEvent = (event: ServerTransformEvent) => {
const object = this.trackedObjects.find(
(obj) => event.object === obj.uuid
) as PhysicsObject;
if (!object) return;
object.rigidBody?.setTranslation(event.position, false);
object.rigidBody?.setRotation(event.quaternion, false);
event.velocity && object.rigidBody?.setLinvel(event.velocity, false);
event.angularVelocity &&
object.rigidBody?.setAngvel(event.angularVelocity, false);
};
this.events.addListener('loadComplete', worldLoadEvent);
this.events.addListener('sceneJoin', sceneJoinEvent);
this.events.addListener('sceneLeave', sceneLeaveEvent);
this.events.addListener('serverTransform', serverTransformEvent);
return () => {
this.events.removeEventListener('loadComplete', worldLoadEvent);
this.events.removeEventListener('sceneJoin', sceneJoinEvent);
this.events.removeEventListener('sceneLeave', sceneLeaveEvent);
this.events.removeEventListener('serverTransform', serverTransformEvent);
};
}

View File

@ -10,7 +10,7 @@ export class PhysicsObject extends PhysicalObject implements PhysicsTicking {
isTickingObject = true;
protected collider?: Rapier.Collider;
protected rigidBody?: Rapier.RigidBody;
public rigidBody?: Rapier.RigidBody;
protected physicsWorldRef?: Rapier.World;
@EditorProperty({ type: Boolean })

View File

@ -1,4 +1,9 @@
import { Buffer as BufferPolyfill } from 'buffer';
declare var Buffer: typeof BufferPolyfill;
globalThis.Buffer = BufferPolyfill;
export * from './core';
export * from './net';
export * from './utils';
export * from './types';
export * from './components';

View File

@ -0,0 +1,4 @@
export enum ChatType {
MESSAGE = 0,
COMMAND,
}

View File

@ -0,0 +1,3 @@
export enum ErrorType {
AUTH_FAIL = 0,
}

View File

@ -0,0 +1,5 @@
export * from './packet-type.enum';
export * from './chat-type.enum';
export * from './error-type.enum';
export * from './packet';
export * from './socket';

View File

@ -0,0 +1,19 @@
export enum PacketType {
AUTH = 0,
KEEPALIVE,
STREAM_START,
STREAM_ASSET,
STREAM_OBJECT,
STREAM_DESTROY,
STREAM_EVENT,
STREAM_FINISH,
STREAM_TRANSFORM,
STREAM_CHAT,
PLAYER_LIST,
PLAYER_JOIN,
PLAYER_QUIT,
PLAYER_CHARACTER,
PLAYER_MOVEMENT,
PLAYER_CHAT,
ERROR,
}

View File

@ -0,0 +1,97 @@
import { PacketType } from './packet-type.enum';
import { SmartBuffer } from 'smart-buffer';
import { Instancable } from '../types/instancable';
import { Quaternion, Vector3 } from 'three';
export class Packet {
private buffer = new SmartBuffer();
constructor(public packet?: PacketType) {}
write(data: any, type: Instancable<any> | string) {
switch (type) {
case 'string':
case String:
this.buffer.writeStringNT(data);
break;
case 'bool':
case Boolean:
this.buffer.writeUInt8(data ? 1 : 0);
break;
case 'float':
case Number:
this.buffer.writeFloatLE(data);
break;
case 'uint8':
this.buffer.writeUInt8(data);
break;
case 'int32':
this.buffer.writeInt32LE(data);
break;
case 'uint32':
this.buffer.writeUInt32LE(data);
break;
case 'vec3':
this.buffer.writeFloatLE(data.x);
this.buffer.writeFloatLE(data.y);
this.buffer.writeFloatLE(data.z);
break;
case 'quat':
this.buffer.writeFloatLE(data.x);
this.buffer.writeFloatLE(data.y);
this.buffer.writeFloatLE(data.z);
this.buffer.writeFloatLE(data.w);
break;
}
return this;
}
read<T = string>(type: Instancable<any> | string) {
switch (type) {
case 'string':
case String:
return this.buffer.readStringNT() as T;
case 'bool':
case Boolean:
return this.buffer.readUInt8() as T;
case 'float':
case Number:
return this.buffer.readFloatLE() as T;
case 'uint8':
return this.buffer.readUInt8() as T;
case 'int32':
return this.buffer.readInt32LE() as T;
case 'uint32':
return this.buffer.readUInt32LE() as T;
case 'vec3':
return new Vector3(
this.buffer.readFloatLE(),
this.buffer.readFloatLE(),
this.buffer.readFloatLE()
) as T;
case 'quat':
return new Quaternion(
this.buffer.readFloatLE(),
this.buffer.readFloatLE(),
this.buffer.readFloatLE(),
this.buffer.readFloatLE()
) as T;
}
}
writeHeader() {
if (this.packet === undefined) return;
this.buffer.insertUInt8(this.packet, 0);
}
toBuffer() {
this.writeHeader();
return this.buffer.toBuffer();
}
static from(buffer: Buffer) {
const packet = new Packet();
packet.buffer = SmartBuffer.fromBuffer(buffer);
packet.packet = packet.buffer.readUInt8();
return packet;
}
}

View File

@ -0,0 +1,155 @@
import { Quaternion, Vector3 } from 'three';
import { Packet, PacketType } from '.';
import { World } from '../gameobjects/world.object';
import { Disposable } from '../types/disposable';
import { EngineEvents, PlayerEvent } from '../types/events';
import { EventEmitter } from '../utils/events';
export class GameSocket implements Disposable {
private ws?: WebSocket;
private host?: string;
private token!: string;
private playerId!: string;
private world!: World;
private cleanUpEvents?: () => void;
constructor(private events: EventEmitter<EngineEvents>) {}
track(world: World) {
this.world = world;
}
connect(host: string, id: string, token: string) {
this.host = host;
this.playerId = id;
this.token = token;
this.ws = new WebSocket(host);
this.ws.binaryType = 'arraybuffer';
this.bindSocket(this.ws!);
console.log('connected to', this.host);
this.cleanUpEvents = this.bindEvents();
}
disconnect() {
this.ws?.close();
this.events.emit('serverDisconnect');
console.log('disconnected from', this.host);
this.cleanUpEvents?.();
}
dispose(): void {
this.cleanUpEvents?.();
}
private onOpen() {
const packet = new Packet(PacketType.AUTH)
.write(this.playerId, String)
.write(this.token, String)
.write(0, 'uint8')
.toBuffer();
this.ws?.send(packet);
}
private handlePacket(incoming: Packet) {
switch (incoming.packet) {
case PacketType.ERROR: {
const error = incoming.read(String);
alert(error);
break;
}
case PacketType.STREAM_START:
console.log('starting world loading');
break;
case PacketType.STREAM_OBJECT: {
const objectUUID = incoming.read(String);
const parentUUID = incoming.read(String);
const objectType = incoming.read(String);
const objectData = JSON.parse(incoming.read(String) as string);
this.events.emit('instance', {
type: objectType as string,
parent: parentUUID,
data: objectData,
});
break;
}
case PacketType.STREAM_TRANSFORM: {
const objectUUID = incoming.read(String)!;
const objectPos = incoming.read<Vector3>('vec3')!;
const objectQuat = incoming.read<Quaternion>('quat')!;
const objectLinvel = incoming.read<Vector3>('vec3')!;
const objectAngvel = incoming.read<Vector3>('vec3')!;
this.events.emit('serverTransform', {
object: objectUUID,
position: objectPos,
quaternion: objectQuat,
velocity: objectLinvel,
angularVelocity: objectAngvel,
});
break;
}
case PacketType.STREAM_FINISH: {
this.events.emit('loadComplete');
break;
}
case PacketType.PLAYER_LIST: {
const playerCount = incoming.read<number>('uint32')!;
const players = Array.from({ length: playerCount }, () => null).map(
() => incoming.read(String)?.split(':')
);
console.log('player list', players);
break;
}
case PacketType.PLAYER_JOIN: {
const playerId = incoming.read(String)!;
const playerName = incoming.read(String)!;
console.log('player joined', playerId, playerName);
break;
}
case PacketType.PLAYER_CHARACTER: {
const playerId = incoming.read(String)!;
const playerName = incoming.read(String)!;
const position = incoming.read<Vector3>('vec3')!;
const rotation = incoming.read<Quaternion>('quat')!;
this.events.emit('spawnCharacter', {
playerId,
playerName,
position,
rotation,
});
}
}
}
private bindSocket(ws: WebSocket) {
ws.addEventListener('message', (event) => {
const packet = Packet.from(Buffer.from(event.data));
this.handlePacket(packet);
});
ws.addEventListener('close', (event) => {
this.disconnect();
});
ws.addEventListener('error', (event) => {});
ws.addEventListener('open', (event) => {
this.onOpen();
});
}
private bindEvents() {
const sendPlayerEvent = (event: PlayerEvent) => {
if (this.ws?.readyState !== WebSocket.OPEN) return;
this.ws.send(
new Packet(PacketType.PLAYER_MOVEMENT)
.write(event.velocity, 'vec3')
.write(event.lookAt, 'vec3')
.toBuffer()
);
};
this.events.on('sendPlayer', sendPlayerEvent);
return () => {
this.events.removeEventListener('sendPlayer', sendPlayerEvent);
};
}
}

View File

@ -4,7 +4,9 @@ import {
Object3D,
ColorRepresentation,
Vector3,
Quaternion,
} from 'three';
import { SerializedObject } from './game-object';
export interface MousePositionEvent {
position: Vector2;
@ -63,7 +65,28 @@ export interface ReparentEvent {
export interface InstanceEvent {
type: string;
parent?: Object3D;
parent?: Object3D | string;
data?: SerializedObject;
}
export interface ServerTransformEvent {
object: string;
position: Vector3;
quaternion: Quaternion;
velocity?: Vector3;
angularVelocity?: Vector3;
}
export interface PlayerEvent {
velocity: Vector3;
lookAt: Vector3;
}
export interface SpawnEvent {
playerId: string;
playerName: string;
position: Vector3;
rotation: Quaternion;
}
export type EngineEvents = {
@ -84,4 +107,10 @@ export type EngineEvents = {
physicsComplete: () => void;
initialized: () => void;
reset: () => void;
spawnCharacter: (event: SpawnEvent) => void;
sendPlayer: (event: PlayerEvent) => void;
serverConnect: () => void;
serverTransform: (obj: ServerTransformEvent) => void;
serverDisconnect: () => void;
};

View File

@ -1,4 +1,4 @@
import { AnimationClip, Bone, Object3D, SkinnedMesh } from 'three';
import { AnimationClip, Bone, Object3D, SkinnedMesh, Vector3 } from 'three';
import { assetManager } from '../assets';
import { Group } from '../gameobjects/group.object';
import { MeshPart } from '../gameobjects/mesh.object';
@ -36,7 +36,11 @@ export const loadBaseCharacter = async () => {
return cachedMeta;
};
export const instanceCharacterObject = async (name: string) => {
export const instanceCharacterObject = async (
name: string,
pos = new Vector3(0, 1, 0),
uuid?: string
) => {
const base = await loadBaseCharacter();
const cloned = SkeletonUtils.clone(base.root!);
@ -79,8 +83,9 @@ export const instanceCharacterObject = async (name: string) => {
const controller = new Humanoid();
controller.position.set(0, 4.75, 0);
controller.archivable = false;
if (uuid) controller.uuid = uuid;
baseObject.add(controller);
baseObject.position.set(0, 1, 0);
baseObject.position.copy(pos);
return baseObject;
};

View File

@ -2,3 +2,4 @@ export * from './debounce';
export * from './events';
export * from './read-metadata';
export * from './character';
export * from './random';

View File

@ -0,0 +1,3 @@
import { v4 } from 'uuid';
export const randomUUID = () => v4();

View File

@ -169,16 +169,28 @@ importers:
'@dimforge/rapier3d':
specifier: ^0.11.2
version: 0.11.2
buffer:
specifier: ^6.0.3
version: 6.0.3
reflect-metadata:
specifier: ^0.1.13
version: 0.1.13
smart-buffer:
specifier: ^4.2.0
version: 4.2.0
three:
specifier: ^0.153.0
version: 0.153.0
uuid:
specifier: ^9.0.0
version: 9.0.0
devDependencies:
'@types/three':
specifier: ^0.152.1
version: 0.152.1
'@types/uuid':
specifier: ^9.0.2
version: 9.0.2
typescript:
specifier: ^5.0.4
version: 5.0.4
@ -1633,6 +1645,10 @@ packages:
lil-gui: 0.17.0
dev: true
/@types/uuid@9.0.2:
resolution: {integrity: sha512-kNnC1GFBLuhImSnV7w4njQkUiJi0ZXUycu1rUaouPqiKlXkh77JKgdRnTAp1x5eBwcIwbtI+3otwzuIDEuDoxQ==}
dev: true
/@types/webxr@0.5.2:
resolution: {integrity: sha512-szL74BnIcok9m7QwYtVmQ+EdIKwbjPANudfuvDrAF8Cljg9MKUlIoc1w5tjj9PMpeSH3U1Xnx//czQybJ0EfSw==}
dev: true
@ -2233,7 +2249,6 @@ packages:
/base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
dev: true
/big-integer@1.6.51:
resolution: {integrity: sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==}
@ -2353,6 +2368,13 @@ packages:
ieee754: 1.2.1
dev: true
/buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
dev: false
/builtin-modules@3.3.0:
resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==}
engines: {node: '>=6'}
@ -3814,7 +3836,6 @@ packages:
/ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
dev: true
/ignore-walk@6.0.3:
resolution: {integrity: sha512-C7FfFoTA+bI10qfeydT8aZbvr91vAEU+2W5BZUlzPec47oNb07SsOfwYrtxuvOYdUApPP/Qlh4DtAO51Ekk2QA==}
@ -6104,7 +6125,6 @@ packages:
/smart-buffer@4.2.0:
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
dev: true
/smob@1.4.0:
resolution: {integrity: sha512-MqR3fVulhjWuRNSMydnTlweu38UhQ0HXM4buStD/S3mc/BzX3CuM9OmhyQpmtYCvoYdl5ris6TI0ZqH355Ymqg==}