@colyseus/schema 3.0.0-alpha.11 → 3.0.0-alpha.12

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/README.md CHANGED
@@ -1,55 +1,47 @@
1
1
  <div align="center">
2
- <img src="logo.png?raw=true" />
2
+ <img src="logo.png?raw=true" width="50%" />
3
3
  <br>
4
- <br>
5
-
6
4
  <p>
7
5
  An incremental binary state serializer with delta encoding for games.<br>
8
- Although it was born to be used on <a href="https://github.com/colyseus/colyseus">Colyseus</a>, this library can be used as standalone.
6
+ Made for <a href="https://github.com/colyseus/colyseus">Colyseus</a>, yet can be used standalone.
9
7
  </p>
10
8
  </div>
11
9
 
12
- ## Defining Schema
10
+ # Features
11
+
12
+ - Flexible Schema Definition
13
+ - Optimized Data Encoding
14
+ - Automatic State Synchronization
15
+ - Client-side Change Detection
16
+ - Per-client portions of the state
17
+ - Type Safety
18
+ - *...decoders available for multiple languages (C#, Lua, Haxe)*
13
19
 
14
- As Colyseus is written in TypeScript, the schema is defined as type annotations inside the state class. Additional server logic may be added to that class, but client-side generated (not implemented) files will consider only the schema itself.
20
+ ## Schema definition
21
+
22
+ `@colyseus/schema` uses type annotations to define types of synchronized properties.
15
23
 
16
24
  ```typescript
17
25
  import { Schema, type, ArraySchema, MapSchema } from '@colyseus/schema';
18
26
 
19
27
  export class Player extends Schema {
20
- @type("string")
21
- name: string;
22
-
23
- @type("number")
24
- x: number;
25
-
26
- @type("number")
27
- y: number;
28
+ @type("string") name: string;
29
+ @type("number") x: number;
30
+ @type("number") y: number;
28
31
  }
29
32
 
30
- export class State extends Schema {
31
- @type('string')
32
- fieldString: string;
33
-
34
- @type('number') // varint
35
- fieldNumber: number;
36
-
37
- @type(Player)
38
- player: Player;
39
-
40
- @type([ Player ])
41
- arrayOfPlayers: ArraySchema<Player>;
42
-
43
- @type({ map: Player })
44
- mapOfPlayers: MapSchema<Player>;
33
+ export class MyState extends Schema {
34
+ @type('string') fieldString: string;
35
+ @type('number') fieldNumber: number;
36
+ @type(Player) player: Player;
37
+ @type([ Player ]) arrayOfPlayers: ArraySchema<Player>;
38
+ @type({ map: Player }) mapOfPlayers: MapSchema<Player>;
45
39
  }
46
40
  ```
47
41
 
48
- See [example](test/Schema.ts).
49
-
50
42
  ## Supported types
51
43
 
52
- ## Primitive Types
44
+ ### Primitive Types
53
45
 
54
46
  | Type | Description | Limitation |
55
47
  |------|-------------|------------|
@@ -79,14 +71,14 @@ name: string;
79
71
  name: number;
80
72
  ```
81
73
 
82
- #### Custom `Schema` type
74
+ #### Child `Schema` structures
83
75
 
84
76
  ```typescript
85
77
  @type(Player)
86
78
  player: Player;
87
79
  ```
88
80
 
89
- #### Array of custom `Schema` type
81
+ #### Array of `Schema` structure
90
82
 
91
83
  ```typescript
92
84
  @type([ Player ])
@@ -105,7 +97,7 @@ arrayOfNumbers: ArraySchema<number>;
105
97
  arrayOfStrings: ArraySchema<string>;
106
98
  ```
107
99
 
108
- #### Map of custom `Schema` type
100
+ #### Map of `Schema` structure
109
101
 
110
102
  ```typescript
111
103
  @type({ map: Player })
@@ -114,7 +106,7 @@ mapOfPlayers: MapSchema<Player>;
114
106
 
115
107
  #### Map of a primitive type
116
108
 
117
- You can't mix types inside maps.
109
+ You can't mix primitive types inside maps.
118
110
 
119
111
  ```typescript
120
112
  @type({ map: "number" })
@@ -124,16 +116,6 @@ mapOfNumbers: MapSchema<number>;
124
116
  mapOfStrings: MapSchema<string>;
125
117
  ```
126
118
 
127
- ### Backwards/forwards compability
128
-
129
- Backwards/fowards compatibility is possible by declaring new fields at the
130
- end of existing structures, and earlier declarations to not be removed, but
131
- be marked `@deprecated()` when needed.
132
-
133
- This is particularly useful for native-compiled targets, such as C#, C++,
134
- Haxe, etc - where the client-side can potentially not have the most
135
- up-to-date version of the schema definitions.
136
-
137
119
  ### Reflection
138
120
 
139
121
  The Schema definitions can encode itself through `Reflection`. You can have the
@@ -144,10 +126,8 @@ reflection to the client-side, for example:
144
126
  import { Schema, type, Reflection } from "@colyseus/schema";
145
127
 
146
128
  class MyState extends Schema {
147
- @type("string")
148
- currentTurn: string;
149
-
150
- // more definitions relating to more Schema types.
129
+ @type("string") currentTurn: string;
130
+ // ... more definitions
151
131
  }
152
132
 
153
133
  // send `encodedStateSchema` across the network
@@ -157,22 +137,113 @@ const encodedStateSchema = Reflection.encode(new MyState());
157
137
  const myState = Reflection.decode(encodedStateSchema);
158
138
  ```
159
139
 
160
- ### Data filters
140
+ ### `StateView` / `@view()`
161
141
 
162
- On the example below, considering we're making a card game, we are filtering the cards to be available only for the owner of the cards, or if the card has been flagged as `"revealed"`.
142
+ You can use `@view()` to filter properties that should be sent only to `StateView`'s that have access to it.
163
143
 
164
144
  ```typescript
165
- import { Schema, type, filter } from "@colyseus/schema";
166
-
167
- export class State extends Schema {
168
- @filterChildren(function(client: any, key: string, value: Card, root: State) {
169
- return (value.ownerId === client.sessionId) || value.revealed;
170
- })
171
- @type({ map: Card })
172
- cards = new MapSchema<Card>();
145
+ import { Schema, type, view } from "@colyseus/schema";
146
+
147
+ class Player extends Schema {
148
+ @view() @type("string") secret: string;
149
+ @type("string") notSecret: string;
173
150
  }
151
+
152
+ class MyState extends Schema {
153
+ @type({ map: Player }) players = new MapSchema<Player>();
154
+ }
155
+ ```
156
+
157
+ Using the `StateView`
158
+
159
+ ```typescript
160
+ const view = new StateView();
161
+ view.add(player);
162
+ ```
163
+
164
+ ## Encoder
165
+
166
+ There are 3 majour features of the `Encoder` class:
167
+
168
+ - Encoding the full state
169
+ - Encoding the state changes
170
+ - Encoding state with filters (properties using `@view()` tag)
171
+
172
+ ```typescript
173
+ import { Encoder } from "@colyseus/schema";
174
+
175
+ const state = new MyState();
176
+ const encoder = new Encoder(state);
177
+ ```
178
+
179
+ New clients must receive the full state on their first connection:
180
+
181
+ ```typescript
182
+ const fullEncode = encoder.encodeAll();
183
+ // ... send "fullEncode" to client and decode it
184
+ ```
185
+
186
+ Further state changes must be sent in order:
187
+
188
+ ```typescript
189
+ const changesBuffer = encoder.encode();
190
+ // ... send "changesBuffer" to client and decode it
191
+ ```
192
+
193
+ ### Encoding with views
194
+
195
+ When using `@view()` and `StateView`'s, a single "full encode" must be used for multiple views. Each view also must add its own changes.
196
+
197
+ ```typescript
198
+ // shared buffer iterator
199
+ const it = { offset: 0 };
200
+
201
+ // shared full encode
202
+ encoder.encodeAll(it);
203
+ const sharedOffset = it.offset;
204
+
205
+ // view 1
206
+ const fullEncode1 = encoder.encodeAllView(view1, sharedOffset, it);
207
+ // ... send "fullEncode1" to client1 and decode it
208
+
209
+ // view 2
210
+ const fullEncode2 = encoder.encodeAllView(view2, sharedOffset, it);
211
+ // ... send "fullEncode" to client2 and decode it
212
+ ```
213
+
214
+ Encoding changes per views:
215
+
216
+ ```typescript
217
+ // shared buffer iterator
218
+ const it = { offset: 0 };
219
+
220
+ // shared changes encode
221
+ encoder.encode(it);
222
+ const sharedOffset = it.offset;
223
+
224
+ // view 1
225
+ const view1Encoded = this.encoder.encodeView(view1, sharedOffset, it);
226
+ // ... send "view1Encoded" to client1 and decode it
227
+
228
+ // view 2
229
+ const view2Encoded = this.encoder.encodeView(view2, sharedOffset, it);
230
+ // ... send "view2Encoded" to client2 and decode it
231
+
232
+ // discard all changes after encoding is done.
233
+ encoder.discardChanges();
174
234
  ```
175
235
 
236
+ ### Backwards/forwards compability
237
+
238
+ Backwards/fowards compatibility is possible by declaring new fields at the
239
+ end of existing structures, and earlier declarations to not be removed, but
240
+ be marked `@deprecated()` when needed.
241
+
242
+ This is particularly useful for native-compiled targets, such as C#, C++,
243
+ Haxe, etc - where the client-side can potentially not have the most
244
+ up-to-date version of the schema definitions.
245
+
246
+
176
247
  ## Limitations and best practices
177
248
 
178
249
  - Each `Schema` structure can hold up to `64` fields. If you need more fields, use nested structures.
@@ -184,7 +255,6 @@ export class State extends Schema {
184
255
  - `@colyseus/schema` encodes only field values in the specified order.
185
256
  - Both encoder (server) and decoder (client) must have same schema definition.
186
257
  - The order of the fields must be the same.
187
- - Avoid manipulating indexes of an array. This result in at least `2` extra bytes for each index change. **Example:** If you have an array of 20 items, and remove the first item (through `shift()`) this means `38` extra bytes to be serialized.
188
258
 
189
259
  ## Generating client-side schema files (for strictly typed languages)
190
260
 
@@ -3397,9 +3397,8 @@ class Encoder {
3397
3397
  this.state = state;
3398
3398
  state[$changes].setRoot(this.root);
3399
3399
  }
3400
- encode(it = { offset: 0 }, view, buffer = this.sharedBuffer, changeTrees = this.root.changes) {
3400
+ encode(it = { offset: 0 }, view, buffer = this.sharedBuffer, changeTrees = this.root.changes, isEncodeAll = this.root.allChanges === changeTrees) {
3401
3401
  const initialOffset = it.offset; // cache current offset in case we need to resize the buffer
3402
- const isEncodeAll = this.root.allChanges === changeTrees;
3403
3402
  const hasView = (view !== undefined);
3404
3403
  const rootChangeTree = this.state[$changes];
3405
3404
  const changeTreesIterator = changeTrees.entries();
@@ -3467,7 +3466,7 @@ class Encoder {
3467
3466
  if (buffer === this.sharedBuffer) {
3468
3467
  this.sharedBuffer = buffer;
3469
3468
  }
3470
- return this.encode({ offset: initialOffset }, view, buffer);
3469
+ return this.encode({ offset: initialOffset }, view, buffer, changeTrees, isEncodeAll);
3471
3470
  }
3472
3471
  else {
3473
3472
  //
@@ -3487,14 +3486,14 @@ class Encoder {
3487
3486
  // Array.from(this.root.allChanges.entries()).map((item) => {
3488
3487
  // console.log("->", { ref: item[0].ref.constructor.name, refId: item[0].refId, changes: item[1].size });
3489
3488
  // });
3490
- return this.encode(it, undefined, buffer, this.root.allChanges);
3489
+ return this.encode(it, undefined, buffer, this.root.allChanges, true);
3491
3490
  }
3492
3491
  encodeAllView(view, sharedOffset, it, bytes = this.sharedBuffer) {
3493
3492
  const viewOffset = it.offset;
3494
3493
  // console.log(`encodeAllView(), this.root.allFilteredChanges (${this.root.allFilteredChanges.size})`);
3495
3494
  // this.debugAllFilteredChanges();
3496
3495
  // try to encode "filtered" changes
3497
- this.encode(it, view, bytes, this.root.allFilteredChanges);
3496
+ this.encode(it, view, bytes, this.root.allFilteredChanges, true);
3498
3497
  return Buffer.concat([
3499
3498
  bytes.subarray(0, sharedOffset),
3500
3499
  bytes.subarray(viewOffset, it.offset)
@@ -3964,6 +3963,238 @@ __decorate([
3964
3963
  type([ReflectionType])
3965
3964
  ], Reflection.prototype, "types", void 0);
3966
3965
 
3966
+ function getStateCallbacks(decoder) {
3967
+ const $root = decoder.root;
3968
+ const callbacks = $root.callbacks;
3969
+ let isTriggeringOnAdd = false;
3970
+ decoder.triggerChanges = function (allChanges) {
3971
+ const uniqueRefIds = new Set();
3972
+ for (let i = 0, l = allChanges.length; i < l; i++) {
3973
+ const change = allChanges[i];
3974
+ const refId = change.refId;
3975
+ const ref = change.ref;
3976
+ const $callbacks = callbacks[refId];
3977
+ if (!$callbacks) {
3978
+ continue;
3979
+ }
3980
+ //
3981
+ // trigger onRemove on child structure.
3982
+ //
3983
+ if ((change.op & exports.OPERATION.DELETE) === exports.OPERATION.DELETE &&
3984
+ change.previousValue instanceof Schema) {
3985
+ const deleteCallbacks = callbacks[$root.refIds.get(change.previousValue)]?.[exports.OPERATION.DELETE];
3986
+ for (let i = deleteCallbacks?.length - 1; i >= 0; i--) {
3987
+ deleteCallbacks[i]();
3988
+ }
3989
+ }
3990
+ if (ref instanceof Schema) {
3991
+ //
3992
+ // Handle schema instance
3993
+ //
3994
+ if (!uniqueRefIds.has(refId)) {
3995
+ try {
3996
+ // trigger onChange
3997
+ const replaceCallbacks = $callbacks?.[exports.OPERATION.REPLACE];
3998
+ for (let i = replaceCallbacks?.length - 1; i >= 0; i--) {
3999
+ replaceCallbacks[i]();
4000
+ }
4001
+ }
4002
+ catch (e) {
4003
+ console.error(e);
4004
+ }
4005
+ }
4006
+ try {
4007
+ if ($callbacks.hasOwnProperty(change.field)) {
4008
+ const fieldCallbacks = $callbacks[change.field];
4009
+ for (let i = fieldCallbacks?.length - 1; i >= 0; i--) {
4010
+ fieldCallbacks[i](change.value, change.previousValue);
4011
+ }
4012
+ }
4013
+ }
4014
+ catch (e) {
4015
+ //
4016
+ console.error(e);
4017
+ }
4018
+ }
4019
+ else {
4020
+ //
4021
+ // Handle collection of items
4022
+ //
4023
+ if (change.op === exports.OPERATION.ADD && change.previousValue === undefined) {
4024
+ // triger onAdd
4025
+ isTriggeringOnAdd = true;
4026
+ const addCallbacks = $callbacks[exports.OPERATION.ADD];
4027
+ for (let i = addCallbacks?.length - 1; i >= 0; i--) {
4028
+ addCallbacks[i](change.value, change.dynamicIndex ?? change.field);
4029
+ }
4030
+ isTriggeringOnAdd = false;
4031
+ }
4032
+ else if ((change.op & exports.OPERATION.DELETE) === exports.OPERATION.DELETE) {
4033
+ //
4034
+ // FIXME: `previousValue` should always be available.
4035
+ //
4036
+ if (change.previousValue !== undefined) {
4037
+ // triger onRemove
4038
+ const deleteCallbacks = $callbacks[exports.OPERATION.DELETE];
4039
+ for (let i = deleteCallbacks?.length - 1; i >= 0; i--) {
4040
+ deleteCallbacks[i](change.previousValue, change.dynamicIndex ?? change.field);
4041
+ }
4042
+ }
4043
+ // Handle DELETE_AND_ADD operations
4044
+ // FIXME: should we set "isTriggeringOnAdd" here?
4045
+ if ((change.op & exports.OPERATION.ADD) === exports.OPERATION.ADD) {
4046
+ const addCallbacks = $callbacks[exports.OPERATION.ADD];
4047
+ for (let i = addCallbacks?.length - 1; i >= 0; i--) {
4048
+ addCallbacks[i](change.value, change.dynamicIndex ?? change.field);
4049
+ }
4050
+ }
4051
+ }
4052
+ // trigger onChange
4053
+ if (change.value !== change.previousValue) {
4054
+ const replaceCallbacks = $callbacks[exports.OPERATION.REPLACE];
4055
+ for (let i = replaceCallbacks?.length - 1; i >= 0; i--) {
4056
+ replaceCallbacks[i](change.value, change.dynamicIndex ?? change.field);
4057
+ }
4058
+ }
4059
+ }
4060
+ uniqueRefIds.add(refId);
4061
+ }
4062
+ };
4063
+ function getProxy(metadataOrType, context) {
4064
+ let metadata = context.instance?.constructor[Symbol.metadata] || metadataOrType;
4065
+ let isCollection = ((context.instance && typeof (context.instance['forEach']) === "function") ||
4066
+ (metadataOrType && typeof (metadataOrType[Symbol.metadata]) === "undefined"));
4067
+ if (metadata && !isCollection) {
4068
+ const onAdd = function (ref, prop, callback, immediate) {
4069
+ // immediate trigger
4070
+ if (immediate &&
4071
+ context.instance[prop] !== undefined &&
4072
+ !isTriggeringOnAdd // FIXME: This is a workaround (https://github.com/colyseus/schema/issues/147)
4073
+ ) {
4074
+ callback(context.instance[prop], undefined);
4075
+ }
4076
+ return $root.addCallback($root.refIds.get(ref), prop, callback);
4077
+ };
4078
+ /**
4079
+ * Schema instances
4080
+ */
4081
+ return new Proxy({
4082
+ listen: function listen(prop, callback, immediate = true) {
4083
+ if (context.instance) {
4084
+ return onAdd(context.instance, prop, callback, immediate);
4085
+ }
4086
+ else {
4087
+ // collection instance not received yet
4088
+ context.onInstanceAvailable((ref, existing) => onAdd(ref, prop, callback, immediate && existing));
4089
+ }
4090
+ },
4091
+ onChange: function onChange(callback) {
4092
+ return $root.addCallback($root.refIds.get(context.instance), exports.OPERATION.REPLACE, callback);
4093
+ },
4094
+ bindTo: function bindTo(targetObject, properties) {
4095
+ // return $root.addCallback(
4096
+ // $root.refIds.get(context.instance),
4097
+ // OPERATION.BIND,
4098
+ // callback
4099
+ // );
4100
+ console.log("bindTo", targetObject, properties);
4101
+ }
4102
+ }, {
4103
+ get(target, prop) {
4104
+ if (metadata[prop]) {
4105
+ const instance = context.instance?.[prop];
4106
+ const onInstanceAvailable = ((callback) => {
4107
+ const unbind = $(context.instance).listen(prop, (value, _) => {
4108
+ callback(value, false);
4109
+ // FIXME: by "unbinding" the callback here,
4110
+ // it will not support when the server
4111
+ // re-instantiates the instance.
4112
+ //
4113
+ unbind?.();
4114
+ }, false);
4115
+ // has existing value
4116
+ if ($root.refIds.get(instance) !== undefined) {
4117
+ callback(instance, true);
4118
+ }
4119
+ });
4120
+ return getProxy(metadata[prop].type, {
4121
+ instance,
4122
+ parentInstance: context.instance,
4123
+ onInstanceAvailable,
4124
+ });
4125
+ }
4126
+ else {
4127
+ // accessing the function
4128
+ return target[prop];
4129
+ }
4130
+ },
4131
+ has(target, prop) { return metadata[prop] !== undefined; },
4132
+ set(_, _1, _2) { throw new Error("not allowed"); },
4133
+ deleteProperty(_, _1) { throw new Error("not allowed"); },
4134
+ });
4135
+ }
4136
+ else {
4137
+ /**
4138
+ * Collection instances
4139
+ */
4140
+ const onAdd = function (ref, callback, immediate) {
4141
+ // Trigger callback on existing items
4142
+ if (immediate) {
4143
+ ref.forEach((v, k) => callback(v, k));
4144
+ }
4145
+ return $root.addCallback($root.refIds.get(ref), exports.OPERATION.ADD, callback);
4146
+ };
4147
+ const onRemove = function (ref, callback) {
4148
+ return $root.addCallback($root.refIds.get(ref), exports.OPERATION.DELETE, callback);
4149
+ };
4150
+ return new Proxy({
4151
+ onAdd: function (callback, immediate = true) {
4152
+ //
4153
+ // https://github.com/colyseus/schema/issues/147
4154
+ // If parent instance has "onAdd" registered, avoid triggering immediate callback.
4155
+ //
4156
+ // FIXME: "isTriggeringOnAdd" is a workaround. We should find a better way to handle this.
4157
+ //
4158
+ if (context.onInstanceAvailable) {
4159
+ // collection instance not received yet
4160
+ context.onInstanceAvailable((ref, existing) => onAdd(ref, callback, immediate && existing && !isTriggeringOnAdd));
4161
+ }
4162
+ else if (context.instance) {
4163
+ onAdd(context.instance, callback, immediate && !isTriggeringOnAdd);
4164
+ }
4165
+ },
4166
+ onRemove: function (callback) {
4167
+ if (context.onInstanceAvailable) {
4168
+ // collection instance not received yet
4169
+ context.onInstanceAvailable((ref) => onRemove(ref, callback));
4170
+ }
4171
+ else if (context.instance) {
4172
+ onRemove(context.instance, callback);
4173
+ }
4174
+ },
4175
+ }, {
4176
+ get(target, prop) {
4177
+ if (!target[prop]) {
4178
+ throw new Error(`Can't access '${prop}' through callback proxy. access the instance directly.`);
4179
+ }
4180
+ return target[prop];
4181
+ },
4182
+ has(target, prop) { return target[prop] !== undefined; },
4183
+ set(_, _1, _2) { throw new Error("not allowed"); },
4184
+ deleteProperty(_, _1) { throw new Error("not allowed"); },
4185
+ });
4186
+ }
4187
+ }
4188
+ function $(instance) {
4189
+ return getProxy(undefined, { instance });
4190
+ }
4191
+ return $(decoder.state);
4192
+ }
4193
+
4194
+ function getRawChangesCallback(decoder, callback) {
4195
+ decoder.triggerChanges = callback;
4196
+ }
4197
+
3967
4198
  class StateView {
3968
4199
  constructor() {
3969
4200
  /**
@@ -4188,6 +4419,8 @@ exports.dumpChanges = dumpChanges;
4188
4419
  exports.encode = encode;
4189
4420
  exports.encodeKeyValueOperation = encodeArray;
4190
4421
  exports.encodeSchemaOperation = encodeSchemaOperation;
4422
+ exports.getRawChangesCallback = getRawChangesCallback;
4423
+ exports.getStateCallbacks = getStateCallbacks;
4191
4424
  exports.registerType = registerType;
4192
4425
  exports.type = type;
4193
4426
  exports.view = view;