initial commit
This commit is contained in:
commit
84309feb0c
25
.eslintrc.js
Normal file
25
.eslintrc.js
Normal 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
35
.gitignore
vendored
Normal 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
4
.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"editor.formatOnSave": true
|
||||
}
|
73
README.md
Normal file
73
README.md
Normal 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
8
nest-cli.json
Normal 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
82
package.json
Normal 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
5239
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
src/app.module.ts
Normal file
14
src/app.module.ts
Normal 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 {}
|
7
src/config/world.config.ts
Normal file
7
src/config/world.config.ts
Normal 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',
|
||||
),
|
||||
}));
|
81
src/events/events.gateway.ts
Normal file
81
src/events/events.gateway.ts
Normal 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);
|
||||
}
|
||||
}
|
10
src/events/events.module.ts
Normal file
10
src/events/events.module.ts
Normal 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
22
src/game/brick.object.ts
Normal 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);
|
||||
}
|
||||
}
|
20
src/game/capsule.object.ts
Normal file
20
src/game/capsule.object.ts
Normal 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);
|
||||
}
|
||||
}
|
19
src/game/cylinder.object.ts
Normal file
19
src/game/cylinder.object.ts
Normal 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);
|
||||
}
|
||||
}
|
48
src/game/environment.object.ts
Normal file
48
src/game/environment.object.ts
Normal 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
132
src/game/game-object.ts
Normal 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
43
src/game/geometries.ts
Normal 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
6
src/game/group.object.ts
Normal 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
198
src/game/humanoid.object.ts
Normal 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
43
src/game/index.ts
Normal 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
21
src/game/mesh.object.ts
Normal 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);
|
||||
}
|
||||
}
|
25
src/game/physical.object.ts
Normal file
25
src/game/physical.object.ts
Normal 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
105
src/game/physics.object.ts
Normal 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
56
src/game/player.ts
Normal 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
20
src/game/sphere.object.ts
Normal 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
25
src/game/torus.object.ts
Normal 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);
|
||||
}
|
||||
}
|
27
src/game/wedge-corner.object.ts
Normal file
27
src/game/wedge-corner.object.ts
Normal 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);
|
||||
}
|
||||
}
|
27
src/game/wedge-inner-corner.object.ts
Normal file
27
src/game/wedge-inner-corner.object.ts
Normal 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
27
src/game/wedge.object.ts
Normal 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
16
src/game/world.object.ts
Normal 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
12
src/main.ts
Normal 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
97
src/net/packet.ts
Normal 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
0
src/net/packets/world.ts
Normal file
52
src/net/ws-adapter.ts
Normal file
52
src/net/ws-adapter.ts
Normal 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
2
src/physics/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './rapier';
|
||||
export * from './ticking';
|
5
src/physics/rapier.ts
Normal file
5
src/physics/rapier.ts
Normal 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
30
src/physics/ticking.ts
Normal 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;
|
||||
}
|
4
src/player/player-auth.service.ts
Normal file
4
src/player/player-auth.service.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class PlayerAuthService {}
|
79
src/player/player-store.service.ts
Normal file
79
src/player/player-store.service.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
9
src/player/player.module.ts
Normal file
9
src/player/player.module.ts
Normal 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
9
src/types/asset.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export type AssetType = 'Texture' | 'CubeTexture' | 'Mesh';
|
||||
|
||||
export interface Asset {
|
||||
name: string;
|
||||
path?: string;
|
||||
type: AssetType;
|
||||
data: any;
|
||||
remote?: boolean;
|
||||
}
|
4
src/types/chat-type.enum.ts
Normal file
4
src/types/chat-type.enum.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum ChatType {
|
||||
MESSAGE = 0,
|
||||
COMMAND,
|
||||
}
|
5
src/types/data-socket.ts
Normal file
5
src/types/data-socket.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
export interface PlayerSocket extends WebSocket {
|
||||
id: string;
|
||||
}
|
3
src/types/error-type.enum.ts
Normal file
3
src/types/error-type.enum.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export enum ErrorType {
|
||||
AUTH_FAIL = 0,
|
||||
}
|
3
src/types/instancable.ts
Normal file
3
src/types/instancable.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export interface Instancable<T> {
|
||||
new (...args: any[]): T;
|
||||
}
|
67
src/types/packet-type.enum.ts
Normal file
67
src/types/packet-type.enum.ts
Normal 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,
|
||||
}
|
31
src/types/property.decorator.ts
Normal file
31
src/types/property.decorator.ts
Normal 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
7
src/types/serialized.ts
Normal 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
10
src/types/world-file.ts
Normal 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
3
src/utils/clamp.ts
Normal 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
4
src/utils/random.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
export const getRandomId = v4;
|
||||
export const getRandomName = () => `Player${Date.now()}`;
|
12
src/utils/read-metadata.ts
Normal file
12
src/utils/read-metadata.ts
Normal 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;
|
||||
};
|
113
src/world/physics.service.ts
Normal file
113
src/world/physics.service.ts
Normal 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
13
src/world/world.module.ts
Normal 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
190
src/world/world.service.ts
Normal 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
24
test/app.e2e-spec.ts
Normal 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
9
test/jest-e2e.json
Normal 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
4
tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
21
tsconfig.json
Normal file
21
tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user