modd-network 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -37
- package/dist/index.js +15 -174
- package/dist/modd-engine.d.ts +95 -0
- package/dist/modd-engine.js +389 -0
- package/dist/modd-network.d.ts +40 -0
- package/dist/modd-network.js +449 -0
- package/package.json +7 -3
package/dist/index.d.ts
CHANGED
|
@@ -1,37 +1,2 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
3
|
-
nodeUrl?: string;
|
|
4
|
-
}
|
|
5
|
-
export interface OrderedInput {
|
|
6
|
-
data: any;
|
|
7
|
-
sequenceNumber: number;
|
|
8
|
-
timestamp: number;
|
|
9
|
-
clientId: string;
|
|
10
|
-
id: string;
|
|
11
|
-
}
|
|
12
|
-
export type InputHandler = (input: OrderedInput) => void;
|
|
13
|
-
export type SnapshotHandler = (snapshot: any) => void;
|
|
14
|
-
export declare class MODD {
|
|
15
|
-
private ws;
|
|
16
|
-
private roomId;
|
|
17
|
-
private appId;
|
|
18
|
-
private centralServiceUrl;
|
|
19
|
-
private nodeUrl;
|
|
20
|
-
private snapshot;
|
|
21
|
-
private inputHandlers;
|
|
22
|
-
private snapshotHandlers;
|
|
23
|
-
constructor(appId: string, options?: MODDOptions);
|
|
24
|
-
createRoom(roomId: string, initialSnapshot: any): Promise<void>;
|
|
25
|
-
joinRoom(roomId: string): Promise<void>;
|
|
26
|
-
private setupMessageHandlers;
|
|
27
|
-
sendInput(data: any): void;
|
|
28
|
-
sendSnapshot(snapshot: any): void;
|
|
29
|
-
onInput(handler: InputHandler): void;
|
|
30
|
-
onSnapshot(handler: SnapshotHandler): void;
|
|
31
|
-
getSnapshot(): any;
|
|
32
|
-
disconnect(): void;
|
|
33
|
-
private getNodeUrl;
|
|
34
|
-
private hashSnapshot;
|
|
35
|
-
}
|
|
36
|
-
export declare class MeshClient extends MODD {
|
|
37
|
-
}
|
|
1
|
+
export * from './modd-network';
|
|
2
|
+
export * from './modd-engine';
|
package/dist/index.js
CHANGED
|
@@ -1,177 +1,18 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var
|
|
3
|
-
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
4
15
|
};
|
|
5
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const crypto_1 = __importDefault(require("crypto"));
|
|
9
|
-
// Primary SDK client: MODD
|
|
10
|
-
class MODD {
|
|
11
|
-
ws = null;
|
|
12
|
-
roomId = null;
|
|
13
|
-
appId = null;
|
|
14
|
-
centralServiceUrl;
|
|
15
|
-
nodeUrl = null;
|
|
16
|
-
snapshot = null;
|
|
17
|
-
inputHandlers = [];
|
|
18
|
-
snapshotHandlers = [];
|
|
19
|
-
constructor(appId, options = {}) {
|
|
20
|
-
this.appId = appId;
|
|
21
|
-
this.centralServiceUrl = options.centralServiceUrl || 'ws://localhost:9001';
|
|
22
|
-
this.nodeUrl = options.nodeUrl || null;
|
|
23
|
-
}
|
|
24
|
-
async createRoom(roomId, initialSnapshot) {
|
|
25
|
-
if (!this.appId) {
|
|
26
|
-
throw new Error('appId is required. Initialize MODD with appId in constructor.');
|
|
27
|
-
}
|
|
28
|
-
this.roomId = roomId;
|
|
29
|
-
this.snapshot = initialSnapshot;
|
|
30
|
-
const nodeUrl = this.nodeUrl || await this.getNodeUrl(roomId);
|
|
31
|
-
return new Promise((resolve, reject) => {
|
|
32
|
-
this.ws = new ws_1.default(nodeUrl);
|
|
33
|
-
this.ws.on('open', () => {
|
|
34
|
-
this.ws.send(JSON.stringify({
|
|
35
|
-
type: 'CREATE_ROOM',
|
|
36
|
-
payload: { roomId, appId: this.appId, snapshot: initialSnapshot }
|
|
37
|
-
}));
|
|
38
|
-
});
|
|
39
|
-
this.ws.on('message', (data) => {
|
|
40
|
-
const message = JSON.parse(data.toString());
|
|
41
|
-
if (message.type === 'ROOM_CREATED') {
|
|
42
|
-
console.log('Room created:', roomId);
|
|
43
|
-
this.setupMessageHandlers();
|
|
44
|
-
resolve();
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
this.ws.on('error', reject);
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
async joinRoom(roomId) {
|
|
51
|
-
if (!this.appId) {
|
|
52
|
-
throw new Error('appId is required. Initialize MODD with appId in constructor.');
|
|
53
|
-
}
|
|
54
|
-
this.roomId = roomId;
|
|
55
|
-
const nodeUrl = this.nodeUrl || await this.getNodeUrl(roomId);
|
|
56
|
-
return new Promise((resolve, reject) => {
|
|
57
|
-
this.ws = new ws_1.default(nodeUrl);
|
|
58
|
-
this.ws.on('open', () => {
|
|
59
|
-
this.ws.send(JSON.stringify({
|
|
60
|
-
type: 'JOIN_ROOM',
|
|
61
|
-
payload: { roomId, appId: this.appId }
|
|
62
|
-
}));
|
|
63
|
-
});
|
|
64
|
-
this.ws.on('message', (data) => {
|
|
65
|
-
const message = JSON.parse(data.toString());
|
|
66
|
-
if (message.type === 'INITIAL_STATE') {
|
|
67
|
-
this.snapshot = message.payload.snapshot;
|
|
68
|
-
const inputs = message.payload.inputs || [];
|
|
69
|
-
// Apply any pending inputs to catch up (in sequence order)
|
|
70
|
-
const sortedInputs = [...inputs].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
|
71
|
-
sortedInputs.forEach((input) => {
|
|
72
|
-
const orderedInput = {
|
|
73
|
-
data: input.data,
|
|
74
|
-
sequenceNumber: input.sequenceNumber,
|
|
75
|
-
timestamp: input.timestamp,
|
|
76
|
-
clientId: input.clientId,
|
|
77
|
-
id: input.id
|
|
78
|
-
};
|
|
79
|
-
this.inputHandlers.forEach(handler => handler(orderedInput));
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
if (message.type === 'ROOM_JOINED') {
|
|
83
|
-
console.log('Joined room:', roomId);
|
|
84
|
-
this.setupMessageHandlers();
|
|
85
|
-
resolve();
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
this.ws.on('error', reject);
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
setupMessageHandlers() {
|
|
92
|
-
if (!this.ws)
|
|
93
|
-
return;
|
|
94
|
-
this.ws.on('message', (data) => {
|
|
95
|
-
const message = JSON.parse(data.toString());
|
|
96
|
-
if (message.type === 'ORDERED_INPUTS') {
|
|
97
|
-
const inputs = message.payload.inputs || [];
|
|
98
|
-
inputs.forEach((input) => {
|
|
99
|
-
const orderedInput = {
|
|
100
|
-
data: input.data,
|
|
101
|
-
sequenceNumber: input.sequenceNumber,
|
|
102
|
-
timestamp: input.timestamp,
|
|
103
|
-
clientId: input.clientId,
|
|
104
|
-
id: input.id
|
|
105
|
-
};
|
|
106
|
-
this.inputHandlers.forEach(handler => handler(orderedInput));
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
if (message.type === 'SNAPSHOT_UPDATE') {
|
|
110
|
-
this.snapshot = message.payload.snapshot;
|
|
111
|
-
this.snapshotHandlers.forEach(handler => handler(this.snapshot));
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
sendInput(data) {
|
|
116
|
-
if (!this.ws || !this.roomId) {
|
|
117
|
-
throw new Error('Not connected to a room');
|
|
118
|
-
}
|
|
119
|
-
this.ws.send(JSON.stringify({
|
|
120
|
-
type: 'SEND_INPUT',
|
|
121
|
-
payload: { roomId: this.roomId, data }
|
|
122
|
-
}));
|
|
123
|
-
}
|
|
124
|
-
sendSnapshot(snapshot) {
|
|
125
|
-
if (!this.ws || !this.roomId) {
|
|
126
|
-
throw new Error('Not connected to a room');
|
|
127
|
-
}
|
|
128
|
-
this.snapshot = snapshot;
|
|
129
|
-
const hash = this.hashSnapshot(snapshot);
|
|
130
|
-
this.ws.send(JSON.stringify({
|
|
131
|
-
type: 'SEND_SNAPSHOT',
|
|
132
|
-
payload: { roomId: this.roomId, snapshot, hash }
|
|
133
|
-
}));
|
|
134
|
-
}
|
|
135
|
-
onInput(handler) {
|
|
136
|
-
this.inputHandlers.push(handler);
|
|
137
|
-
}
|
|
138
|
-
onSnapshot(handler) {
|
|
139
|
-
this.snapshotHandlers.push(handler);
|
|
140
|
-
}
|
|
141
|
-
getSnapshot() {
|
|
142
|
-
return this.snapshot;
|
|
143
|
-
}
|
|
144
|
-
disconnect() {
|
|
145
|
-
if (this.ws) {
|
|
146
|
-
this.ws.close();
|
|
147
|
-
this.ws = null;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
async getNodeUrl(roomId) {
|
|
151
|
-
if (!this.appId) {
|
|
152
|
-
throw new Error('appId is required');
|
|
153
|
-
}
|
|
154
|
-
// Use REST API instead of WebSocket for better error handling
|
|
155
|
-
const httpUrl = this.centralServiceUrl.replace('ws://', 'http://').replace('wss://', 'https://');
|
|
156
|
-
const response = await fetch(`${httpUrl}/api/apps/${this.appId}/rooms/${roomId}/connect`, {
|
|
157
|
-
method: 'POST',
|
|
158
|
-
headers: { 'Content-Type': 'application/json' },
|
|
159
|
-
body: JSON.stringify({})
|
|
160
|
-
});
|
|
161
|
-
if (!response.ok) {
|
|
162
|
-
const error = await response.json().catch(() => ({ error: 'Failed to connect to room' }));
|
|
163
|
-
throw new Error(error.error || `HTTP ${response.status}`);
|
|
164
|
-
}
|
|
165
|
-
const data = await response.json();
|
|
166
|
-
return data.url;
|
|
167
|
-
}
|
|
168
|
-
hashSnapshot(snapshot) {
|
|
169
|
-
const data = JSON.stringify(snapshot);
|
|
170
|
-
return crypto_1.default.createHash('sha256').update(data).digest('hex');
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
exports.MODD = MODD;
|
|
174
|
-
// Backwards-compatible alias
|
|
175
|
-
class MeshClient extends MODD {
|
|
176
|
-
}
|
|
177
|
-
exports.MeshClient = MeshClient;
|
|
17
|
+
__exportStar(require("./modd-network"), exports);
|
|
18
|
+
__exportStar(require("./modd-engine"), exports);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MODD Engine SDK
|
|
3
|
+
* Game synchronization utilities for multiplayer games
|
|
4
|
+
* - Proximity-based authority
|
|
5
|
+
* - Delta compression
|
|
6
|
+
* - Rest detection
|
|
7
|
+
* - Soft sync / interpolation
|
|
8
|
+
*/
|
|
9
|
+
import type { Connection } from './modd-network';
|
|
10
|
+
export interface EntityState {
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
z: number;
|
|
14
|
+
vx?: number;
|
|
15
|
+
vy?: number;
|
|
16
|
+
vz?: number;
|
|
17
|
+
qx?: number;
|
|
18
|
+
qy?: number;
|
|
19
|
+
qz?: number;
|
|
20
|
+
qw?: number;
|
|
21
|
+
avx?: number;
|
|
22
|
+
avy?: number;
|
|
23
|
+
avz?: number;
|
|
24
|
+
[key: string]: any;
|
|
25
|
+
}
|
|
26
|
+
export interface PhysicsAdapter {
|
|
27
|
+
getState(): EntityState;
|
|
28
|
+
setState(state: EntityState): void;
|
|
29
|
+
}
|
|
30
|
+
export interface EngineOptions {
|
|
31
|
+
softSyncLerp?: number;
|
|
32
|
+
positionThreshold?: number;
|
|
33
|
+
velocityThreshold?: number;
|
|
34
|
+
rotationThreshold?: number;
|
|
35
|
+
restVelocityThreshold?: number;
|
|
36
|
+
restFramesRequired?: number;
|
|
37
|
+
touchBonusScore?: number;
|
|
38
|
+
touchBonusDecay?: number;
|
|
39
|
+
}
|
|
40
|
+
export interface Entity {
|
|
41
|
+
id: string;
|
|
42
|
+
adapter: PhysicsAdapter;
|
|
43
|
+
state: EntityState;
|
|
44
|
+
lastSentState?: EntityState;
|
|
45
|
+
restFrames: number;
|
|
46
|
+
lastTouchedBy?: string;
|
|
47
|
+
lastTouchFrame?: number;
|
|
48
|
+
}
|
|
49
|
+
export interface Player {
|
|
50
|
+
id: string;
|
|
51
|
+
x: number;
|
|
52
|
+
y: number;
|
|
53
|
+
z: number;
|
|
54
|
+
dead?: boolean;
|
|
55
|
+
}
|
|
56
|
+
export declare function createEngine(options?: EngineOptions): {
|
|
57
|
+
readonly config: {
|
|
58
|
+
softSyncLerp: number;
|
|
59
|
+
positionThreshold: number;
|
|
60
|
+
velocityThreshold: number;
|
|
61
|
+
rotationThreshold: number;
|
|
62
|
+
restVelocityThreshold: number;
|
|
63
|
+
restFramesRequired: number;
|
|
64
|
+
touchBonusScore: number;
|
|
65
|
+
touchBonusDecay: number;
|
|
66
|
+
};
|
|
67
|
+
setConnection(conn: Connection): void;
|
|
68
|
+
setLocalPlayer(playerId: string): void;
|
|
69
|
+
setFrame(frame: number): void;
|
|
70
|
+
registerEntity(id: string, adapter: PhysicsAdapter): void;
|
|
71
|
+
unregisterEntity(id: string): void;
|
|
72
|
+
getEntity(id: string): Entity | undefined;
|
|
73
|
+
updatePlayer(id: string, x: number, y: number, z: number, dead?: boolean): void;
|
|
74
|
+
removePlayer(id: string): void;
|
|
75
|
+
computeAuthority: (entityId: string) => string | null;
|
|
76
|
+
isLocalAuthority: (entityId: string) => boolean;
|
|
77
|
+
syncFromPhysics(entityId: string): void;
|
|
78
|
+
syncAllFromPhysics(): void;
|
|
79
|
+
sendUpdates: () => void;
|
|
80
|
+
handleEntityState: (id: string, state: EntityState) => void;
|
|
81
|
+
handleEntityTouch: (id: string, touchedBy: string, frame: number) => void;
|
|
82
|
+
checkPlayerTouches(playerId: string, playerX: number, playerZ: number, touchDistance?: number): void;
|
|
83
|
+
checkEntityCollisions(touchDistance?: number): void;
|
|
84
|
+
wakeEntity: (entityId: string) => void;
|
|
85
|
+
isAtRest: (entityId: string) => boolean;
|
|
86
|
+
hasStateChanged: (entityId: string) => boolean;
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Rapier 3D physics adapter
|
|
90
|
+
*/
|
|
91
|
+
export declare function rapierAdapter(body: any): PhysicsAdapter;
|
|
92
|
+
/**
|
|
93
|
+
* Generic 2D physics adapter (for Matter.js, Planck, etc.)
|
|
94
|
+
*/
|
|
95
|
+
export declare function generic2DAdapter(getBody: () => any, setBody: (state: any) => void): PhysicsAdapter;
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* MODD Engine SDK
|
|
4
|
+
* Game synchronization utilities for multiplayer games
|
|
5
|
+
* - Proximity-based authority
|
|
6
|
+
* - Delta compression
|
|
7
|
+
* - Rest detection
|
|
8
|
+
* - Soft sync / interpolation
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.createEngine = createEngine;
|
|
12
|
+
exports.rapierAdapter = rapierAdapter;
|
|
13
|
+
exports.generic2DAdapter = generic2DAdapter;
|
|
14
|
+
// ============================================
|
|
15
|
+
// Engine Implementation
|
|
16
|
+
// ============================================
|
|
17
|
+
function createEngine(options = {}) {
|
|
18
|
+
// Configuration with defaults
|
|
19
|
+
const config = {
|
|
20
|
+
softSyncLerp: options.softSyncLerp ?? 0.5,
|
|
21
|
+
positionThreshold: options.positionThreshold ?? 0.01,
|
|
22
|
+
velocityThreshold: options.velocityThreshold ?? 0.1,
|
|
23
|
+
rotationThreshold: options.rotationThreshold ?? 0.01,
|
|
24
|
+
restVelocityThreshold: options.restVelocityThreshold ?? 0.05,
|
|
25
|
+
restFramesRequired: options.restFramesRequired ?? 30,
|
|
26
|
+
touchBonusScore: options.touchBonusScore ?? 50,
|
|
27
|
+
touchBonusDecay: options.touchBonusDecay ?? 100,
|
|
28
|
+
};
|
|
29
|
+
// State
|
|
30
|
+
const entities = new Map();
|
|
31
|
+
const players = new Map();
|
|
32
|
+
let localPlayerId = null;
|
|
33
|
+
let currentFrame = 0;
|
|
34
|
+
let connection = null;
|
|
35
|
+
// ==========================================
|
|
36
|
+
// Authority Computation
|
|
37
|
+
// ==========================================
|
|
38
|
+
function simpleHash(str) {
|
|
39
|
+
let h = 0;
|
|
40
|
+
for (let i = 0; i < str.length; i++) {
|
|
41
|
+
h = ((h << 5) - h + str.charCodeAt(i)) | 0;
|
|
42
|
+
}
|
|
43
|
+
return Math.abs(h);
|
|
44
|
+
}
|
|
45
|
+
function computeAuthority(entityId) {
|
|
46
|
+
const entity = entities.get(entityId);
|
|
47
|
+
if (!entity)
|
|
48
|
+
return null;
|
|
49
|
+
const playerIds = Array.from(players.keys()).sort();
|
|
50
|
+
if (playerIds.length === 0)
|
|
51
|
+
return null;
|
|
52
|
+
let bestPlayer = null;
|
|
53
|
+
let bestScore = -Infinity;
|
|
54
|
+
for (const pid of playerIds) {
|
|
55
|
+
const player = players.get(pid);
|
|
56
|
+
if (player.dead)
|
|
57
|
+
continue;
|
|
58
|
+
// Distance-based score (closer = higher)
|
|
59
|
+
const dx = player.x - entity.state.x;
|
|
60
|
+
const dz = player.z - entity.state.z;
|
|
61
|
+
const dist = Math.sqrt(dx * dx + dz * dz);
|
|
62
|
+
let score = 1000 - dist * 10;
|
|
63
|
+
// Touch bonus (decays over time)
|
|
64
|
+
if (entity.lastTouchedBy === pid) {
|
|
65
|
+
const framesSinceTouch = currentFrame - (entity.lastTouchFrame || 0);
|
|
66
|
+
const decayRate = config.touchBonusScore / config.touchBonusDecay;
|
|
67
|
+
score += Math.max(0, config.touchBonusScore - framesSinceTouch * decayRate);
|
|
68
|
+
}
|
|
69
|
+
// Deterministic tiebreaker
|
|
70
|
+
score += simpleHash(pid + entityId) * 0.0001;
|
|
71
|
+
if (score > bestScore) {
|
|
72
|
+
bestScore = score;
|
|
73
|
+
bestPlayer = pid;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return bestPlayer;
|
|
77
|
+
}
|
|
78
|
+
function isLocalAuthority(entityId) {
|
|
79
|
+
return localPlayerId !== null && computeAuthority(entityId) === localPlayerId;
|
|
80
|
+
}
|
|
81
|
+
// ==========================================
|
|
82
|
+
// Delta Compression
|
|
83
|
+
// ==========================================
|
|
84
|
+
function hasStateChanged(entityId) {
|
|
85
|
+
const entity = entities.get(entityId);
|
|
86
|
+
if (!entity || !entity.lastSentState)
|
|
87
|
+
return true;
|
|
88
|
+
const curr = entity.state;
|
|
89
|
+
const last = entity.lastSentState;
|
|
90
|
+
// Position
|
|
91
|
+
if (Math.abs(curr.x - last.x) > config.positionThreshold)
|
|
92
|
+
return true;
|
|
93
|
+
if (Math.abs(curr.y - last.y) > config.positionThreshold)
|
|
94
|
+
return true;
|
|
95
|
+
if (Math.abs(curr.z - last.z) > config.positionThreshold)
|
|
96
|
+
return true;
|
|
97
|
+
// Velocity
|
|
98
|
+
if (Math.abs((curr.vx || 0) - (last.vx || 0)) > config.velocityThreshold)
|
|
99
|
+
return true;
|
|
100
|
+
if (Math.abs((curr.vy || 0) - (last.vy || 0)) > config.velocityThreshold)
|
|
101
|
+
return true;
|
|
102
|
+
if (Math.abs((curr.vz || 0) - (last.vz || 0)) > config.velocityThreshold)
|
|
103
|
+
return true;
|
|
104
|
+
// Rotation
|
|
105
|
+
if (Math.abs((curr.qx || 0) - (last.qx || 0)) > config.rotationThreshold)
|
|
106
|
+
return true;
|
|
107
|
+
if (Math.abs((curr.qy || 0) - (last.qy || 0)) > config.rotationThreshold)
|
|
108
|
+
return true;
|
|
109
|
+
if (Math.abs((curr.qz || 0) - (last.qz || 0)) > config.rotationThreshold)
|
|
110
|
+
return true;
|
|
111
|
+
if (Math.abs((curr.qw || 1) - (last.qw || 1)) > config.rotationThreshold)
|
|
112
|
+
return true;
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
// ==========================================
|
|
116
|
+
// Rest Detection
|
|
117
|
+
// ==========================================
|
|
118
|
+
function isAtRest(entityId) {
|
|
119
|
+
const entity = entities.get(entityId);
|
|
120
|
+
if (!entity)
|
|
121
|
+
return false;
|
|
122
|
+
const state = entity.state;
|
|
123
|
+
const speed = Math.sqrt((state.vx || 0) ** 2 +
|
|
124
|
+
(state.vy || 0) ** 2 +
|
|
125
|
+
(state.vz || 0) ** 2);
|
|
126
|
+
const angSpeed = Math.sqrt((state.avx || 0) ** 2 +
|
|
127
|
+
(state.avy || 0) ** 2 +
|
|
128
|
+
(state.avz || 0) ** 2);
|
|
129
|
+
if (speed < config.restVelocityThreshold && angSpeed < config.restVelocityThreshold) {
|
|
130
|
+
entity.restFrames++;
|
|
131
|
+
return entity.restFrames > config.restFramesRequired;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
entity.restFrames = 0;
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function wakeEntity(entityId) {
|
|
139
|
+
const entity = entities.get(entityId);
|
|
140
|
+
if (entity) {
|
|
141
|
+
entity.restFrames = 0;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// ==========================================
|
|
145
|
+
// Soft Sync
|
|
146
|
+
// ==========================================
|
|
147
|
+
function applySoftSync(entityId, targetState) {
|
|
148
|
+
const entity = entities.get(entityId);
|
|
149
|
+
if (!entity)
|
|
150
|
+
return;
|
|
151
|
+
// Skip if we're authority
|
|
152
|
+
if (isLocalAuthority(entityId))
|
|
153
|
+
return;
|
|
154
|
+
const lerp = config.softSyncLerp;
|
|
155
|
+
// Lerp position
|
|
156
|
+
entity.state.x += (targetState.x - entity.state.x) * lerp;
|
|
157
|
+
entity.state.y += (targetState.y - entity.state.y) * lerp;
|
|
158
|
+
entity.state.z += (targetState.z - entity.state.z) * lerp;
|
|
159
|
+
// Hard sync velocity
|
|
160
|
+
entity.state.vx = targetState.vx;
|
|
161
|
+
entity.state.vy = targetState.vy;
|
|
162
|
+
entity.state.vz = targetState.vz;
|
|
163
|
+
// Hard sync rotation
|
|
164
|
+
entity.state.qx = targetState.qx;
|
|
165
|
+
entity.state.qy = targetState.qy;
|
|
166
|
+
entity.state.qz = targetState.qz;
|
|
167
|
+
entity.state.qw = targetState.qw;
|
|
168
|
+
// Hard sync angular velocity
|
|
169
|
+
entity.state.avx = targetState.avx;
|
|
170
|
+
entity.state.avy = targetState.avy;
|
|
171
|
+
entity.state.avz = targetState.avz;
|
|
172
|
+
// Apply to physics
|
|
173
|
+
entity.adapter.setState(entity.state);
|
|
174
|
+
// Wake the entity
|
|
175
|
+
wakeEntity(entityId);
|
|
176
|
+
}
|
|
177
|
+
// ==========================================
|
|
178
|
+
// Network Integration
|
|
179
|
+
// ==========================================
|
|
180
|
+
function sendEntityUpdates() {
|
|
181
|
+
if (!connection?.connected)
|
|
182
|
+
return;
|
|
183
|
+
for (const [id, entity] of entities) {
|
|
184
|
+
// Skip if not authority
|
|
185
|
+
if (!isLocalAuthority(id))
|
|
186
|
+
continue;
|
|
187
|
+
// Skip if at rest
|
|
188
|
+
if (isAtRest(id))
|
|
189
|
+
continue;
|
|
190
|
+
// Skip if no meaningful change
|
|
191
|
+
if (!hasStateChanged(id))
|
|
192
|
+
continue;
|
|
193
|
+
// Sync state from physics
|
|
194
|
+
entity.state = entity.adapter.getState();
|
|
195
|
+
// Send update
|
|
196
|
+
connection.send({
|
|
197
|
+
type: 'entityState',
|
|
198
|
+
id,
|
|
199
|
+
...entity.state
|
|
200
|
+
});
|
|
201
|
+
// Remember what we sent
|
|
202
|
+
entity.lastSentState = { ...entity.state };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function handleEntityState(id, state) {
|
|
206
|
+
const entity = entities.get(id);
|
|
207
|
+
if (!entity)
|
|
208
|
+
return;
|
|
209
|
+
// Skip if we're authority
|
|
210
|
+
if (isLocalAuthority(id))
|
|
211
|
+
return;
|
|
212
|
+
applySoftSync(id, state);
|
|
213
|
+
}
|
|
214
|
+
function handleEntityTouch(id, touchedBy, frame) {
|
|
215
|
+
const entity = entities.get(id);
|
|
216
|
+
if (entity) {
|
|
217
|
+
entity.lastTouchedBy = touchedBy;
|
|
218
|
+
entity.lastTouchFrame = frame;
|
|
219
|
+
wakeEntity(id);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// ==========================================
|
|
223
|
+
// Public API
|
|
224
|
+
// ==========================================
|
|
225
|
+
return {
|
|
226
|
+
// Configuration
|
|
227
|
+
get config() {
|
|
228
|
+
return { ...config };
|
|
229
|
+
},
|
|
230
|
+
// Setup
|
|
231
|
+
setConnection(conn) {
|
|
232
|
+
connection = conn;
|
|
233
|
+
},
|
|
234
|
+
setLocalPlayer(playerId) {
|
|
235
|
+
localPlayerId = playerId;
|
|
236
|
+
},
|
|
237
|
+
setFrame(frame) {
|
|
238
|
+
currentFrame = frame;
|
|
239
|
+
},
|
|
240
|
+
// Entity management
|
|
241
|
+
registerEntity(id, adapter) {
|
|
242
|
+
entities.set(id, {
|
|
243
|
+
id,
|
|
244
|
+
adapter,
|
|
245
|
+
state: adapter.getState(),
|
|
246
|
+
restFrames: 0
|
|
247
|
+
});
|
|
248
|
+
},
|
|
249
|
+
unregisterEntity(id) {
|
|
250
|
+
entities.delete(id);
|
|
251
|
+
},
|
|
252
|
+
getEntity(id) {
|
|
253
|
+
return entities.get(id);
|
|
254
|
+
},
|
|
255
|
+
// Player management (for authority calculation)
|
|
256
|
+
updatePlayer(id, x, y, z, dead = false) {
|
|
257
|
+
players.set(id, { id, x, y, z, dead });
|
|
258
|
+
},
|
|
259
|
+
removePlayer(id) {
|
|
260
|
+
players.delete(id);
|
|
261
|
+
},
|
|
262
|
+
// Authority
|
|
263
|
+
computeAuthority,
|
|
264
|
+
isLocalAuthority,
|
|
265
|
+
// Sync operations
|
|
266
|
+
syncFromPhysics(entityId) {
|
|
267
|
+
const entity = entities.get(entityId);
|
|
268
|
+
if (entity) {
|
|
269
|
+
entity.state = entity.adapter.getState();
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
syncAllFromPhysics() {
|
|
273
|
+
for (const entity of entities.values()) {
|
|
274
|
+
entity.state = entity.adapter.getState();
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
// Network operations (call each tick)
|
|
278
|
+
sendUpdates: sendEntityUpdates,
|
|
279
|
+
// Input handlers (call from processInput)
|
|
280
|
+
handleEntityState,
|
|
281
|
+
handleEntityTouch,
|
|
282
|
+
// Touch detection (call after physics step)
|
|
283
|
+
checkPlayerTouches(playerId, playerX, playerZ, touchDistance = 2.0) {
|
|
284
|
+
if (!connection?.connected || playerId !== localPlayerId)
|
|
285
|
+
return;
|
|
286
|
+
for (const [id, entity] of entities) {
|
|
287
|
+
const dx = playerX - entity.state.x;
|
|
288
|
+
const dz = playerZ - entity.state.z;
|
|
289
|
+
const dist = Math.sqrt(dx * dx + dz * dz);
|
|
290
|
+
if (dist < touchDistance && entity.lastTouchedBy !== playerId) {
|
|
291
|
+
connection.send({
|
|
292
|
+
type: 'entityTouch',
|
|
293
|
+
id,
|
|
294
|
+
touchedBy: playerId,
|
|
295
|
+
frame: currentFrame
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
checkEntityCollisions(touchDistance = 4.0) {
|
|
301
|
+
if (!connection?.connected)
|
|
302
|
+
return;
|
|
303
|
+
const entityIds = Array.from(entities.keys());
|
|
304
|
+
for (let i = 0; i < entityIds.length; i++) {
|
|
305
|
+
const idA = entityIds[i];
|
|
306
|
+
if (!isLocalAuthority(idA))
|
|
307
|
+
continue;
|
|
308
|
+
const entityA = entities.get(idA);
|
|
309
|
+
for (let j = i + 1; j < entityIds.length; j++) {
|
|
310
|
+
const idB = entityIds[j];
|
|
311
|
+
const entityB = entities.get(idB);
|
|
312
|
+
const dx = entityA.state.x - entityB.state.x;
|
|
313
|
+
const dz = entityA.state.z - entityB.state.z;
|
|
314
|
+
const dist = Math.sqrt(dx * dx + dz * dz);
|
|
315
|
+
if (dist < touchDistance && entityB.lastTouchedBy !== localPlayerId) {
|
|
316
|
+
connection.send({
|
|
317
|
+
type: 'entityTouch',
|
|
318
|
+
id: idB,
|
|
319
|
+
touchedBy: localPlayerId,
|
|
320
|
+
frame: currentFrame
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
// Utility
|
|
327
|
+
wakeEntity,
|
|
328
|
+
isAtRest,
|
|
329
|
+
hasStateChanged,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
// ============================================
|
|
333
|
+
// Physics Adapters
|
|
334
|
+
// ============================================
|
|
335
|
+
/**
|
|
336
|
+
* Rapier 3D physics adapter
|
|
337
|
+
*/
|
|
338
|
+
function rapierAdapter(body) {
|
|
339
|
+
return {
|
|
340
|
+
getState() {
|
|
341
|
+
const pos = body.translation();
|
|
342
|
+
const vel = body.linvel();
|
|
343
|
+
const rot = body.rotation();
|
|
344
|
+
const angvel = body.angvel();
|
|
345
|
+
return {
|
|
346
|
+
x: pos.x, y: pos.y, z: pos.z,
|
|
347
|
+
vx: vel.x, vy: vel.y, vz: vel.z,
|
|
348
|
+
qx: rot.x, qy: rot.y, qz: rot.z, qw: rot.w,
|
|
349
|
+
avx: angvel.x, avy: angvel.y, avz: angvel.z
|
|
350
|
+
};
|
|
351
|
+
},
|
|
352
|
+
setState(state) {
|
|
353
|
+
body.setTranslation({ x: state.x, y: state.y, z: state.z }, true);
|
|
354
|
+
body.setLinvel({ x: state.vx || 0, y: state.vy || 0, z: state.vz || 0 }, true);
|
|
355
|
+
body.setRotation({ x: state.qx || 0, y: state.qy || 0, z: state.qz || 0, w: state.qw || 1 }, true);
|
|
356
|
+
body.setAngvel({ x: state.avx || 0, y: state.avy || 0, z: state.avz || 0 }, true);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Generic 2D physics adapter (for Matter.js, Planck, etc.)
|
|
362
|
+
*/
|
|
363
|
+
function generic2DAdapter(getBody, setBody) {
|
|
364
|
+
return {
|
|
365
|
+
getState() {
|
|
366
|
+
const body = getBody();
|
|
367
|
+
return {
|
|
368
|
+
x: body.x || body.position?.x || 0,
|
|
369
|
+
y: body.y || body.position?.y || 0,
|
|
370
|
+
z: 0,
|
|
371
|
+
vx: body.vx || body.velocity?.x || 0,
|
|
372
|
+
vy: body.vy || body.velocity?.y || 0,
|
|
373
|
+
vz: 0
|
|
374
|
+
};
|
|
375
|
+
},
|
|
376
|
+
setState(state) {
|
|
377
|
+
setBody({
|
|
378
|
+
x: state.x,
|
|
379
|
+
y: state.y,
|
|
380
|
+
vx: state.vx,
|
|
381
|
+
vy: state.vy
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
// Browser global fallback
|
|
387
|
+
if (typeof window !== 'undefined') {
|
|
388
|
+
window.moddEngine = { createEngine, rapierAdapter, generic2DAdapter };
|
|
389
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MODD Network SDK
|
|
3
|
+
* Pure network layer for real-time multiplayer applications
|
|
4
|
+
* Works with any app type: games, chat, collaboration, etc.
|
|
5
|
+
*/
|
|
6
|
+
export interface ConnectOptions {
|
|
7
|
+
appId?: string;
|
|
8
|
+
snapshot?: any;
|
|
9
|
+
user?: any;
|
|
10
|
+
centralServiceUrl?: string;
|
|
11
|
+
creatorNodeId?: string;
|
|
12
|
+
getStateHash?: () => string;
|
|
13
|
+
onConnect?: (snapshot: any, inputs: Input[]) => void;
|
|
14
|
+
onDisconnect?: () => void;
|
|
15
|
+
onError?: (error: string) => void;
|
|
16
|
+
onMessage?: (data: any, seq: number) => void;
|
|
17
|
+
onTick?: (frame: number, inputs: Input[], isCatchUp?: boolean) => void;
|
|
18
|
+
onSnapshot?: (snapshot: any, hash: string) => void;
|
|
19
|
+
}
|
|
20
|
+
export interface Input {
|
|
21
|
+
sequenceNumber: number;
|
|
22
|
+
data: any;
|
|
23
|
+
clientId?: string;
|
|
24
|
+
timestamp?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface Connection {
|
|
27
|
+
send(data: any): void;
|
|
28
|
+
sendSnapshot(snapshot: any, hash: string): void;
|
|
29
|
+
leaveRoom(): void;
|
|
30
|
+
close(): void;
|
|
31
|
+
readonly connected: boolean;
|
|
32
|
+
readonly node: string | null;
|
|
33
|
+
readonly bandwidthIn: number;
|
|
34
|
+
readonly bandwidthOut: number;
|
|
35
|
+
readonly totalBytesIn: number;
|
|
36
|
+
readonly totalBytesOut: number;
|
|
37
|
+
readonly frame: number;
|
|
38
|
+
}
|
|
39
|
+
export declare function connect(roomId: string, options?: ConnectOptions): Promise<Connection>;
|
|
40
|
+
export declare const modd: typeof connect;
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* MODD Network SDK
|
|
4
|
+
* Pure network layer for real-time multiplayer applications
|
|
5
|
+
* Works with any app type: games, chat, collaboration, etc.
|
|
6
|
+
*/
|
|
7
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
8
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
9
|
+
};
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.modd = void 0;
|
|
12
|
+
exports.connect = connect;
|
|
13
|
+
const ws_1 = __importDefault(require("ws"));
|
|
14
|
+
// ============================================
|
|
15
|
+
// Binary Protocol
|
|
16
|
+
// ============================================
|
|
17
|
+
const BinaryMessageType = {
|
|
18
|
+
TICK: 0x01,
|
|
19
|
+
INITIAL_STATE: 0x02,
|
|
20
|
+
ROOM_JOINED: 0x03,
|
|
21
|
+
ROOM_CREATED: 0x04,
|
|
22
|
+
ERROR: 0x05,
|
|
23
|
+
SNAPSHOT_UPDATE: 0x06,
|
|
24
|
+
ROOM_LEFT: 0x07,
|
|
25
|
+
SYNC_HASH: 0x08
|
|
26
|
+
};
|
|
27
|
+
function encodeSyncHash(roomId, hash, seq, frame) {
|
|
28
|
+
const roomIdBytes = new TextEncoder().encode(roomId);
|
|
29
|
+
const hashBytes = new TextEncoder().encode(hash);
|
|
30
|
+
const totalLen = 1 + 2 + roomIdBytes.length + 2 + hashBytes.length + 4 + 4;
|
|
31
|
+
const buffer = new ArrayBuffer(totalLen);
|
|
32
|
+
const view = new DataView(buffer);
|
|
33
|
+
const uint8 = new Uint8Array(buffer);
|
|
34
|
+
let offset = 0;
|
|
35
|
+
view.setUint8(offset, BinaryMessageType.SYNC_HASH);
|
|
36
|
+
offset += 1;
|
|
37
|
+
view.setUint16(offset, roomIdBytes.length, true);
|
|
38
|
+
offset += 2;
|
|
39
|
+
uint8.set(roomIdBytes, offset);
|
|
40
|
+
offset += roomIdBytes.length;
|
|
41
|
+
view.setUint16(offset, hashBytes.length, true);
|
|
42
|
+
offset += 2;
|
|
43
|
+
uint8.set(hashBytes, offset);
|
|
44
|
+
offset += hashBytes.length;
|
|
45
|
+
view.setUint32(offset, seq, true);
|
|
46
|
+
offset += 4;
|
|
47
|
+
view.setUint32(offset, frame, true);
|
|
48
|
+
return buffer;
|
|
49
|
+
}
|
|
50
|
+
function decodeBinaryMessage(buffer) {
|
|
51
|
+
const view = new DataView(buffer);
|
|
52
|
+
if (buffer.byteLength === 0)
|
|
53
|
+
return null;
|
|
54
|
+
const type = view.getUint8(0);
|
|
55
|
+
switch (type) {
|
|
56
|
+
case BinaryMessageType.TICK: {
|
|
57
|
+
const frame = view.getUint32(1, true);
|
|
58
|
+
let inputs = [];
|
|
59
|
+
if (buffer.byteLength > 5) {
|
|
60
|
+
const inputCount = view.getUint16(5, true);
|
|
61
|
+
if (inputCount > 0) {
|
|
62
|
+
const inputsJson = new TextDecoder().decode(buffer.slice(7));
|
|
63
|
+
inputs = JSON.parse(inputsJson);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { type: 'TICK', frame, inputs };
|
|
67
|
+
}
|
|
68
|
+
case BinaryMessageType.INITIAL_STATE: {
|
|
69
|
+
let offset = 1;
|
|
70
|
+
const frame = view.getUint32(offset, true);
|
|
71
|
+
offset += 4;
|
|
72
|
+
const roomIdLen = view.getUint16(offset, true);
|
|
73
|
+
offset += 2;
|
|
74
|
+
const roomId = new TextDecoder().decode(buffer.slice(offset, offset + roomIdLen));
|
|
75
|
+
offset += roomIdLen;
|
|
76
|
+
const snapshotLen = view.getUint32(offset, true);
|
|
77
|
+
offset += 4;
|
|
78
|
+
const snapshotJson = new TextDecoder().decode(buffer.slice(offset, offset + snapshotLen));
|
|
79
|
+
offset += snapshotLen;
|
|
80
|
+
const inputsLen = view.getUint32(offset, true);
|
|
81
|
+
offset += 4;
|
|
82
|
+
const inputsJson = new TextDecoder().decode(buffer.slice(offset, offset + inputsLen));
|
|
83
|
+
const { snapshot, snapshotHash } = JSON.parse(snapshotJson);
|
|
84
|
+
const inputs = JSON.parse(inputsJson);
|
|
85
|
+
return { type: 'INITIAL_STATE', roomId, frame, snapshot, snapshotHash, inputs };
|
|
86
|
+
}
|
|
87
|
+
case BinaryMessageType.ROOM_JOINED: {
|
|
88
|
+
const roomIdLen = view.getUint16(1, true);
|
|
89
|
+
const roomId = new TextDecoder().decode(buffer.slice(3, 3 + roomIdLen));
|
|
90
|
+
return { type: 'ROOM_JOINED', roomId };
|
|
91
|
+
}
|
|
92
|
+
case BinaryMessageType.ROOM_CREATED: {
|
|
93
|
+
let offset = 1;
|
|
94
|
+
const roomIdLen = view.getUint16(offset, true);
|
|
95
|
+
offset += 2;
|
|
96
|
+
const roomId = new TextDecoder().decode(buffer.slice(offset, offset + roomIdLen));
|
|
97
|
+
offset += roomIdLen;
|
|
98
|
+
const snapshotLen = view.getUint32(offset, true);
|
|
99
|
+
offset += 4;
|
|
100
|
+
const snapshotJson = new TextDecoder().decode(buffer.slice(offset, offset + snapshotLen));
|
|
101
|
+
const { snapshot, snapshotHash } = JSON.parse(snapshotJson);
|
|
102
|
+
return { type: 'ROOM_CREATED', roomId, snapshot, snapshotHash };
|
|
103
|
+
}
|
|
104
|
+
case BinaryMessageType.ERROR: {
|
|
105
|
+
const msgLen = view.getUint16(1, true);
|
|
106
|
+
const message = new TextDecoder().decode(buffer.slice(3, 3 + msgLen));
|
|
107
|
+
return { type: 'ERROR', message };
|
|
108
|
+
}
|
|
109
|
+
case BinaryMessageType.SNAPSHOT_UPDATE: {
|
|
110
|
+
let offset = 1;
|
|
111
|
+
const roomIdLen = view.getUint16(offset, true);
|
|
112
|
+
offset += 2;
|
|
113
|
+
const roomId = new TextDecoder().decode(buffer.slice(offset, offset + roomIdLen));
|
|
114
|
+
offset += roomIdLen;
|
|
115
|
+
const snapshotLen = view.getUint32(offset, true);
|
|
116
|
+
offset += 4;
|
|
117
|
+
const snapshotJson = new TextDecoder().decode(buffer.slice(offset, offset + snapshotLen));
|
|
118
|
+
const { snapshot, snapshotHash } = JSON.parse(snapshotJson);
|
|
119
|
+
return { type: 'SNAPSHOT_UPDATE', roomId, snapshot, snapshotHash };
|
|
120
|
+
}
|
|
121
|
+
case BinaryMessageType.ROOM_LEFT: {
|
|
122
|
+
const roomIdLen = view.getUint16(1, true);
|
|
123
|
+
const roomId = new TextDecoder().decode(buffer.slice(3, 3 + roomIdLen));
|
|
124
|
+
return { type: 'ROOM_LEFT', roomId };
|
|
125
|
+
}
|
|
126
|
+
default:
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Helper to normalize binary data to ArrayBuffer
|
|
131
|
+
async function toArrayBuffer(data) {
|
|
132
|
+
if (data instanceof ArrayBuffer) {
|
|
133
|
+
return data;
|
|
134
|
+
}
|
|
135
|
+
if (typeof Blob !== 'undefined' && data instanceof Blob) {
|
|
136
|
+
return await data.arrayBuffer();
|
|
137
|
+
}
|
|
138
|
+
// Fallback for Buffer, ArrayBufferView, or array-like
|
|
139
|
+
// creating a new Uint8Array(data) copies the data to a new ArrayBuffer
|
|
140
|
+
return new Uint8Array(data).buffer;
|
|
141
|
+
}
|
|
142
|
+
// ============================================
|
|
143
|
+
// Main Connect Function
|
|
144
|
+
// ============================================
|
|
145
|
+
async function connect(roomId, options = {}) {
|
|
146
|
+
const appId = options.appId || 'app';
|
|
147
|
+
const initialSnapshot = options.snapshot || {};
|
|
148
|
+
const user = options.user || null;
|
|
149
|
+
const onConnect = options.onConnect || (() => { });
|
|
150
|
+
const onDisconnect = options.onDisconnect || (() => { });
|
|
151
|
+
const onError = options.onError || ((err) => console.error('[modd-network]', err));
|
|
152
|
+
const onMessage = options.onMessage || (() => { });
|
|
153
|
+
const onTick = options.onTick || null;
|
|
154
|
+
const getStateHash = options.getStateHash || null;
|
|
155
|
+
// Determine central service URL
|
|
156
|
+
const centralServiceUrl = options.centralServiceUrl || (typeof window !== 'undefined' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
|
|
157
|
+
? 'http://localhost:9001'
|
|
158
|
+
: 'https://nodes.modd.io');
|
|
159
|
+
let connected = false;
|
|
160
|
+
let initialStateReceived = null;
|
|
161
|
+
let deliveredSeqs = new Set();
|
|
162
|
+
let pendingTicks = [];
|
|
163
|
+
let ws = null;
|
|
164
|
+
let nodeUrl = null;
|
|
165
|
+
// Bandwidth tracking
|
|
166
|
+
let bytesIn = 0;
|
|
167
|
+
let bytesOut = 0;
|
|
168
|
+
let lastBytesIn = 0;
|
|
169
|
+
let lastBytesOut = 0;
|
|
170
|
+
let bandwidthIn = 0;
|
|
171
|
+
let bandwidthOut = 0;
|
|
172
|
+
let bandwidthInterval = null;
|
|
173
|
+
// Hash sync tracking
|
|
174
|
+
let hashInterval = null;
|
|
175
|
+
let lastSyncSeq = 0;
|
|
176
|
+
let lastSyncFrame = 0;
|
|
177
|
+
let currentFrame = 0;
|
|
178
|
+
try {
|
|
179
|
+
const res = await fetch(`${centralServiceUrl}/api/apps/${appId}/rooms/${roomId}/connect`, {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: { 'Content-Type': 'application/json' },
|
|
182
|
+
body: JSON.stringify({ creatorNodeId: options.creatorNodeId })
|
|
183
|
+
});
|
|
184
|
+
if (!res.ok) {
|
|
185
|
+
throw new Error(`Central service error: ${res.status}`);
|
|
186
|
+
}
|
|
187
|
+
const { url } = await res.json();
|
|
188
|
+
nodeUrl = url;
|
|
189
|
+
// Use imported WebSocket (work in Node) or global WebSocket if present (Browser)
|
|
190
|
+
// Note: importing 'ws' gives a constructor. In browser env, 'ws' module might be shimmed.
|
|
191
|
+
// However, for best compat, let's use globalThis.WebSocket if available, else WS.
|
|
192
|
+
const WS = (typeof globalThis !== 'undefined' && globalThis.WebSocket) ? globalThis.WebSocket : ws_1.default;
|
|
193
|
+
// @ts-ignore - TS might complain about mixing types
|
|
194
|
+
ws = new WS(nodeUrl);
|
|
195
|
+
ws.binaryType = 'arraybuffer'; // Ensure we get ArrayBuffer if possible (in Browser)
|
|
196
|
+
ws.onopen = () => {
|
|
197
|
+
bandwidthInterval = setInterval(() => {
|
|
198
|
+
bandwidthIn = bytesIn - lastBytesIn;
|
|
199
|
+
bandwidthOut = bytesOut - lastBytesOut;
|
|
200
|
+
lastBytesIn = bytesIn;
|
|
201
|
+
lastBytesOut = bytesOut;
|
|
202
|
+
}, 1000);
|
|
203
|
+
if (getStateHash) {
|
|
204
|
+
hashInterval = setInterval(() => {
|
|
205
|
+
if (!connected || !ws || ws.readyState !== 1)
|
|
206
|
+
return;
|
|
207
|
+
try {
|
|
208
|
+
const hash = getStateHash();
|
|
209
|
+
if (hash) {
|
|
210
|
+
const hashMsg = encodeSyncHash(roomId, hash, lastSyncSeq, lastSyncFrame);
|
|
211
|
+
bytesOut += hashMsg.byteLength;
|
|
212
|
+
ws.send(hashMsg);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
console.warn('[modd-network] Error getting state hash:', err);
|
|
217
|
+
}
|
|
218
|
+
}, 1000);
|
|
219
|
+
}
|
|
220
|
+
const joinMsg = JSON.stringify({ type: 'JOIN_ROOM', payload: { roomId } });
|
|
221
|
+
bytesOut += joinMsg.length;
|
|
222
|
+
ws.send(joinMsg);
|
|
223
|
+
};
|
|
224
|
+
ws.onerror = (e) => onError(`Failed to connect to ${nodeUrl}: ${e.message || 'Unknown error'}`);
|
|
225
|
+
ws.onclose = () => {
|
|
226
|
+
connected = false;
|
|
227
|
+
if (bandwidthInterval)
|
|
228
|
+
clearInterval(bandwidthInterval);
|
|
229
|
+
if (hashInterval)
|
|
230
|
+
clearInterval(hashInterval);
|
|
231
|
+
onDisconnect();
|
|
232
|
+
};
|
|
233
|
+
ws.onmessage = async (e) => {
|
|
234
|
+
// Handle various binary formats
|
|
235
|
+
let buffer;
|
|
236
|
+
try {
|
|
237
|
+
buffer = await toArrayBuffer(e.data);
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
console.warn('[modd-network] Failed to read message data:', err);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
bytesIn += buffer.byteLength;
|
|
244
|
+
const msg = decodeBinaryMessage(buffer);
|
|
245
|
+
if (!msg) {
|
|
246
|
+
console.warn('[modd-network] Failed to decode binary message');
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
switch (msg.type) {
|
|
250
|
+
case 'TICK': {
|
|
251
|
+
if (!connected) {
|
|
252
|
+
pendingTicks.push(msg);
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
currentFrame = msg.frame;
|
|
256
|
+
lastSyncFrame = msg.frame;
|
|
257
|
+
if (msg.inputs && msg.inputs.length > 0) {
|
|
258
|
+
const maxSeq = Math.max(...msg.inputs.map(i => i.sequenceNumber || 0));
|
|
259
|
+
if (maxSeq > lastSyncSeq)
|
|
260
|
+
lastSyncSeq = maxSeq;
|
|
261
|
+
}
|
|
262
|
+
if (onTick) {
|
|
263
|
+
const newInputs = msg.inputs.filter(i => !deliveredSeqs.has(i.sequenceNumber));
|
|
264
|
+
newInputs.forEach(i => deliveredSeqs.add(i.sequenceNumber));
|
|
265
|
+
onTick(msg.frame, newInputs);
|
|
266
|
+
}
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
case 'ERROR': {
|
|
270
|
+
if (msg.message === 'Room not found') {
|
|
271
|
+
const createMsg = JSON.stringify({
|
|
272
|
+
type: 'CREATE_ROOM',
|
|
273
|
+
payload: { roomId, appId, snapshot: initialSnapshot }
|
|
274
|
+
});
|
|
275
|
+
bytesOut += createMsg.length;
|
|
276
|
+
ws.send(createMsg);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
onError(msg.message);
|
|
280
|
+
}
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
case 'ROOM_CREATED': {
|
|
284
|
+
connected = true;
|
|
285
|
+
if (user) {
|
|
286
|
+
const joinInputMsg = JSON.stringify({ type: 'SEND_INPUT', payload: { roomId, data: { type: 'join', user } } });
|
|
287
|
+
bytesOut += joinInputMsg.length;
|
|
288
|
+
ws.send(joinInputMsg);
|
|
289
|
+
}
|
|
290
|
+
onConnect(initialSnapshot, []);
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
case 'INITIAL_STATE': {
|
|
294
|
+
initialStateReceived = {
|
|
295
|
+
snapshot: msg.snapshot,
|
|
296
|
+
inputs: msg.inputs || [],
|
|
297
|
+
frame: msg.frame
|
|
298
|
+
};
|
|
299
|
+
lastSyncFrame = msg.frame;
|
|
300
|
+
if (msg.inputs && msg.inputs.length > 0) {
|
|
301
|
+
const maxSeq = Math.max(...msg.inputs.map(i => i.sequenceNumber || 0));
|
|
302
|
+
if (maxSeq > lastSyncSeq)
|
|
303
|
+
lastSyncSeq = maxSeq;
|
|
304
|
+
}
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
case 'ROOM_JOINED': {
|
|
308
|
+
connected = true;
|
|
309
|
+
if (user) {
|
|
310
|
+
const joinInputMsg = JSON.stringify({ type: 'SEND_INPUT', payload: { roomId, data: { type: 'join', user } } });
|
|
311
|
+
bytesOut += joinInputMsg.length;
|
|
312
|
+
ws.send(joinInputMsg);
|
|
313
|
+
}
|
|
314
|
+
const processInitialState = () => {
|
|
315
|
+
if (initialStateReceived) {
|
|
316
|
+
const { snapshot, inputs, frame } = initialStateReceived;
|
|
317
|
+
onConnect(snapshot, inputs);
|
|
318
|
+
const newInputs = inputs.filter(i => !deliveredSeqs.has(i.sequenceNumber));
|
|
319
|
+
newInputs.forEach(i => deliveredSeqs.add(i.sequenceNumber));
|
|
320
|
+
if (onTick && newInputs.length > 0) {
|
|
321
|
+
onTick(frame, newInputs, true);
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
newInputs.forEach(i => onMessage(i.data, i.sequenceNumber));
|
|
325
|
+
}
|
|
326
|
+
initialStateReceived = null;
|
|
327
|
+
// Process buffered ticks
|
|
328
|
+
if (pendingTicks.length > 0) {
|
|
329
|
+
pendingTicks.sort((a, b) => a.frame - b.frame);
|
|
330
|
+
for (const tickMsg of pendingTicks) {
|
|
331
|
+
if (onTick) {
|
|
332
|
+
const tickInputs = tickMsg.inputs.filter(i => !deliveredSeqs.has(i.sequenceNumber));
|
|
333
|
+
tickInputs.forEach(i => deliveredSeqs.add(i.sequenceNumber));
|
|
334
|
+
if (tickInputs.length > 0 || tickMsg.frame > frame) {
|
|
335
|
+
onTick(tickMsg.frame, tickInputs);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
pendingTicks = [];
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
onConnect(null, []);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
if (initialStateReceived) {
|
|
347
|
+
processInitialState();
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
setTimeout(processInitialState, 100);
|
|
351
|
+
}
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
case 'SNAPSHOT_UPDATE': {
|
|
355
|
+
if (options.onSnapshot) {
|
|
356
|
+
options.onSnapshot(msg.snapshot, msg.snapshotHash);
|
|
357
|
+
}
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
case 'ROOM_LEFT': {
|
|
361
|
+
console.log(`[modd-network] Left room ${msg.roomId}`);
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
// Auto leave on page unload
|
|
367
|
+
if (user && typeof window !== 'undefined') {
|
|
368
|
+
window.addEventListener('beforeunload', () => {
|
|
369
|
+
if (connected && ws) {
|
|
370
|
+
const leaveMsg = JSON.stringify({ type: 'SEND_INPUT', payload: { roomId, data: { type: 'leave', user } } });
|
|
371
|
+
bytesOut += leaveMsg.length;
|
|
372
|
+
ws.send(leaveMsg);
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
send(data) {
|
|
378
|
+
if (connected && ws) {
|
|
379
|
+
const msg = JSON.stringify({ type: 'SEND_INPUT', payload: { roomId, data } });
|
|
380
|
+
bytesOut += msg.length;
|
|
381
|
+
ws.send(msg);
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
sendSnapshot(snapshot, hash) {
|
|
385
|
+
if (connected && ws) {
|
|
386
|
+
const msg = JSON.stringify({ type: 'SEND_SNAPSHOT', payload: { roomId, snapshot, hash } });
|
|
387
|
+
bytesOut += msg.length;
|
|
388
|
+
ws.send(msg);
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
leaveRoom() {
|
|
392
|
+
if (connected && user && ws) {
|
|
393
|
+
const msg = JSON.stringify({ type: 'SEND_INPUT', payload: { roomId, data: { type: 'leave', user } } });
|
|
394
|
+
bytesOut += msg.length;
|
|
395
|
+
ws.send(msg);
|
|
396
|
+
}
|
|
397
|
+
if (ws)
|
|
398
|
+
ws.close();
|
|
399
|
+
},
|
|
400
|
+
close() {
|
|
401
|
+
if (ws)
|
|
402
|
+
ws.close();
|
|
403
|
+
},
|
|
404
|
+
get connected() {
|
|
405
|
+
return connected;
|
|
406
|
+
},
|
|
407
|
+
get node() {
|
|
408
|
+
return nodeUrl ? (nodeUrl.match(/:(\d+)/)?.[1] || nodeUrl) : null;
|
|
409
|
+
},
|
|
410
|
+
get bandwidthIn() {
|
|
411
|
+
return bandwidthIn;
|
|
412
|
+
},
|
|
413
|
+
get bandwidthOut() {
|
|
414
|
+
return bandwidthOut;
|
|
415
|
+
},
|
|
416
|
+
get totalBytesIn() {
|
|
417
|
+
return bytesIn;
|
|
418
|
+
},
|
|
419
|
+
get totalBytesOut() {
|
|
420
|
+
return bytesOut;
|
|
421
|
+
},
|
|
422
|
+
get frame() {
|
|
423
|
+
return currentFrame;
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
onError(`Failed to get node assignment: ${err.message}`);
|
|
429
|
+
return {
|
|
430
|
+
send() { },
|
|
431
|
+
sendSnapshot() { },
|
|
432
|
+
leaveRoom() { },
|
|
433
|
+
close() { },
|
|
434
|
+
get connected() { return false; },
|
|
435
|
+
get node() { return null; },
|
|
436
|
+
get bandwidthIn() { return 0; },
|
|
437
|
+
get bandwidthOut() { return 0; },
|
|
438
|
+
get totalBytesIn() { return 0; },
|
|
439
|
+
get totalBytesOut() { return 0; },
|
|
440
|
+
get frame() { return 0; }
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// Legacy alias for backwards compatibility
|
|
445
|
+
exports.modd = connect;
|
|
446
|
+
// Browser global fallback
|
|
447
|
+
if (typeof window !== 'undefined') {
|
|
448
|
+
window.moddNetwork = { connect, modd: exports.modd };
|
|
449
|
+
}
|
package/package.json
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "modd-network",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "SDK for connecting to mesh network for multiplayer applications",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"files": [
|
|
8
8
|
"dist"
|
|
9
9
|
],
|
|
10
|
+
"browser": {
|
|
11
|
+
"ws": false
|
|
12
|
+
},
|
|
10
13
|
"publishConfig": {
|
|
11
14
|
"access": "public"
|
|
12
15
|
},
|
|
13
16
|
"scripts": {
|
|
14
17
|
"build": "tsc",
|
|
15
|
-
"
|
|
18
|
+
"build:browser": "node build-browser.js",
|
|
19
|
+
"prepublishOnly": "npm run build"
|
|
16
20
|
},
|
|
17
21
|
"keywords": [
|
|
18
22
|
"mesh",
|
|
@@ -28,7 +32,7 @@
|
|
|
28
32
|
"devDependencies": {
|
|
29
33
|
"@types/node": "^20.10.6",
|
|
30
34
|
"@types/ws": "^8.5.10",
|
|
31
|
-
"
|
|
35
|
+
"esbuild": "^0.27.2",
|
|
32
36
|
"typescript": "^5.3.3"
|
|
33
37
|
}
|
|
34
38
|
}
|