Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 0 additions & 1 deletion packages/core/src/Networking/NetworkedActor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,4 @@ export default abstract class NetworkedActor extends NetworkedGameObjectMixin(Ac
return `${super.message} - ${this.$typeName}`
}

abstract updateFromNetwork: (data: object) => void
}
3 changes: 2 additions & 1 deletion packages/core/src/Networking/NetworkedEntity.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export default interface NetworkedEntity {
$typeName: string
updateFromNetwork(data: object): void
updateFromNetwork(data: Record<string, unknown>): void
networkedFieldCallbacks(): Record<string, (value: unknown) => void>
previousStateHash: string
needsSync: boolean
markSyncd(): void
Expand Down
77 changes: 44 additions & 33 deletions packages/core/src/Networking/NetworkedGameObject.ts
Original file line number Diff line number Diff line change
@@ -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<T = object> = abstract new (...args: any[]) => T

previousStateHash = ''
needsSync = true
export function NetworkedGameObjectMixin<TBase extends AbstractCtor<GameObject>>(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<string, unknown>): void {
Object.entries(this.networkedFieldCallbacks()).forEach(([name, cb]) => {
if (name in data) {
cb(data[name])
}
})
}

networkedFieldCallbacks(): Record<string, (value: unknown) => 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
*/
Expand All @@ -37,23 +68,3 @@ export default abstract class NetworkedGameObject extends GameObject implements
&& typeof candidate.previousStateHash === 'string'
}
}

type AbstractCtor<T = object> = abstract new (...args: any[]) => T

export function NetworkedGameObjectMixin<TBase extends AbstractCtor<GameObject>>(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
}
7 changes: 7 additions & 0 deletions packages/core/src/Networking/NetworkedLivingActor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ export default abstract class NetworkedLivingActor extends NetworkedGameObjectMi
}
}

networkedFieldCallbacks(): Record<string, (value: unknown) => void> {
return {
...super.networkedFieldCallbacks(),
health: (health) => this.health = health as number
}
}

public serialize(): object {
return {
...super.serialize(),
Expand Down
78 changes: 78 additions & 0 deletions packages/core/tests/Networking/NetworkedGameObject.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, (value: unknown) => void> {
return {
...super.networkedFieldCallbacks(),
health: (v) => { this.health = v as number },
}
}
}

const obj = new ExtendedObject()
obj.updateFromNetwork({ health: 75 })
expect(obj.health).toBe(75)
})
})
})
39 changes: 17 additions & 22 deletions packages/multiplayer-template/client/src/Entities/Player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, (value: unknown) => 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<string, unknown>): void {
super.updateFromNetwork(data)

syncStateStack(this, data.state, stateFactories)
syncStateStack(this, data.state as { stateName: string }[], this.stateFactories)
}

destroy(): void {
Expand Down
5 changes: 5 additions & 0 deletions packages/multiplayer-template/client/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@ declare module '*.vue' {
const component: DefineComponent
export default component
}

declare module '*.glsl' {
const content: string
export default content
}
24 changes: 12 additions & 12 deletions packages/multiplayer-template/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/multiplayer-template/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
32 changes: 0 additions & 32 deletions packages/multiplayer-template/server/src/Base/Player.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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)
}
}
Loading