diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 3de66f6..a2b62a1 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -41,11 +41,16 @@ jobs: run: npm run typecheck working-directory: ./packages/bootstrap + - name: npm link core locally + run: | + npm link + working-directory: ./packages/core + # multiplayer-template has its own nested workspaces (client/server) that npm # workspaces does not handle recursively, so we need a separate npm ci here - name: Build multiplayer-template run: | - npm ci + npm link @mavonengine/core npm run typecheck npm run build working-directory: ./packages/multiplayer-template diff --git a/packages/core/src/Networking/NetworkedActor.ts b/packages/core/src/Networking/NetworkedActor.ts index ce38888..3b98270 100644 --- a/packages/core/src/Networking/NetworkedActor.ts +++ b/packages/core/src/Networking/NetworkedActor.ts @@ -15,5 +15,4 @@ export default abstract class NetworkedActor extends NetworkedGameObjectMixin(Ac return `${super.message} - ${this.$typeName}` } - abstract updateFromNetwork: (data: object) => void } diff --git a/packages/core/src/Networking/NetworkedEntity.ts b/packages/core/src/Networking/NetworkedEntity.ts index 9f80073..3a41118 100644 --- a/packages/core/src/Networking/NetworkedEntity.ts +++ b/packages/core/src/Networking/NetworkedEntity.ts @@ -1,6 +1,7 @@ export default interface NetworkedEntity { $typeName: string - updateFromNetwork(data: object): void + updateFromNetwork(data: Record): void + networkedFieldCallbacks(): Record void> previousStateHash: string needsSync: boolean markSyncd(): void diff --git a/packages/core/src/Networking/NetworkedGameObject.ts b/packages/core/src/Networking/NetworkedGameObject.ts index 35c6e61..ed9be2a 100644 --- a/packages/core/src/Networking/NetworkedGameObject.ts +++ b/packages/core/src/Networking/NetworkedGameObject.ts @@ -1,25 +1,56 @@ -import type { Vector3 } from 'three' +import type { Vector3Like } from 'three' import type NetworkedEntity from './NetworkedEntity' import GameObject from '../World/GameObject' -export default abstract class NetworkedGameObject extends GameObject implements NetworkedEntity { - abstract $typeName: string +type AbstractCtor = abstract new (...args: any[]) => T - previousStateHash = '' - needsSync = true +export function NetworkedGameObjectMixin>(Base: TBase) { + abstract class NetworkedGameObjectBase + extends Base + implements NetworkedEntity { + abstract $typeName: string - constructor(id?: string, position?: Vector3, rotation?: Vector3, scale?: Vector3) { - super(id, position, rotation, scale) - } + previousStateHash = '' + needsSync = true - updateFromNetwork(_data: object): void { - throw new Error('Not implemented') - } + /** + * This is the reverse of serialize on GameObject. + * This way we can parse the values back from the network serialization into our + * current entity. + * + * Once it gets the data in from the server it loops through the entities networkedFieldCallbacks + * and assigns the values. + * + * After the values have been set you can then do what you need to do + * in the entity.update() method. + */ + updateFromNetwork(data: Record): void { + Object.entries(this.networkedFieldCallbacks()).forEach(([name, cb]) => { + if (name in data) { + cb(data[name]) + } + }) + } + + networkedFieldCallbacks(): Record void> { + return { + position: (pos) => this.position.set((pos as Vector3Like).x, (pos as Vector3Like).y, (pos as Vector3Like).z), + rotation: (rot) => this.rotation.set((rot as Vector3Like).x, (rot as Vector3Like).y, (rot as Vector3Like).z), + scale: (scale) => this.scale.set((scale as Vector3Like).x, (scale as Vector3Like).y, (scale as Vector3Like).z), + } + } - markSyncd() { - this.needsSync = false + markSyncd() { + this.needsSync = false + } } + return NetworkedGameObjectBase +} + +export default abstract class NetworkedGameObject extends NetworkedGameObjectMixin(GameObject) { + abstract $typeName: string + /** * We need this so we can use instanceof on these instances */ @@ -37,23 +68,3 @@ export default abstract class NetworkedGameObject extends GameObject implements && typeof candidate.previousStateHash === 'string' } } - -type AbstractCtor = abstract new (...args: any[]) => T - -export function NetworkedGameObjectMixin>(Base: TBase) { - abstract class NetworkedGameObjectBase - extends Base - implements NetworkedEntity { - abstract $typeName: string - abstract updateFromNetwork(data: object): void - - previousStateHash = '' - needsSync = true - - markSyncd() { - this.needsSync = false - } - } - - return NetworkedGameObjectBase -} diff --git a/packages/core/src/Networking/NetworkedLivingActor.ts b/packages/core/src/Networking/NetworkedLivingActor.ts index cdb6f33..789b847 100644 --- a/packages/core/src/Networking/NetworkedLivingActor.ts +++ b/packages/core/src/Networking/NetworkedLivingActor.ts @@ -28,6 +28,13 @@ export default abstract class NetworkedLivingActor extends NetworkedGameObjectMi } } + networkedFieldCallbacks(): Record void> { + return { + ...super.networkedFieldCallbacks(), + health: (health) => this.health = health as number + } + } + public serialize(): object { return { ...super.serialize(), diff --git a/packages/core/tests/Networking/NetworkedGameObject.test.ts b/packages/core/tests/Networking/NetworkedGameObject.test.ts new file mode 100644 index 0000000..1a3f078 --- /dev/null +++ b/packages/core/tests/Networking/NetworkedGameObject.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { Vector3 } from 'three' +import NetworkedGameObject from '../../src/Networking/NetworkedGameObject' + +class TestNetworkedGameObject extends NetworkedGameObject { + $typeName = 'test' + update(_delta: number): void { } +} + +describe('NetworkedGameObject', () => { + describe('updateFromNetwork', () => { + it('updates position from network data', () => { + const obj = new TestNetworkedGameObject() + expect(obj.position).toStrictEqual(new Vector3) + + obj.updateFromNetwork({ position: new Vector3(1, 2, 3) }) + expect(obj.position).toStrictEqual(new Vector3(1, 2, 3)) + }) + + it('updates rotation from network data', () => { + const obj = new TestNetworkedGameObject() + expect(obj.rotation).toStrictEqual(new Vector3) + + obj.updateFromNetwork({ rotation: new Vector3(0.1, 0.2, 0.3) }) + expect(obj.rotation).toStrictEqual(new Vector3(0.1, 0.2, 0.3)) + }) + + it('updates scale from network data', () => { + const obj = new TestNetworkedGameObject() + + obj.updateFromNetwork({ scale: new Vector3(2, 2, 2) }) + expect(obj.scale).toStrictEqual(new Vector3(2, 2, 2)) + }) + + it('updates multiple fields at once', () => { + const obj = new TestNetworkedGameObject() + obj.updateFromNetwork({ position: new Vector3(10, 20, 30), rotation: new Vector3(1, 0, 0), scale: new Vector3(3, 3, 3) }) + expect(obj.position).toStrictEqual(new Vector3(10, 20, 30)) + expect(obj.rotation).toStrictEqual(new Vector3(1, 0, 0)) + expect(obj.scale).toStrictEqual(new Vector3(3, 3, 3)) + }) + + it('preserves the original Vector3 instance reference', () => { + const obj = new TestNetworkedGameObject() + const originalPos = obj.position + obj.updateFromNetwork({ position: new Vector3(5, 6, 7) }) + expect(obj.position).toBe(originalPos) + }) + + it('ignores unknown fields in network data', () => { + const obj = new TestNetworkedGameObject() + const originalPos = obj.position + expect(() => obj.updateFromNetwork({ unknown: 'value' })).not.toThrow() + expect(obj.position).toBe(originalPos) + }) + }) + + describe('subclass networkedFieldCallbacks override', () => { + it('applies custom callback fields from subclass', () => { + class ExtendedObject extends NetworkedGameObject { + $typeName = 'extended' + health = 100 + update(_delta: number): void { } + + networkedFieldCallbacks(): Record void> { + return { + ...super.networkedFieldCallbacks(), + health: (v) => { this.health = v as number }, + } + } + } + + const obj = new ExtendedObject() + obj.updateFromNetwork({ health: 75 }) + expect(obj.health).toBe(75) + }) + }) +}) diff --git a/packages/multiplayer-template/client/src/Entities/Player.ts b/packages/multiplayer-template/client/src/Entities/Player.ts index 5f15952..12d8935 100644 --- a/packages/multiplayer-template/client/src/Entities/Player.ts +++ b/packages/multiplayer-template/client/src/Entities/Player.ts @@ -28,36 +28,31 @@ export default class Character extends BasePlayer { } update(delta: number): void { + this.rigidBody!.setTranslation({ x: this.position.x, y: this.position.y, z: this.position.z }, true) + this.graphicalComponent.update(delta) this.label.update(delta) } - updateFromNetwork = (data: { - position: { x: number; y: number; z: number } - rotation?: { x: number; y: number; z: number } - state: { stateName: string }[] - health: number - name?: string - }) => { - this.position.set(data.position.x, data.position.y, data.position.z) - this.rigidBody?.setTranslation({ x: data.position.x, y: data.position.y, z: data.position.z }, false) - this.health = data.health - - if (!this.isLocalPlayer && data.rotation) { - this.rotation.y = data.rotation.y + networkedFieldCallbacks(): Record void> { + return { + ...super.networkedFieldCallbacks(), + name: (v) => { if (v) this.name = v as string }, } + } - if (data.name && data.name !== this.name) { - this.name = data.name - this.label.setName(this.isLocalPlayer ? `You (${data.name})` : data.name) - } + private stateFactories = { + idleState: (entity: BasePlayer) => new IdleState(entity), + walkingState: (entity: BasePlayer) => new WalkingState(entity), + } - const stateFactories = { - idleState: (entity: BasePlayer) => new IdleState(entity), - walkingState: (entity: BasePlayer) => new WalkingState(entity), - } + /** + * Applied on the client when server state arrives. + */ + updateFromNetwork(data: Record): void { + super.updateFromNetwork(data) - syncStateStack(this, data.state, stateFactories) + syncStateStack(this, data.state as { stateName: string }[], this.stateFactories) } destroy(): void { diff --git a/packages/multiplayer-template/client/src/env.d.ts b/packages/multiplayer-template/client/src/env.d.ts index 6ac34fb..8625305 100644 --- a/packages/multiplayer-template/client/src/env.d.ts +++ b/packages/multiplayer-template/client/src/env.d.ts @@ -5,3 +5,8 @@ declare module '*.vue' { const component: DefineComponent export default component } + +declare module '*.glsl' { + const content: string + export default content +} diff --git a/packages/multiplayer-template/package-lock.json b/packages/multiplayer-template/package-lock.json index bdff935..4a4a430 100644 --- a/packages/multiplayer-template/package-lock.json +++ b/packages/multiplayer-template/package-lock.json @@ -571,9 +571,9 @@ } }, "node_modules/@geckos.io/common": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@geckos.io/common/-/common-3.0.2.tgz", - "integrity": "sha512-2Rf61S+Xya1FwGnIShfdP7GPE73o9zDQRbiFEd/SCTkIApR2MusJholLJ88cmWHXY9bX0nDpyiflgwYYUfXx7g==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@geckos.io/common/-/common-3.1.0.tgz", + "integrity": "sha512-ipdkDOudhPOLjBr1/rPm8ts1KlfduT0fdNwWDFVwk9bg3B8As7YD74rNpW9FRg4Kk69vsRL2MZqJXuS2djDmpw==", "license": "BSD-3-Clause", "dependencies": { "@yandeu/events": "0.0.7" @@ -583,14 +583,14 @@ } }, "node_modules/@geckos.io/server": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@geckos.io/server/-/server-3.0.2.tgz", - "integrity": "sha512-WvMn4+dnWZYH8P30n+q3c5tN/mMOma1QYtlr0ztquRfID8Vyt09UldUAkNL5AN7gBb6WPDCgzZuEbajCeZV0WA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@geckos.io/server/-/server-3.1.0.tgz", + "integrity": "sha512-KcSBdMsoPST/CBD6qXAQ9gDVmYngrDNbAss0dkRuQVcmSRjE038uXkPK6EbtE9uUcLKZhZJchHxXmoInFM76Jg==", "license": "BSD-3-Clause", "dependencies": { - "@geckos.io/common": "^3.0.2", + "@geckos.io/common": "^3.1.0", "@yandeu/events": "0.0.7", - "node-datachannel": "0.26.0" + "node-datachannel": "0.32.1" }, "engines": { "node": ">=18" @@ -2653,9 +2653,9 @@ } }, "node_modules/node-datachannel": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/node-datachannel/-/node-datachannel-0.26.0.tgz", - "integrity": "sha512-i9ZcFNszK1HeV6Ym2AoQokmRHE5jk0L5023CdRLzbQl8rqyjJkOGecMxEjo1WSjNHDvRO3I3ay9waclBdD3jRQ==", + "version": "0.32.1", + "resolved": "https://registry.npmjs.org/node-datachannel/-/node-datachannel-0.32.1.tgz", + "integrity": "sha512-r4UdtA0lCsz6XrG84pJ6lntAyw/MHpmBOhEkg5UQcmWTEpANqCPkMos6rj/QZDdq3GBUsdI/wst5acwWUiibCA==", "hasInstallScript": true, "license": "MPL 2.0", "dependencies": { @@ -4558,7 +4558,7 @@ "version": "0.0.0", "dependencies": { "@dimforge/rapier3d-compat": "0.18.2", - "@geckos.io/server": "^3.0.2", + "@geckos.io/server": "3.1.0", "express": "5.1.0", "three": "^0.176.0", "winston": "^3.17.0" diff --git a/packages/multiplayer-template/server/package.json b/packages/multiplayer-template/server/package.json index fdac324..5b49968 100644 --- a/packages/multiplayer-template/server/package.json +++ b/packages/multiplayer-template/server/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@dimforge/rapier3d-compat": "0.18.2", - "@geckos.io/server": "^3.0.2", + "@geckos.io/server": "3.1.0", "express": "5.1.0", "three": "^0.176.0", "winston": "^3.17.0" diff --git a/packages/multiplayer-template/server/src/Base/Player.ts b/packages/multiplayer-template/server/src/Base/Player.ts index e008323..2ffb83a 100644 --- a/packages/multiplayer-template/server/src/Base/Player.ts +++ b/packages/multiplayer-template/server/src/Base/Player.ts @@ -1,10 +1,7 @@ import RAPIER from '@dimforge/rapier3d-compat' import BaseGame from '@mavonengine/core/BaseGame' import BasePlayer from '@mavonengine/core/Networking/Entities/Player' -import { syncStateStack } from '@mavonengine/core/Networking/syncState' import { Vector3 } from 'three' -import IdleState from '../Player/IdleState' -import WalkingState from '../Player/WalkingState' /** * Base player class shared between client and server. @@ -112,33 +109,4 @@ export default class Player extends BasePlayer { return this.serialize() } - /** - * Applied on the client when server state arrives. - * Overridden in client/Entities/Player.ts to also update the mesh. - */ - updateFromNetwork = (data: { - position: { x: number; y: number; z: number } - rotation?: { x: number; y: number; z: number } - state: { stateName: string }[] - health: number - name?: string - }) => { - if (this.rigidBody) { - this.rigidBody.setTranslation({ x: data.position.x, y: data.position.y, z: data.position.z }, true) - } - else { - this.position.set(data.position.x, data.position.y, data.position.z) - } - this.health = data.health - if (data.name) { - this.name = data.name - } - - const stateFactories = { - idleState: (entity: Player) => new IdleState(entity), - walkingState: (entity: Player) => new WalkingState(entity), - } - - syncStateStack(this, data.state, stateFactories) - } }