initial commit

This commit is contained in:
Evert Prants 2023-06-25 14:52:15 +03:00
commit 84309feb0c
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
60 changed files with 7283 additions and 0 deletions

25
.eslintrc.js Normal file
View File

@ -0,0 +1,25 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

35
.gitignore vendored Normal file
View File

@ -0,0 +1,35 @@
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"editor.formatOnSave": true
}

73
README.md Normal file
View File

@ -0,0 +1,73 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Installation
```bash
$ pnpm install
```
## Running the app
```bash
# development
$ pnpm run start
# watch mode
$ pnpm run start:dev
# production mode
$ pnpm run start:prod
```
## Test
```bash
# unit tests
$ pnpm run test
# e2e tests
$ pnpm run test:e2e
# test coverage
$ pnpm run test:cov
```
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](LICENSE).

8
nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

82
package.json Normal file
View File

@ -0,0 +1,82 @@
{
"name": "server",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@dimforge/rapier3d-compat": "^0.11.2",
"@nestjs/axios": "^3.0.0",
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/platform-ws": "^10.0.3",
"@nestjs/websockets": "^10.0.3",
"axios": "^1.4.0",
"rxjs": "^7.8.1",
"smart-buffer": "^4.2.0",
"three": "^0.153.0",
"uuid": "^9.0.0",
"ws": "^8.13.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/config": "^3.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/supertest": "^2.0.12",
"@types/three": "^0.152.1",
"@types/uuid": "^9.0.2",
"@types/ws": "^8.5.5",
"@typescript-eslint/eslint-plugin": "^5.59.11",
"@typescript-eslint/parser": "^5.59.11",
"eslint": "^8.42.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"jest": "^29.5.0",
"prettier": "^2.8.8",
"reflect-metadata": "^0.1.13",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

5239
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

14
src/app.module.ts Normal file
View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { EventsModule } from './events/events.module';
import { ConfigModule } from '@nestjs/config';
import { world } from './config/world.config';
@Module({
imports: [
ConfigModule.forRoot({
load: [world],
}),
EventsModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,7 @@
import { registerAs } from '@nestjs/config';
export const world = registerAs('world', () => ({
worldAsset: String(
process.env.WORLD_URI || 'https://lunasqu.ee/freeblox/test-level.json',
),
}));

View File

@ -0,0 +1,81 @@
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Packet } from 'src/net/packet';
import { PlayerStoreService } from 'src/player/player-store.service';
import { PlayerSocket } from 'src/types/data-socket';
import { ErrorType } from 'src/types/error-type.enum';
import { PacketType } from 'src/types/packet-type.enum';
import { getRandomId } from 'src/utils/random';
import { WorldService } from 'src/world/world.service';
import { Server } from 'ws';
@WebSocketGateway(8256, {
cors: {
origin: '*',
},
})
export class EventsGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
constructor(
private readonly players: PlayerStoreService,
private readonly world: WorldService,
) {}
@WebSocketServer()
server: Server;
@SubscribeMessage('message')
async onEvent(
@ConnectedSocket() client: PlayerSocket,
@MessageBody() data: Packet,
) {
let player = this.players.getByClient(client);
if (!player && data.packet !== PacketType.AUTH) {
return client.close(
1007,
new Packet(PacketType.ERROR)
.write(ErrorType.AUTH_FAIL, 'uint8')
.toBuffer(),
);
}
if (data.packet === PacketType.AUTH) {
try {
player = await this.players.authenticate(client, data);
} catch {
return client.close(
1007,
new Packet(PacketType.ERROR)
.write(ErrorType.AUTH_FAIL, 'uint8')
.toBuffer(),
);
}
await this.world.initializePlayer(player);
}
if (player.handlePlayerPacket(data)) return false;
return false;
}
afterInit(server: Server) {
console.log('server init');
}
handleConnection(client: PlayerSocket, ...args: any[]) {
client.id = getRandomId();
}
handleDisconnect(client: PlayerSocket) {
this.players.disconnect(client);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { EventsGateway } from './events.gateway';
import { PlayerModule } from 'src/player/player.module';
import { WorldModule } from 'src/world/world.module';
@Module({
imports: [PlayerModule, WorldModule],
providers: [EventsGateway],
})
export class EventsModule {}

22
src/game/brick.object.ts Normal file
View File

@ -0,0 +1,22 @@
import { PhysicsObject } from './physics.object';
import type Rapier from '@dimforge/rapier3d-compat';
import { Vector3 } from 'three';
export class Brick extends PhysicsObject {
public objectType = 'Brick';
public name = 'Brick';
protected override createCollider(
factory: typeof Rapier,
world: Rapier.World,
body?: Rapier.RigidBody,
) {
const scale = this.getWorldScale(new Vector3()).divideScalar(2);
const collider = factory.ColliderDesc.cuboid(
Math.abs(scale.x),
Math.abs(scale.y),
Math.abs(scale.z),
);
return world.createCollider(collider, body);
}
}

View File

@ -0,0 +1,20 @@
import { Vector3 } from 'three';
import { Brick } from './brick.object';
import type Rapier from '@dimforge/rapier3d-compat';
export class Capsule extends Brick {
public objectType = 'Capsule';
public name = this.objectType;
protected override createCollider(
factory: typeof Rapier,
world: Rapier.World,
body?: Rapier.RigidBody,
) {
const scale = this.getWorldScale(new Vector3());
const height = Math.abs(scale.y) / 2;
const radius = (Math.abs(scale.x) + Math.abs(scale.z)) / 2;
const collider = factory.ColliderDesc.capsule(height, radius);
return world.createCollider(collider, body);
}
}

View File

@ -0,0 +1,19 @@
import { Vector3 } from 'three';
import { Brick } from './brick.object';
import type Rapier from '@dimforge/rapier3d-compat';
export class Cylinder extends Brick {
public objectType = 'Cylinder';
public name = this.objectType;
protected override createCollider(
factory: typeof Rapier,
world: Rapier.World,
body?: Rapier.RigidBody,
) {
const scale = this.getWorldScale(new Vector3());
const height = Math.abs(scale.y) / 2;
const radius = (Math.abs(scale.x) / 2 + Math.abs(scale.z) / 2) / 2;
const collider = factory.ColliderDesc.cylinder(height, radius);
return world.createCollider(collider, body);
}
}

View File

@ -0,0 +1,48 @@
import {
ObjectPropertyExclude,
ObjectProperty,
} from 'src/types/property.decorator';
import { SerializedObject } from 'src/types/serialized';
import { Color, Vector3 } from 'three';
import { GameObject } from './game-object';
export class Environment extends GameObject {
public objectType = 'Environment';
@ObjectPropertyExclude()
public name = 'Environment';
public virtual = true;
@ObjectPropertyExclude()
public override visible!: boolean;
@ObjectProperty()
sunColor!: Color;
@ObjectProperty()
sunPosition!: Vector3;
@ObjectProperty()
sunStrength!: number;
@ObjectProperty()
ambientColor!: Color;
@ObjectProperty()
ambientStrength!: number;
@ObjectProperty()
clearColor!: Color;
override serialize() {
return super.serialize() as SerializedEnvironment;
}
}
export interface SerializedEnvironment extends SerializedObject {
sunColor: Color;
sunPosition: Vector3;
sunStrength: number;
ambientColor: Color;
ambientStrength: number;
clearColor: Color;
}

132
src/game/game-object.ts Normal file
View File

@ -0,0 +1,132 @@
import { ObjectProperty } from 'src/types/property.decorator';
import { SerializedObject } from 'src/types/serialized';
import { readMetadataOf } from 'src/utils/read-metadata';
import { Color, Euler, Object3D, Vector3 } from 'three';
export class GameObject extends Object3D {
public objectType = 'GameObject';
public virtual = false;
@ObjectProperty()
public override uuid!: string;
@ObjectProperty()
public override name = '';
@ObjectProperty()
public override visible = true;
@ObjectProperty()
public archivable = true;
constructor() {
super();
this.name = this.objectType;
}
private get excludedProperties() {
return readMetadataOf<string>(this, 'excludedProperties');
}
/**
* The exposed properties for this game object, used for the editor
*/
get properties() {
const exclude = this.excludedProperties;
const properties = readMetadataOf<string>(this, 'properties');
return properties.filter((item) => !exclude.includes(item));
}
/**
* Serialize GameObject for exporting
*/
serialize(skipChildren = false) {
const object: SerializedObject = {
name: this.name,
objectType: this.objectType,
children: !skipChildren
? this.children
.filter(
(entry) =>
entry instanceof GameObject && entry.archivable !== false,
)
.map((entry) => (entry as GameObject).serialize())
: undefined,
visible: this.visible,
};
const keys = this.properties.map((property) => property);
Object.assign(
object,
keys.reduce<{ [x: string]: unknown }>((obj, key) => {
const indexable = this as Record<string, unknown>;
const value = (indexable[key] as any)?.toArray
? (indexable[key] as any).toArray()
: indexable[key];
return {
...obj,
[key]: value,
};
}, {}),
);
return object;
}
override copy(object: Object3D, recursive = true) {
super.copy(object as any, recursive);
this.properties
.map((property) => property)
.filter((key) => !['position', 'rotation', 'scale', 'uuid'].includes(key))
.forEach((key) => this.setOwnProperty(key, (object as any)[key]));
return this;
}
/**
* This function can be used to modify any property of the object by key.
* @param key Key to set
* @param value Value to set
*/
public setOwnProperty(key: keyof GameObject, value: unknown): void;
public setOwnProperty(key: string, value: unknown): void;
public setOwnProperty(key: string, value: unknown) {
const indexable = this as any;
if (indexable[key]?.fromArray && Array.isArray(value)) {
indexable[key].fromArray(value);
} else if (indexable[key]?.isColor) {
indexable[key] = new Color(value as string);
} else if (indexable[key]?.copy) {
indexable[key].copy(value);
} else {
indexable[key] = value;
}
}
/**
* Deserialize a serialized object into properties on this object.
* @param input Serialized information
*/
deserialize(input: SerializedObject) {
Object.keys(input)
.filter((key) => !['children', 'objectType'].includes(key))
.forEach((key) => this.setOwnProperty(key, input[key]));
}
}
export class GameObject3D extends GameObject {
public objectType = 'GameObject3D';
@ObjectProperty()
public locked = false;
@ObjectProperty()
public override position!: Vector3;
@ObjectProperty()
public override scale!: Vector3;
@ObjectProperty()
public override rotation!: Euler;
}

43
src/game/geometries.ts Normal file
View File

@ -0,0 +1,43 @@
import { BoxGeometry } from 'three';
export class WedgeGeometry extends BoxGeometry {
type = 'WedgeGeometry';
constructor() {
super();
const pos = this.attributes.position;
for (let i = 0; i < pos.count; i++) {
if (pos.getX(i) < 0 && pos.getY(i) > 0) pos.setY(i, -0.5);
}
this.computeVertexNormals();
}
}
export class WedgeCornerGeometry extends BoxGeometry {
type = 'WedgeCornerGeometry';
constructor() {
super();
const pos = this.attributes.position;
for (let i = 0; i < pos.count; i++) {
if (pos.getY(i) > 0 && (pos.getX(i) !== 0.5 || pos.getZ(i) !== -0.5)) {
pos.setY(i, -0.5);
}
}
this.computeVertexNormals();
}
}
export class WedgeInnerCornerGeometry extends BoxGeometry {
type = 'WedgeInnerCornerGeometry';
constructor() {
super();
const pos = this.attributes.position;
for (let i = 0; i < pos.count; i++) {
if (pos.getY(i) > 0 && pos.getX(i) === 0.5 && pos.getZ(i) === 0.5) {
pos.setY(i, -0.5);
}
}
this.computeVertexNormals();
}
}

6
src/game/group.object.ts Normal file
View File

@ -0,0 +1,6 @@
import { GameObject3D } from './game-object';
export class Group extends GameObject3D {
public objectType = 'Group';
public name = 'Group';
}

198
src/game/humanoid.object.ts Normal file
View File

@ -0,0 +1,198 @@
import { Quaternion, Vector3 } from 'three';
import type Rapier from '@dimforge/rapier3d-compat';
import { PhysicsTicking } from 'src/physics';
import { GameObject } from './game-object';
import { ObjectProperty } from 'src/types/property.decorator';
import { clamp } from 'src/utils/clamp';
export class Humanoid extends GameObject implements PhysicsTicking {
public isTickingObject = true;
public objectType = 'Humanoid';
public name = 'Humanoid';
protected ready = false;
public characterHeight = 5.5;
public characterHalfHeight = this.characterHeight / 2;
protected shouldJump = false;
private _health = 100;
private _maxHealth = 100;
private _velocity = new Vector3(0, 0, 0);
private _appliedGravity = new Vector3(0, 0, 0);
private _grounded = true;
private _lookAt = new Vector3(0, 0, 1);
private _currentLookAt = new Vector3(0, 0, 1);
private _animState = 0;
protected collider?: Rapier.Collider;
protected rigidBody?: Rapier.RigidBody;
protected physicsWorldRef?: Rapier.World;
protected characterControllerRef?: Rapier.KinematicCharacterController;
@ObjectProperty()
public mass = -8;
@ObjectProperty()
public jumpPower = 8;
@ObjectProperty()
get health() {
return this._health;
}
set health(value: number) {
const health = clamp(Math.floor(value), 0, this.maxHealth);
if (health === 0) this.die();
this.health = health;
}
@ObjectProperty()
get maxHealth() {
return this._maxHealth;
}
set maxHealth(value: number) {
const maxHealth = Math.floor(value);
if (this.health > maxHealth) {
this.health = maxHealth;
}
this._maxHealth = maxHealth;
}
get alive() {
return this.health > 0;
}
get grounded() {
return this._grounded;
}
set grounded(value: boolean) {
this._grounded = value;
}
private get weight() {
return (this.physicsWorldRef?.gravity.y || -9.81) + this.mass;
}
initialize(
physicsEngine: typeof Rapier,
physicsWorld: Rapier.World,
characterController: Rapier.KinematicCharacterController,
): void {
if (!this.parent)
throw new Error('Cannot initialize Humanoid to empty parent');
this.ready = true;
this.physicsWorldRef = physicsWorld;
this.characterControllerRef = characterController;
// Character Physics
const halfVec = new Vector3(0, this.characterHalfHeight, 0);
const colliderDesc = physicsEngine.ColliderDesc.cuboid(
1,
this.characterHalfHeight,
0.5,
);
const rigidBodyDesc = physicsEngine.RigidBodyDesc.kinematicPositionBased()
.setTranslation(...this.parent!.position.toArray())
.setRotation(this.parent!.quaternion);
const rigidBody = physicsWorld.createRigidBody(rigidBodyDesc);
const collider = physicsWorld.createCollider(colliderDesc, rigidBody);
collider.setTranslationWrtParent(halfVec);
this.rigidBody = rigidBody;
this.collider = collider;
}
setVelocity(velocity: Vector3) {
this._velocity.copy(velocity);
}
setLook(vector: Vector3) {
this._lookAt.lerp(vector, 0.15);
}
jump() {
if (!this.grounded || this.shouldJump) return;
this.shouldJump = true;
}
tick(dt: number): void {
if (!this.ready) return;
// Apply rigidbody transforms to object from last process tick
if (this.rigidBody) {
this.parent!.position.copy(this.rigidBody.translation() as any);
this.parent!.quaternion.copy(this.rigidBody.rotation() as any);
}
// Apply jumping
if (this.shouldJump) {
this._appliedGravity.y = Math.sqrt(-2 * this.weight * this.jumpPower);
this.grounded = false;
this.shouldJump = false;
}
// Apply gravity
this._appliedGravity.y += this.weight * dt;
// Apply velocity
this.applyVelocity(
this._velocity.clone().add(this._appliedGravity).multiplyScalar(dt),
);
// Apply look vector
this._currentLookAt.copy(this.parent!.position);
this._currentLookAt.add(this._lookAt);
this.parent?.lookAt(this._currentLookAt);
this.applyRotation(this.parent!.quaternion);
// Stick to ground
if (this.grounded) {
this._appliedGravity.y = 0;
}
}
die() {
if (!this.alive) return;
this.health = 0;
}
private applyVelocity(velocity: Vector3) {
if (!this.characterControllerRef || !this.parent || !this.rigidBody) return;
const vec3 = this.parent.position.clone();
this.characterControllerRef.computeColliderMovement(
this.collider!,
velocity,
);
const computed = this.characterControllerRef.computedMovement();
const grounded = this.characterControllerRef.computedGrounded();
vec3.copy(computed as Vector3);
this.rigidBody?.setNextKinematicTranslation(vec3.add(this.parent.position));
// After the collider movement calculation is done, we can read the
// collision events.
// for (let i = 0; i < this.controller.numComputedCollisions(); i++) {
// let collision = this.controller.computedCollision(i);
// // Do something with that collision information.
// console.log(collision);
// }
this._grounded = grounded;
}
private applyRotation(quat: Quaternion) {
this.rigidBody?.setRotation(quat, false);
}
dispose(): void {
if (this.collider && !this.rigidBody) {
this.physicsWorldRef?.removeCollider(this.collider, false);
}
if (this.rigidBody) {
this.physicsWorldRef?.removeRigidBody(this.rigidBody);
}
}
}

43
src/game/index.ts Normal file
View File

@ -0,0 +1,43 @@
import { Cylinder } from './cylinder.object';
import { Brick } from './brick.object';
import { Sphere } from './sphere.object';
import { Wedge } from './wedge.object';
import { WedgeCorner } from './wedge-corner.object';
import { WedgeInnerCorner } from './wedge-inner-corner.object';
import { Instancable } from '../types/instancable';
import { Group } from './group.object';
import { Torus } from './torus.object';
import { Capsule } from './capsule.object';
import { MeshPart } from './mesh.object';
import { Humanoid } from './humanoid.object';
import { GameObject } from './game-object';
export const instancableGameObjects: Record<string, Instancable<GameObject>> = {
['Group']: Group,
['Brick']: Brick,
['Cylinder']: Cylinder,
['Sphere']: Sphere,
['Torus']: Torus,
['Capsule']: Capsule,
['Wedge']: Wedge,
['WedgeCorner']: WedgeCorner,
['WedgeInnerCorner']: WedgeInnerCorner,
};
export * from './environment.object';
export * from './world.object';
export * from './physical.object';
export * from './physics.object';
export {
Group,
Cylinder,
Brick,
Sphere,
Torus,
Capsule,
Wedge,
WedgeCorner,
WedgeInnerCorner,
MeshPart,
Humanoid,
};

21
src/game/mesh.object.ts Normal file
View File

@ -0,0 +1,21 @@
import { Vector3 } from 'three';
import { Brick } from '.';
import type Rapier from '@dimforge/rapier3d-compat';
export class MeshPart extends Brick {
public objectType = 'MeshPart';
protected override createCollider(
factory: typeof Rapier,
world: Rapier.World,
body?: Rapier.RigidBody,
) {
const scale = this.getWorldScale(new Vector3()).divideScalar(2);
const collider = factory.ColliderDesc.cuboid(
Math.abs(scale.x),
Math.abs(scale.y),
Math.abs(scale.z),
);
return world.createCollider(collider, body);
}
}

View File

@ -0,0 +1,25 @@
import { ObjectProperty } from 'src/types/property.decorator';
import { Color } from 'three';
import { GameObject3D } from './game-object';
export class PhysicalObject extends GameObject3D {
private texturePath?: string;
@ObjectProperty()
public color = new Color(0xffffff);
@ObjectProperty()
public transparency = 0;
@ObjectProperty()
get texture() {
return this.texturePath;
}
set texture(path: string | undefined) {
this.texturePath = path;
}
constructor() {
super();
}
}

105
src/game/physics.object.ts Normal file
View File

@ -0,0 +1,105 @@
import { PhysicsTicking } from '../physics/ticking';
import type Rapier from '@dimforge/rapier3d-compat';
import { PhysicalObject } from './physical.object';
import { Quaternion, Vector3 } from 'three';
import { ObjectProperty } from 'src/types/property.decorator';
import { GameObject } from './game-object';
export class PhysicsObject extends PhysicalObject implements PhysicsTicking {
public objectType = 'PhysicsObject';
isTickingObject = true;
protected collider?: Rapier.Collider;
public rigidBody?: Rapier.RigidBody;
protected physicsWorldRef?: Rapier.World;
@ObjectProperty()
public canCollide = true;
@ObjectProperty()
public anchored = true;
@ObjectProperty()
public mass = 1;
@ObjectProperty()
public friction = 0.2;
private _tempVec = new Vector3();
private _tempQuat = new Quaternion();
private _tempQuat2 = new Quaternion();
initialize(physicsEngine: typeof Rapier, physicsWorld: Rapier.World): void {
if (this.virtual) return;
this.physicsWorldRef = physicsWorld;
// This object has effecively no physics:
// doesn't move, doesn't collide
if (this.anchored && !this.canCollide) return;
let bodyDesc: Rapier.RigidBodyDesc;
if (this.anchored) bodyDesc = physicsEngine.RigidBodyDesc.fixed();
else bodyDesc = physicsEngine.RigidBodyDesc.dynamic();
const position = this.position.clone();
const rotation = this.quaternion.clone();
this.getWorldPosition(position);
this.getWorldQuaternion(rotation);
bodyDesc
.setTranslation(...position.toArray())
.setRotation(rotation)
.setAdditionalMass(this.mass)
.setLinearDamping(this.friction)
.setAngularDamping(this.friction);
const body = physicsWorld.createRigidBody(bodyDesc);
let collider: Rapier.Collider | undefined;
if (this.canCollide) {
collider = this.createCollider(physicsEngine, physicsWorld, body);
}
this.collider = collider;
this.rigidBody = body;
}
tick(dt: number): void {
if (!this.rigidBody || this.virtual) return;
this._tempVec.copy(this.rigidBody.translation() as any);
this._tempQuat.copy(this.rigidBody.rotation() as any);
if ((this.parent as GameObject)?.objectType !== 'World') {
this.parent?.worldToLocal(this._tempVec);
this.parent?.getWorldQuaternion(this._tempQuat2);
this._tempQuat2.invert();
this._tempQuat.premultiply(this._tempQuat2);
}
this.position.copy(this._tempVec);
this.quaternion.copy(this._tempQuat);
}
dispose(): void {
if (this.collider && !this.rigidBody) {
this.physicsWorldRef?.removeCollider(this.collider, false);
}
if (this.rigidBody) {
this.physicsWorldRef?.removeRigidBody(this.rigidBody);
}
}
/**
* Create collision shape for physics object.
* @internal
* @param factory Physics engine
* @param world Physics world
* @param body RigidBody
*/
protected createCollider(
factory: typeof Rapier,
world: Rapier.World,
body?: Rapier.RigidBody,
): Rapier.Collider | undefined {
return undefined;
}
}

56
src/game/player.ts Normal file
View File

@ -0,0 +1,56 @@
import { Packet } from 'src/net/packet';
import { PlayerSocket } from 'src/types/data-socket';
import { Humanoid } from './humanoid.object';
import { Group } from './group.object';
import { Vector3 } from 'three';
import { PacketType } from 'src/types/packet-type.enum';
export class Player {
public character: Group;
public controller: Humanoid;
public initialized = false;
constructor(
public socket: PlayerSocket,
public id: string,
public name: string,
private token: string,
) {}
public send(packet: Packet): void;
public send(packet: Buffer): void;
public send(packet: unknown): void {
if (packet instanceof Packet) {
return this.socket.send(packet.toBuffer());
}
this.socket.send(packet as Buffer);
}
public createPlayerCharacter(pos = new Vector3(0, 1, 0)) {
const group = new Group();
group.name = this.name;
group.position.copy(pos);
this.character = group;
const humanoid = new Humanoid();
humanoid.uuid = this.id;
group.add(humanoid);
this.controller = humanoid;
return group;
}
public handlePlayerPacket(packet: Packet) {
if (packet.packet === PacketType.PLAYER_MOVEMENT) {
const velocity = packet.read<Vector3>('vec3');
const lookAt = packet.read<Vector3>('vec3');
if (this.controller) {
this.controller.setVelocity(velocity);
this.controller.setLook(lookAt);
return true;
}
}
return false;
}
}

20
src/game/sphere.object.ts Normal file
View File

@ -0,0 +1,20 @@
import { Vector3 } from 'three';
import { Brick } from './brick.object';
import type Rapier from '@dimforge/rapier3d-compat';
export class Sphere extends Brick {
public objectType = 'Sphere';
public name = this.objectType;
protected override createCollider(
factory: typeof Rapier,
world: Rapier.World,
body?: Rapier.RigidBody,
) {
const scale = this.getWorldScale(new Vector3());
const radius =
Math.max(Math.abs(scale.x), Math.abs(scale.y), Math.abs(scale.z)) / 2;
const collider = factory.ColliderDesc.ball(radius);
return world.createCollider(collider, body);
}
}

25
src/game/torus.object.ts Normal file
View File

@ -0,0 +1,25 @@
import { Quaternion, Vector3 } from 'three';
import { Brick } from './brick.object';
import type Rapier from '@dimforge/rapier3d-compat';
export class Torus extends Brick {
public objectType = 'Torus';
public name = this.objectType;
protected override createCollider(
factory: typeof Rapier,
world: Rapier.World,
body?: Rapier.RigidBody,
) {
const scale = this.getWorldScale(new Vector3());
const height = Math.abs(scale.y) * 0.4;
const radius = (Math.abs(scale.x) + Math.abs(scale.z)) / 2;
const collider = factory.ColliderDesc.cylinder(
height,
radius * 1.4,
).setRotation(
new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), Math.PI / 2),
);
return world.createCollider(collider, body);
}
}

View File

@ -0,0 +1,27 @@
import { Matrix4, Vector3 } from 'three';
import { Brick } from './brick.object';
import type Rapier from '@dimforge/rapier3d-compat';
import { WedgeCornerGeometry } from './geometries';
export class WedgeCorner extends Brick {
public objectType = 'WedgeCorner';
public name = this.objectType;
public static geometry = new WedgeCornerGeometry();
protected override createCollider(
factory: typeof Rapier,
world: Rapier.World,
body?: Rapier.RigidBody,
) {
const scale = this.getWorldScale(new Vector3());
const mat = new Matrix4();
mat.makeScale(...scale.toArray());
const points = WedgeCorner.geometry
.getAttribute('position')
.clone()
.applyMatrix4(mat)?.array as Float32Array;
const collider = factory.ColliderDesc.convexMesh(points)!;
return world.createCollider(collider, body);
}
}

View File

@ -0,0 +1,27 @@
import { Matrix4, Vector3 } from 'three';
import { Brick } from './brick.object';
import type Rapier from '@dimforge/rapier3d-compat';
import { WedgeInnerCornerGeometry } from './geometries';
export class WedgeInnerCorner extends Brick {
public objectType = 'WedgeInnerCorner';
public name = this.objectType;
public static geometry = new WedgeInnerCornerGeometry();
protected override createCollider(
factory: typeof Rapier,
world: Rapier.World,
body?: Rapier.RigidBody,
) {
const scale = this.getWorldScale(new Vector3());
const mat = new Matrix4();
mat.makeScale(...scale.toArray());
const points = WedgeInnerCorner.geometry
.getAttribute('position')
.clone()
.applyMatrix4(mat)?.array as Float32Array;
const collider = factory.ColliderDesc.convexMesh(points)!;
return world.createCollider(collider, body);
}
}

27
src/game/wedge.object.ts Normal file
View File

@ -0,0 +1,27 @@
import { Matrix4, Vector3 } from 'three';
import { Brick } from './brick.object';
import type Rapier from '@dimforge/rapier3d-compat';
import { WedgeGeometry } from './geometries';
export class Wedge extends Brick {
public objectType = 'Wedge';
public name = this.objectType;
public static geometry = new WedgeGeometry();
protected override createCollider(
factory: typeof Rapier,
world: Rapier.World,
body?: Rapier.RigidBody,
) {
const scale = this.getWorldScale(new Vector3());
const mat = new Matrix4();
mat.makeScale(...scale.toArray());
const points = Wedge.geometry
.getAttribute('position')
.clone()
.applyMatrix4(mat)?.array as Float32Array;
const collider = factory.ColliderDesc.convexMesh(points)!;
return world.createCollider(collider, body);
}
}

16
src/game/world.object.ts Normal file
View File

@ -0,0 +1,16 @@
import { ObjectProperty } from 'src/types/property.decorator';
import { GameObject } from './game-object';
export class World extends GameObject {
public objectType = 'World';
public name = 'World';
public virtual = true;
@ObjectProperty()
public gravity = -9.81;
override get properties() {
const properties = super.properties;
return properties.filter((prop) => ['gravity'].includes(prop));
}
}

12
src/main.ts Normal file
View File

@ -0,0 +1,12 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { WsAdapter } from './net/ws-adapter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useWebSocketAdapter(new WsAdapter(app));
await app.listen(3000);
console.log(`Application is running on: ${await app.getUrl()}`);
}
bootstrap();

97
src/net/packet.ts Normal file
View File

@ -0,0 +1,97 @@
import { PacketType } from '../types/packet-type.enum';
import { SmartBuffer } from 'smart-buffer';
import { Instancable } from '../types/instancable';
import { Vector3, Quaternion } 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): Packet {
const packet = new Packet();
packet.buffer = SmartBuffer.fromBuffer(buffer);
packet.packet = packet.buffer.readUInt8();
return packet;
}
}

0
src/net/packets/world.ts Normal file
View File

52
src/net/ws-adapter.ts Normal file
View File

@ -0,0 +1,52 @@
import * as WebSocket from 'ws';
import { WebSocketAdapter, INestApplicationContext } from '@nestjs/common';
import { MessageMappingProperties } from '@nestjs/websockets';
import { Observable, fromEvent, EMPTY } from 'rxjs';
import { mergeMap, filter } from 'rxjs/operators';
import { Packet } from './packet';
export class WsAdapter implements WebSocketAdapter {
constructor(private app: INestApplicationContext) {}
create(port: number, options: any = {}): any {
return new WebSocket.Server({ port, ...options });
}
bindClientConnect(
server: WebSocket.Server,
callback: (...args: any[]) => void,
) {
server.on('connection', callback);
}
bindMessageHandlers(
client: WebSocket,
handlers: MessageMappingProperties[],
process: (data: any) => Observable<any>,
) {
fromEvent(client, 'message')
.pipe(
mergeMap((data: WebSocket.MessageEvent) =>
this.bindMessageHandler(data, handlers, process),
),
filter((result) => result),
)
.subscribe();
}
bindMessageHandler(
buffer: WebSocket.MessageEvent,
handlers: MessageMappingProperties[],
process: (data: any) => Observable<any>,
): Observable<any> {
const messageHandler = handlers[0];
if (!messageHandler) {
return EMPTY;
}
return process(messageHandler.callback(Packet.from(buffer.data as Buffer)));
}
close(server: WebSocket.Server) {
server.close();
}
}

2
src/physics/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './rapier';
export * from './ticking';

5
src/physics/rapier.ts Normal file
View File

@ -0,0 +1,5 @@
export type Rapier = typeof import('@dimforge/rapier3d-compat');
export function getRapier() {
return import('@dimforge/rapier3d-compat');
}

30
src/physics/ticking.ts Normal file
View File

@ -0,0 +1,30 @@
import type Rapier from '@dimforge/rapier3d-compat';
import { Quaternion, Vector3 } from 'three';
export interface PhysicsTicking {
position: Vector3;
quaternion: Quaternion;
/**
* @internal
*/
isTickingObject: boolean;
/**
* @internal
*/
uuid: string;
/**
* @internal
*/
initialize(
physicsEngine: typeof Rapier,
physicsWorld: Rapier.World,
controller?: Rapier.KinematicCharacterController,
): void;
/**
* @internal
*/
tick(dt: number): void;
}

View File

@ -0,0 +1,4 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class PlayerAuthService {}

View File

@ -0,0 +1,79 @@
import { Injectable } from '@nestjs/common';
import { Player } from 'src/game/player';
import { PlayerSocket } from 'src/types/data-socket';
import { PlayerAuthService } from './player-auth.service';
import { Packet } from 'src/net/packet';
import { getRandomName } from 'src/utils/random';
import { PacketType } from 'src/types/packet-type.enum';
@Injectable()
export class PlayerStoreService {
constructor(private readonly auth: PlayerAuthService) {}
private players = new Map<string, Player>();
getByClient(client: PlayerSocket) {
return this.players.get(client.id);
}
disconnect(client: PlayerSocket) {
return this.players.delete(client.id);
}
getPlayerList() {
const all = Array.from(this.players.values());
return all
.filter((object) => object.initialized)
.map((player) => `${player.id}:${player.name}`);
}
getUninitializedIds() {
const all = Array.from(this.players.values());
return all
.filter((object) => !object.initialized)
.map((player) => player.id);
}
async authenticate(client: PlayerSocket, packet: Packet) {
const playerId = packet.read(String);
const playerToken = packet.read(String);
const clientVersion = packet.read<number>('uint8');
if (clientVersion !== 0) throw new Error('Bad client');
// TODO: actually something
const player = new Player(client, playerId, getRandomName(), playerToken);
this.players.set(client.id, player);
return player;
}
broadcast(data: Buffer) {
this.players.forEach((value) => value.send(data));
}
broadcastExcept(data: Buffer, players: string[]) {
Array.from(this.players.values())
.filter((player) => !players.includes(player.id))
.forEach((player) => player.send(data));
}
getPlayerListPacket() {
const packet = new Packet(PacketType.PLAYER_LIST);
const players = this.getPlayerList();
packet.write(players.length, 'uint32');
players.forEach((player) => packet.write(player, String));
return packet.toBuffer();
}
getPlayerCharacterPackets(exclude: Player) {
const players = Array.from(this.players.values());
return players
.filter((player) => !!player.character && player.id !== exclude.id)
.map((player) => {
return new Packet(PacketType.PLAYER_CHARACTER)
.write(player.id, String)
.write(player.name, String)
.write(player.character.position, 'vec3')
.write(player.character.quaternion, 'quat')
.toBuffer();
});
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PlayerAuthService } from './player-auth.service';
import { PlayerStoreService } from './player-store.service';
@Module({
providers: [PlayerAuthService, PlayerStoreService],
exports: [PlayerAuthService, PlayerStoreService],
})
export class PlayerModule {}

9
src/types/asset.ts Normal file
View File

@ -0,0 +1,9 @@
export type AssetType = 'Texture' | 'CubeTexture' | 'Mesh';
export interface Asset {
name: string;
path?: string;
type: AssetType;
data: any;
remote?: boolean;
}

View File

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

5
src/types/data-socket.ts Normal file
View File

@ -0,0 +1,5 @@
import { WebSocket } from 'ws';
export interface PlayerSocket extends WebSocket {
id: string;
}

View File

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

3
src/types/instancable.ts Normal file
View File

@ -0,0 +1,3 @@
export interface Instancable<T> {
new (...args: any[]): T;
}

View File

@ -0,0 +1,67 @@
export enum PacketType {
/**
* [][Player ID][Session Token][Client Version]
*/
AUTH = 0,
KEEPALIVE,
/**
* [][World name]
*/
STREAM_START,
/**
* [][Asset Name][Asset Type][Buffer]
*/
STREAM_ASSET,
/**
* [][Object UUID][Parent UUID][Object Type][Object data JSON]
*/
STREAM_OBJECT,
/**
* [][Object UUID]
*/
STREAM_DESTROY,
/**
* [][Object UUID][Event][JSON]
*/
STREAM_EVENT,
/**
* [][World name]
*/
STREAM_FINISH,
/**
* [][UUID][x y z Position][x y z w Rotation][x y z linvel][x y z angvel]
*/
STREAM_TRANSFORM,
/**
* [][Player ID][Chat Type][Chat Message]
*/
STREAM_CHAT,
/**
* [][Player ID:Player Name]
*/
PLAYER_LIST,
/**
* [][Player ID][Player Name]
*/
PLAYER_JOIN,
/**
* [][Player ID][Player Name][Reason]
*/
PLAYER_QUIT,
/**
* [][Player ID][Player Name][x y z Position][Data...]
*/
PLAYER_CHARACTER,
/**
* [][Velocity][LookAt][AnimState]
*/
PLAYER_MOVEMENT,
/**
* [][Chat Type][Chat Message]
*/
PLAYER_CHAT,
/**
* [][Error type]
*/
ERROR,
}

View File

@ -0,0 +1,31 @@
import 'reflect-metadata';
export function ObjectProperty(): PropertyDecorator {
return (target, propertyKey): void => {
let properties: Array<string> = Reflect.getOwnMetadata(
'properties',
target,
);
if (!properties) {
Reflect.defineMetadata('properties', (properties = []), target);
}
properties.push(String(propertyKey));
};
}
export function ObjectPropertyExclude(): PropertyDecorator {
return (target, propertyKey): void => {
let excluded: Array<string> = Reflect.getOwnMetadata(
'excludedProperties',
target,
);
if (!excluded) {
Reflect.defineMetadata('excludedProperties', (excluded = []), target);
}
excluded.push(String(propertyKey));
};
}

7
src/types/serialized.ts Normal file
View File

@ -0,0 +1,7 @@
export interface SerializedObject {
[x: string]: unknown;
name: string;
objectType: string;
children: SerializedObject[];
visible: boolean;
}

10
src/types/world-file.ts Normal file
View File

@ -0,0 +1,10 @@
import { SerializedEnvironment } from 'src/game/environment.object';
import { Asset } from './asset';
import { SerializedObject } from './serialized';
export interface WorldFile {
name: string;
world: SerializedObject;
environment: SerializedEnvironment;
assets: Asset[];
}

3
src/utils/clamp.ts Normal file
View File

@ -0,0 +1,3 @@
export function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}

4
src/utils/random.ts Normal file
View File

@ -0,0 +1,4 @@
import { v4 } from 'uuid';
export const getRandomId = v4;
export const getRandomName = () => `Player${Date.now()}`;

View File

@ -0,0 +1,12 @@
import 'reflect-metadata';
export const readMetadataOf = <T>(object: any, key: string) => {
const metadata: T[] = [];
let target = Object.getPrototypeOf(object);
while (target != Object.prototype) {
const childFields = Reflect.getOwnMetadata(key, target) || [];
metadata.unshift(...childFields);
target = Object.getPrototypeOf(target);
}
return metadata;
};

View File

@ -0,0 +1,113 @@
import { Injectable } from '@nestjs/common';
import { Humanoid, PhysicsObject, World } from 'src/game';
import RAPIER from '@dimforge/rapier3d-compat';
import { Packet } from 'src/net/packet';
import { PacketType } from 'src/types/packet-type.enum';
import { Object3D, Quaternion, SkinnedMesh, Vector3 } from 'three';
import { GameObject } from 'src/game/game-object';
import { PhysicsTicking } from 'src/physics';
@Injectable()
export class PhysicsService {
private world!: World;
private physicsWorld!: RAPIER.World;
private characterPhysics!: RAPIER.KinematicCharacterController;
private running = false;
private sceneInitialized = false;
private physicsTicker: ReturnType<typeof setTimeout>;
private trackedObjects: PhysicsTicking[] = [];
loop() {
if (!this.running) return;
this.physicsTicker = setTimeout(() => this.loop(), 20);
this.physicsWorld.step();
for (const object of this.trackedObjects) object.tick(0.02); // TODO: DT
}
async start(world: World) {
this.world = world;
await RAPIER.init();
this.physicsWorld = new RAPIER.World(new Vector3(0, this.world.gravity, 0));
this.running = true;
this.initializePhysicsScene();
this.loop();
}
stop() {
this.running = false;
}
getObjectPackets() {
const data: Buffer[] = [];
for (const object of this.trackedObjects) {
const body = (object as PhysicsObject)?.rigidBody;
if (!body) continue;
data.push(
new Packet(PacketType.STREAM_TRANSFORM)
.write(object.uuid, String)
.write(body.translation(), 'vec3')
.write(body.rotation(), 'quat')
.write(body.linvel(), 'vec3')
.write(body.angvel(), 'vec3')
.toBuffer(),
);
}
return data;
}
applyPhysics(root: Object3D) {
root.traverse((object) => {
// Prevent double-init
if (this.trackedObjects.some((entry) => entry.uuid === object.uuid))
return;
// Initialize humanoids
if (object instanceof Humanoid) {
object.initialize(RAPIER, this.physicsWorld, this.characterPhysics);
this.trackedObjects.push(object);
return;
}
// Only track physics object instances
if (!(object instanceof PhysicsObject)) return;
// Do not add physics to virtual objects
if ((object as GameObject).virtual) return;
// Initialize object physics
object.initialize(RAPIER, this.physicsWorld);
this.trackedObjects.push(object);
});
}
removePhysics(root: Object3D) {
const trackedObjects = [...this.trackedObjects];
const physicsLeaveUUIDs: string[] = [];
root.traverse((object) => {
if (trackedObjects.some((item) => item.uuid === object.uuid)) {
physicsLeaveUUIDs.push(object.uuid);
}
});
this.trackedObjects = this.trackedObjects.filter((object) =>
physicsLeaveUUIDs.includes(object.uuid),
);
}
private async initializePhysicsScene() {
if (this.sceneInitialized) return;
this.characterPhysics = this.physicsWorld.createCharacterController(0.01);
this.characterPhysics.setApplyImpulsesToDynamicBodies(true);
this.characterPhysics.setMaxSlopeClimbAngle((75 * Math.PI) / 180);
this.characterPhysics.enableAutostep(1, 0.5, true);
// Automatically slide down on slopes smaller than 30 degrees.
// this.characterPhysics.setMinSlopeSlideAngle((30 * Math.PI) / 180);
this.applyPhysics(this.world);
this.sceneInitialized = true;
}
}

13
src/world/world.module.ts Normal file
View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { WorldService } from './world.service';
import { PlayerModule } from 'src/player/player.module';
import { ConfigModule } from '@nestjs/config';
import { HttpModule } from '@nestjs/axios';
import { PhysicsService } from './physics.service';
@Module({
imports: [PlayerModule, ConfigModule, HttpModule],
providers: [WorldService, PhysicsService],
exports: [WorldService],
})
export class WorldModule {}

190
src/world/world.service.ts Normal file
View File

@ -0,0 +1,190 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PlayerStoreService } from 'src/player/player-store.service';
import { HttpService } from '@nestjs/axios';
import { WorldFile } from 'src/types/world-file';
import { Environment, World, instancableGameObjects } from 'src/game';
import { SerializedObject } from 'src/types/serialized';
import { Object3D } from 'three';
import { lastValueFrom } from 'rxjs';
import { Packet } from 'src/net/packet';
import { PacketType } from 'src/types/packet-type.enum';
import { Player } from 'src/game/player';
import { GameObject } from 'src/game/game-object';
import { PhysicsService } from './physics.service';
@Injectable()
export class WorldService implements OnModuleInit {
private logger = new Logger(WorldService.name);
private world = new World();
private environment = new Environment();
private broadcastWorldStateTicker!: ReturnType<typeof setInterval>;
constructor(
private readonly players: PlayerStoreService,
private readonly physics: PhysicsService,
private readonly config: ConfigService,
private readonly http: HttpService,
) {}
onModuleInit() {
this.logger.log('Loading world file from environment');
this.loadWorld()
.then(() => {
this.logger.log('World file loaded');
this.physics.start(this.world);
this.startUpdateTick();
})
.catch((err) => {
this.logger.error('Failed to load world:', err.stack);
process.exit(1);
});
}
async loadWorld() {
const path = this.config.get('world').worldAsset;
const file = await lastValueFrom(this.http.get(path));
await this.deserializeLevelSave(file.data);
}
public async sendWorldToPlayer(player: Player) {
const packets: Buffer[] = [];
this.world.traverse((object) => {
if (!(object instanceof GameObject)) return;
packets.push(
new Packet(PacketType.STREAM_OBJECT)
.write(object.uuid, String)
.write(object.parent?.uuid || '', String)
.write(object.objectType, String)
.write(JSON.stringify(object.serialize(true)), String)
.toBuffer(),
);
});
packets.forEach((packet) => player.send(packet));
}
public async initializePlayer(player: Player) {
// Streaming start
player.send(
new Packet(PacketType.STREAM_START)
.write(this.world.name, String)
.toBuffer(),
);
// TODO: assets
// Send world objects
await this.sendWorldToPlayer(player);
// Player is initialized
player.initialized = true;
// Send player list
player.send(this.players.getPlayerListPacket());
// Broadcast join
this.players.broadcast(
new Packet(PacketType.PLAYER_JOIN)
.write(player.id, String)
.write(player.name, String)
.toBuffer(),
);
// Tell player they're ready to play
player.send(
new Packet(PacketType.STREAM_FINISH)
.write(this.world.name, String)
.toBuffer(),
);
// Spawn player character
const character = player.createPlayerCharacter();
this.world.add(character);
this.physics.applyPhysics(character);
// TODO: position
this.players.broadcast(
new Packet(PacketType.PLAYER_CHARACTER)
.write(player.id, String)
.write(player.name, String)
.write(character.position, 'vec3')
.write(character.quaternion, 'quat')
.toBuffer(),
);
this.players
.getPlayerCharacterPackets(player)
.forEach((packet) => player.send(packet));
}
public createObject(object: string, setParent?: Object3D, skipEvent = false) {
const parent = setParent || this.world;
const ObjectType = instancableGameObjects[object];
if (!ObjectType) return;
const newObject = new ObjectType();
parent.add(newObject);
if (!skipEvent) {
this.players.broadcast(
new Packet(PacketType.STREAM_OBJECT)
.write(newObject.uuid, String)
.write(newObject.parent?.uuid || '', String)
.write(newObject.objectType, String)
.write(JSON.stringify(newObject.serialize()), String)
.toBuffer(),
);
}
return newObject;
}
public deserializeObject(root: SerializedObject, setParent?: Object3D) {
const parent = setParent || this.world;
if (root.objectType === 'World') {
root.children.forEach((entry) => this.recursiveCreate(entry, parent));
return parent;
}
return this.recursiveCreate(root, parent);
}
public serializeLevelSave(name: string): WorldFile {
const world = this.world.serialize();
const environment = this.environment.serialize();
return {
name,
world,
environment,
assets: [],
};
}
public async deserializeLevelSave(save: WorldFile) {
this.environment.deserialize(save.environment);
// Load world
this.deserializeObject(save.world);
this.world.deserialize(save.world);
}
private recursiveCreate(entry: SerializedObject, setParent?: Object3D) {
const parent = setParent || this.world;
const newObject = this.createObject(entry.objectType, parent, true);
newObject?.deserialize(entry);
entry.children.forEach((child) => this.recursiveCreate(child, newObject));
return newObject;
}
private broadcastWorldState() {
const data = this.physics.getObjectPackets();
data.forEach((data) =>
this.players.broadcastExcept(data, this.players.getUninitializedIds()),
);
}
private startUpdateTick() {
this.broadcastWorldStateTicker = setInterval(
() => this.broadcastWorldState(),
5000,
);
}
}

24
test/app.e2e-spec.ts Normal file
View File

@ -0,0 +1,24 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

9
test/jest-e2e.json Normal file
View File

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

4
tsconfig.build.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}