modd-network 1.0.2 → 1.0.3
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/binary-input.d.ts +53 -0
- package/dist/binary-input.js +168 -0
- package/dist/game-input-codec.d.ts +55 -0
- package/dist/game-input-codec.js +263 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -1
- package/dist/input-codec.d.ts +33 -0
- package/dist/input-codec.js +192 -0
- package/dist/modd-engine.d.ts +77 -56
- package/dist/modd-engine.js +479 -244
- package/dist/modd-engine.test.d.ts +1 -0
- package/dist/modd-engine.test.js +190 -0
- package/dist/modd-network.d.ts +55 -22
- package/dist/modd-network.js +497 -352
- package/dist/state-codec.d.ts +81 -0
- package/dist/state-codec.js +337 -0
- package/package.json +5 -2
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Input Codec - Compresses readable inputs to binary
|
|
4
|
+
*
|
|
5
|
+
* Game sends: { type: 'keydown', key: 'w' }
|
|
6
|
+
* Wire sends: [0x01, 0x80] (2 bytes)
|
|
7
|
+
*
|
|
8
|
+
* Game sends: { type: 'camera', yaw: 1.5, pitch: 0.3 }
|
|
9
|
+
* Wire sends: [0x02, yaw_lo, yaw_hi, pitch_lo, pitch_hi] (5 bytes)
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.compress = compress;
|
|
13
|
+
exports.compressBatch = compressBatch;
|
|
14
|
+
exports.decompress = decompress;
|
|
15
|
+
exports.decompressBatch = decompressBatch;
|
|
16
|
+
exports.canCompress = canCompress;
|
|
17
|
+
// Message types
|
|
18
|
+
const MSG = {
|
|
19
|
+
KEY: 0x01,
|
|
20
|
+
CAMERA: 0x02,
|
|
21
|
+
SHOOT: 0x03,
|
|
22
|
+
JOIN: 0x04,
|
|
23
|
+
LEAVE: 0x05,
|
|
24
|
+
};
|
|
25
|
+
// Key indices
|
|
26
|
+
const KEY_MAP = {
|
|
27
|
+
'w': 0, 'a': 1, 's': 2, 'd': 3,
|
|
28
|
+
'space': 4, ' ': 4,
|
|
29
|
+
'shift': 5, 'ctrl': 6, 'control': 6,
|
|
30
|
+
'e': 7, 'q': 8, 'r': 9, 'f': 10,
|
|
31
|
+
'mouse0': 11, 'mouse1': 12, 'mouse2': 13,
|
|
32
|
+
};
|
|
33
|
+
const KEY_NAMES = [
|
|
34
|
+
'w', 'a', 's', 'd', 'space', 'shift', 'ctrl',
|
|
35
|
+
'e', 'q', 'r', 'f', 'mouse0', 'mouse1', 'mouse2'
|
|
36
|
+
];
|
|
37
|
+
const KEY_DOWN_BIT = 0x80;
|
|
38
|
+
/**
|
|
39
|
+
* Compress a readable input to binary
|
|
40
|
+
*/
|
|
41
|
+
function compress(input) {
|
|
42
|
+
if (!input || !input.type)
|
|
43
|
+
return null;
|
|
44
|
+
switch (input.type) {
|
|
45
|
+
case 'keydown':
|
|
46
|
+
case 'keyup': {
|
|
47
|
+
const down = input.type === 'keydown';
|
|
48
|
+
const keyIdx = KEY_MAP[input.key?.toLowerCase()] ?? 0;
|
|
49
|
+
return new Uint8Array([MSG.KEY, keyIdx | (down ? KEY_DOWN_BIT : 0)]);
|
|
50
|
+
}
|
|
51
|
+
case 'camera': {
|
|
52
|
+
const buf = new Uint8Array(5);
|
|
53
|
+
buf[0] = MSG.CAMERA;
|
|
54
|
+
// Normalize yaw to [-π, π] to fit in Int16 range
|
|
55
|
+
let yaw = (input.yaw ?? 0) % (Math.PI * 2);
|
|
56
|
+
if (yaw > Math.PI)
|
|
57
|
+
yaw -= Math.PI * 2;
|
|
58
|
+
if (yaw < -Math.PI)
|
|
59
|
+
yaw += Math.PI * 2;
|
|
60
|
+
// Pitch is naturally bounded to [-π/2, π/2]
|
|
61
|
+
const pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, input.pitch ?? 0));
|
|
62
|
+
const yawInt = Math.round(yaw * 10000);
|
|
63
|
+
const pitchInt = Math.round(pitch * 10000);
|
|
64
|
+
buf[1] = yawInt & 0xFF;
|
|
65
|
+
buf[2] = (yawInt >> 8) & 0xFF;
|
|
66
|
+
buf[3] = pitchInt & 0xFF;
|
|
67
|
+
buf[4] = (pitchInt >> 8) & 0xFF;
|
|
68
|
+
return buf;
|
|
69
|
+
}
|
|
70
|
+
case 'shoot':
|
|
71
|
+
return new Uint8Array([MSG.SHOOT]);
|
|
72
|
+
case 'join':
|
|
73
|
+
return new Uint8Array([MSG.JOIN]);
|
|
74
|
+
case 'leave':
|
|
75
|
+
return new Uint8Array([MSG.LEAVE]);
|
|
76
|
+
default:
|
|
77
|
+
// Unknown type - can't compress
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Compress multiple inputs into single buffer
|
|
83
|
+
*/
|
|
84
|
+
function compressBatch(inputs) {
|
|
85
|
+
if (inputs.length === 0)
|
|
86
|
+
return null;
|
|
87
|
+
if (inputs.length === 1)
|
|
88
|
+
return compress(inputs[0]);
|
|
89
|
+
// Calculate total size
|
|
90
|
+
const buffers = [];
|
|
91
|
+
for (const input of inputs) {
|
|
92
|
+
const buf = compress(input);
|
|
93
|
+
if (!buf)
|
|
94
|
+
return null; // Can't compress one = can't compress batch
|
|
95
|
+
buffers.push(buf);
|
|
96
|
+
}
|
|
97
|
+
// Concatenate with length prefix
|
|
98
|
+
const totalSize = buffers.reduce((sum, b) => sum + b.length, 0);
|
|
99
|
+
const result = new Uint8Array(1 + totalSize);
|
|
100
|
+
result[0] = 0x00; // Batch marker
|
|
101
|
+
let offset = 1;
|
|
102
|
+
for (const buf of buffers) {
|
|
103
|
+
result.set(buf, offset);
|
|
104
|
+
offset += buf.length;
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Decompress binary back to readable input
|
|
110
|
+
*/
|
|
111
|
+
function decompress(buf, offset = 0) {
|
|
112
|
+
if (buf.length <= offset)
|
|
113
|
+
return null;
|
|
114
|
+
const type = buf[offset];
|
|
115
|
+
switch (type) {
|
|
116
|
+
case MSG.KEY: {
|
|
117
|
+
const keyByte = buf[offset + 1];
|
|
118
|
+
const down = (keyByte & KEY_DOWN_BIT) !== 0;
|
|
119
|
+
const keyIdx = keyByte & 0x7F;
|
|
120
|
+
return {
|
|
121
|
+
input: {
|
|
122
|
+
type: down ? 'keydown' : 'keyup',
|
|
123
|
+
key: KEY_NAMES[keyIdx] ?? 'unknown'
|
|
124
|
+
},
|
|
125
|
+
bytesRead: 2
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
case MSG.CAMERA: {
|
|
129
|
+
const yawLo = buf[offset + 1];
|
|
130
|
+
const yawHi = buf[offset + 2];
|
|
131
|
+
const pitchLo = buf[offset + 3];
|
|
132
|
+
const pitchHi = buf[offset + 4];
|
|
133
|
+
let yawInt = yawLo | (yawHi << 8);
|
|
134
|
+
let pitchInt = pitchLo | (pitchHi << 8);
|
|
135
|
+
// Sign extend
|
|
136
|
+
if (yawInt > 32767)
|
|
137
|
+
yawInt -= 65536;
|
|
138
|
+
if (pitchInt > 32767)
|
|
139
|
+
pitchInt -= 65536;
|
|
140
|
+
return {
|
|
141
|
+
input: {
|
|
142
|
+
type: 'camera',
|
|
143
|
+
yaw: yawInt / 10000,
|
|
144
|
+
pitch: pitchInt / 10000
|
|
145
|
+
},
|
|
146
|
+
bytesRead: 5
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
case MSG.SHOOT:
|
|
150
|
+
return { input: { type: 'shoot' }, bytesRead: 1 };
|
|
151
|
+
case MSG.JOIN:
|
|
152
|
+
return { input: { type: 'join' }, bytesRead: 1 };
|
|
153
|
+
case MSG.LEAVE:
|
|
154
|
+
return { input: { type: 'leave' }, bytesRead: 1 };
|
|
155
|
+
default:
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Decompress a batch of inputs
|
|
161
|
+
*/
|
|
162
|
+
function decompressBatch(buf) {
|
|
163
|
+
if (buf.length === 0)
|
|
164
|
+
return [];
|
|
165
|
+
// Check for batch marker
|
|
166
|
+
if (buf[0] === 0x00) {
|
|
167
|
+
const results = [];
|
|
168
|
+
let offset = 1;
|
|
169
|
+
while (offset < buf.length) {
|
|
170
|
+
const result = decompress(buf, offset);
|
|
171
|
+
if (!result)
|
|
172
|
+
break;
|
|
173
|
+
results.push(result.input);
|
|
174
|
+
offset += result.bytesRead;
|
|
175
|
+
}
|
|
176
|
+
return results;
|
|
177
|
+
}
|
|
178
|
+
// Single message
|
|
179
|
+
const result = decompress(buf, 0);
|
|
180
|
+
return result ? [result.input] : [];
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Check if an input can be compressed
|
|
184
|
+
* Note: join/leave are excluded because they contain unique player ID data
|
|
185
|
+
*/
|
|
186
|
+
function canCompress(input) {
|
|
187
|
+
if (!input?.type)
|
|
188
|
+
return false;
|
|
189
|
+
// shoot has extra data (position, direction, snapshots) - must be JSON
|
|
190
|
+
// join/leave contain player ID that must be preserved - send as JSON
|
|
191
|
+
return ['keydown', 'keyup', 'camera'].includes(input.type);
|
|
192
|
+
}
|
package/dist/modd-engine.d.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MODD Engine SDK
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
3
|
+
* Unified game networking with state synchronization
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* const game = await moddEngine.connect('my-app-id', 'my-room', {
|
|
7
|
+
* stateSync: { lerpSpeed: 0.3 },
|
|
8
|
+
* onConnect: (snapshot, inputs, frame) => { ... }
|
|
9
|
+
* });
|
|
10
|
+
*
|
|
11
|
+
* game.setLocalPlayer(playerId);
|
|
12
|
+
* game.addPlayer(playerId, { x, y, z });
|
|
13
|
+
* game.registerEntity('box1', adapter);
|
|
14
|
+
* game.tick();
|
|
8
15
|
*/
|
|
9
|
-
import type
|
|
16
|
+
import { type Connection, type ConnectOptions } from './modd-network';
|
|
17
|
+
export { type Connection, type ConnectOptions, type Input } from './modd-network';
|
|
10
18
|
export interface EntityState {
|
|
11
19
|
x: number;
|
|
12
20
|
y: number;
|
|
@@ -27,69 +35,82 @@ export interface PhysicsAdapter {
|
|
|
27
35
|
getState(): EntityState;
|
|
28
36
|
setState(state: EntityState): void;
|
|
29
37
|
}
|
|
30
|
-
export interface
|
|
31
|
-
|
|
38
|
+
export interface StateSyncConfig {
|
|
39
|
+
lerpSpeed?: number;
|
|
32
40
|
positionThreshold?: number;
|
|
33
41
|
velocityThreshold?: number;
|
|
34
42
|
rotationThreshold?: number;
|
|
35
43
|
restVelocityThreshold?: number;
|
|
36
44
|
restFramesRequired?: number;
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
authorityRadius?: number;
|
|
46
|
+
playerSendRate?: number;
|
|
47
|
+
entitySendRate?: number;
|
|
48
|
+
staleThreshold?: number;
|
|
39
49
|
}
|
|
40
|
-
export interface
|
|
50
|
+
export interface GameConnectOptions extends ConnectOptions {
|
|
51
|
+
stateSync?: StateSyncConfig;
|
|
52
|
+
}
|
|
53
|
+
interface GameInternal extends Game {
|
|
54
|
+
setConnection(conn: Connection): void;
|
|
55
|
+
}
|
|
56
|
+
export interface TrackedEntity {
|
|
41
57
|
id: string;
|
|
42
58
|
adapter: PhysicsAdapter;
|
|
43
|
-
|
|
44
|
-
|
|
59
|
+
localState: EntityState;
|
|
60
|
+
targetState: EntityState;
|
|
61
|
+
lastSentState: EntityState;
|
|
45
62
|
restFrames: number;
|
|
46
|
-
|
|
47
|
-
|
|
63
|
+
isAuthority: boolean;
|
|
64
|
+
lastUpdate: number;
|
|
48
65
|
}
|
|
49
|
-
export interface
|
|
66
|
+
export interface TrackedPlayer {
|
|
50
67
|
id: string;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
dead?: boolean;
|
|
68
|
+
state: EntityState;
|
|
69
|
+
targetState: EntityState;
|
|
70
|
+
lastUpdate: number;
|
|
55
71
|
}
|
|
56
|
-
export
|
|
57
|
-
readonly
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
export interface Game {
|
|
73
|
+
readonly connected: boolean;
|
|
74
|
+
readonly room: string | null;
|
|
75
|
+
readonly node: string | null;
|
|
76
|
+
readonly bandwidthIn: number;
|
|
77
|
+
readonly bandwidthOut: number;
|
|
78
|
+
readonly frame: number;
|
|
79
|
+
readonly localPlayerId: string | null;
|
|
80
|
+
readonly config: StateSyncConfig;
|
|
81
|
+
disconnect(): void;
|
|
82
|
+
send(data: any): void;
|
|
83
|
+
setLocalPlayer(id: string): void;
|
|
84
|
+
addPlayer(id: string, initialState: EntityState): void;
|
|
85
|
+
removePlayer(id: string): void;
|
|
86
|
+
getPlayer(id: string): TrackedPlayer | undefined;
|
|
87
|
+
getPlayers(): TrackedPlayer[];
|
|
88
|
+
updateLocalPlayerState(state: EntityState): void;
|
|
89
|
+
isPlayerStale(id: string): boolean;
|
|
90
|
+
getStalePlayers(): TrackedPlayer[];
|
|
70
91
|
registerEntity(id: string, adapter: PhysicsAdapter): void;
|
|
71
92
|
unregisterEntity(id: string): void;
|
|
72
|
-
getEntity(id: string):
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
*/
|
|
93
|
+
getEntity(id: string): TrackedEntity | undefined;
|
|
94
|
+
getEntities(): TrackedEntity[];
|
|
95
|
+
isAuthority(entityId: string): boolean;
|
|
96
|
+
tick(): void;
|
|
97
|
+
processInput(input: any): void;
|
|
98
|
+
}
|
|
99
|
+
declare function createStateSync(options?: StateSyncConfig): GameInternal;
|
|
100
|
+
export declare function connect(appId: string, room: string, options?: GameConnectOptions): Promise<Game>;
|
|
91
101
|
export declare function rapierAdapter(body: any): PhysicsAdapter;
|
|
92
|
-
/**
|
|
93
|
-
* Generic 2D physics adapter (for Matter.js, Planck, etc.)
|
|
94
|
-
*/
|
|
95
102
|
export declare function generic2DAdapter(getBody: () => any, setBody: (state: any) => void): PhysicsAdapter;
|
|
103
|
+
interface InitOptions {
|
|
104
|
+
threeUrl?: string;
|
|
105
|
+
rapierUrl?: string;
|
|
106
|
+
skipThree?: boolean;
|
|
107
|
+
skipRapier?: boolean;
|
|
108
|
+
}
|
|
109
|
+
export declare function init(options?: InitOptions): Promise<{
|
|
110
|
+
THREE: any;
|
|
111
|
+
RAPIER: any;
|
|
112
|
+
}>;
|
|
113
|
+
export declare function getThree(): any;
|
|
114
|
+
export declare function getRapier(): any;
|
|
115
|
+
export { createStateSync };
|
|
116
|
+
export declare const connectToRoom: typeof connect;
|