multyx-client 0.1.0

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.
@@ -0,0 +1,170 @@
1
+ import { Message } from "../message";
2
+ import { RawObject } from '../types';
3
+ import { Add, EditWrapper, Unpack } from "../utils";
4
+
5
+ import type Multyx from '../index';
6
+ import type { MultyxClientItem } from ".";
7
+ import MultyxClientItemRouter from "./router";
8
+ import MultyxClientValue from "./value";
9
+
10
+ export default class MultyxClientObject {
11
+ protected object: RawObject<MultyxClientItem>;
12
+ private multyx: Multyx;
13
+ propertyPath: string[];
14
+ editable: boolean;
15
+
16
+ private setterListeners: ((key: any, value: any) => void)[]
17
+
18
+ get value() {
19
+ const parsed = {};
20
+ for(const prop in this.object) parsed[prop] = this.object[prop];
21
+ return parsed;
22
+ }
23
+
24
+ constructor(multyx: Multyx, object: RawObject | EditWrapper<RawObject>, propertyPath: string[] = [], editable: boolean) {
25
+ this.object = {};
26
+ this.propertyPath = propertyPath;
27
+ this.multyx = multyx;
28
+ this.editable = editable;
29
+
30
+ this.setterListeners = [];
31
+
32
+ if(object instanceof MultyxClientObject) object = object.value;
33
+
34
+ for(const prop in (object instanceof EditWrapper ? object.value : object)) {
35
+ this.set(prop, object instanceof EditWrapper
36
+ ? new EditWrapper(object.value[prop])
37
+ : object[prop]
38
+ );
39
+ }
40
+
41
+ if(this.constructor !== MultyxClientObject) return;
42
+
43
+ return new Proxy(this, {
44
+ has: (o, p) => {
45
+ return o.has(p);
46
+ },
47
+ get: (o, p) => {
48
+ if(p in o) return o[p];
49
+ return o.get(p);
50
+ },
51
+ set: (o, p, v) => {
52
+ if(p in o) {
53
+ o[p] = v;
54
+ return true;
55
+ }
56
+ return o.set(p, v);
57
+ },
58
+ deleteProperty: (o, p) => {
59
+ return o.delete(p, false);
60
+ }
61
+ });
62
+ }
63
+
64
+ has(property: any): boolean {
65
+ return property in this.object;
66
+ }
67
+
68
+ get(property: any): MultyxClientItem {
69
+ return this.object[property];
70
+ }
71
+
72
+ set(property: any, value: any): boolean {
73
+ if(value === undefined) return this.delete(property);
74
+
75
+ // Only create new MultyxClientItem when needed
76
+ if(this.object[property] instanceof MultyxClientValue) return this.object[property].set(value);
77
+
78
+ // If value was deleted by the server
79
+ if(value instanceof EditWrapper && value.value === undefined) return this.delete(property, true);
80
+
81
+ // Attempting to edit property not editable to client
82
+ if(!(value instanceof EditWrapper) && !this.editable) {
83
+ if(this.multyx.options.verbose) {
84
+ console.error(`Attempting to set property that is not editable. Setting '${this.propertyPath.join('.') + '.' + property}' to ${value}`);
85
+ }
86
+ return false;
87
+ }
88
+
89
+ // Creating a new value
90
+ this.object[property] = new (MultyxClientItemRouter(
91
+ value instanceof EditWrapper ? value.value : value
92
+ ))(this.multyx, value, [...this.propertyPath, property], this.editable);
93
+
94
+ // We have to push into queue, since object may not be fully created
95
+ // and there may still be more updates to parse
96
+ for(const listener of this.setterListeners) {
97
+ this.multyx[Add](() => {
98
+ if(this.has(property)) listener(property, this.get(property));
99
+ });
100
+ }
101
+
102
+ // Relay change to server if not edit wrapped
103
+ if(!(value instanceof EditWrapper)) this.multyx.ws.send(Message.Native({
104
+ instruction: 'edit',
105
+ path: this.propertyPath,
106
+ value: this.object[property].value
107
+ }));
108
+
109
+ return true;
110
+ }
111
+
112
+ delete(property: any, native: boolean = false) {
113
+ // Attempting to edit property not editable by client
114
+ if(!this.editable && !native) {
115
+ if(this.multyx.options.verbose) {
116
+ console.error(`Attempting to delete property that is not editable. Deleting '${this.propertyPath.join('.') + '.' + property}'`);
117
+ }
118
+ return false;
119
+ }
120
+
121
+ delete this.object[property];
122
+
123
+ if(!native) {
124
+ this.multyx.ws.send(Message.Native({
125
+ instruction: 'edit',
126
+ path: [...this.propertyPath, property],
127
+ value: undefined
128
+ }));
129
+ }
130
+
131
+ return true;
132
+ }
133
+
134
+ /**
135
+ * Create a callback function that gets called for any current or future property in object
136
+ * @param callbackfn Function to call for every property
137
+ */
138
+ forAll(callbackfn: (key: any, value: any) => void) {
139
+ for(let prop in this.object) {
140
+ callbackfn(prop, this.get(prop));
141
+ }
142
+ this.setterListeners.push(callbackfn);
143
+ }
144
+
145
+ keys(): any[] {
146
+ return Object.keys(this.object);
147
+ }
148
+
149
+ values(): any[] {
150
+ return Object.values(this.object);
151
+ }
152
+
153
+ entries(): [any, any][] {
154
+ const entryList: [any, any][] = [];
155
+ for(let prop in this.object) {
156
+ entryList.push([prop, this.get(prop)]);
157
+ }
158
+ return entryList;
159
+ }
160
+
161
+ /**
162
+ * Unpack constraints from server
163
+ * @param constraints Packed constraints object mirroring MultyxClientObject shape
164
+ */
165
+ [Unpack](constraints: RawObject) {
166
+ for(const prop in constraints) {
167
+ this.object[prop][Unpack](constraints[prop]);
168
+ }
169
+ }
170
+ }
@@ -0,0 +1,5 @@
1
+ export default function MultyxClientItemRouter(data: any) {
2
+ return Array.isArray(data) ? require('./list').default
3
+ : typeof data == 'object' ? require('./object').default
4
+ : require('./value').default;
5
+ }
@@ -0,0 +1,150 @@
1
+ import type Multyx from '../';
2
+ import { Message } from "../message";
3
+ import { Constraint, RawObject, Value } from "../types";
4
+ import { BuildConstraint, EditWrapper, Unpack } from '../utils';
5
+
6
+ export default class MultyxClientValue {
7
+ private _value: Value;
8
+ private multyx: Multyx;
9
+ propertyPath: string[];
10
+ editable: boolean;
11
+ constraints: { [key: string]: Constraint };
12
+
13
+ private interpolator: undefined | {
14
+ get: () => Value,
15
+ set: () => void,
16
+ history: { time: number, value: Value }[]
17
+ };
18
+
19
+ get value() {
20
+ if(this.interpolator) return this.interpolator.get();
21
+ return this._value;
22
+ }
23
+
24
+ set value(v) {
25
+ this._value = v;
26
+ if(this.interpolator) this.interpolator.set();
27
+ }
28
+
29
+ constructor(multyx: Multyx, value: Value | EditWrapper<Value>, propertyPath: string[] = [], editable: boolean) {
30
+ this.propertyPath = propertyPath;
31
+ this.editable = editable;
32
+ this.multyx = multyx;
33
+ this.constraints = {};
34
+ this.set(value);
35
+ }
36
+
37
+ set(value: Value | EditWrapper<Value>) {
38
+ if(value instanceof EditWrapper) {
39
+ this.value = value.value;
40
+ return true;
41
+ }
42
+
43
+ // Attempting to edit property not editable to client
44
+ if(!this.editable) {
45
+ if(this.multyx.options.verbose) {
46
+ console.error(`Attempting to set property that is not editable. Setting '${this.propertyPath.join('.')}' to ${value}`);
47
+ }
48
+ return false;
49
+ }
50
+
51
+ let nv = value;
52
+ for(const constraint in this.constraints) {
53
+ const fn = this.constraints[constraint];
54
+ nv = fn(nv);
55
+
56
+ if(nv === null) {
57
+ if(this.multyx.options.verbose) {
58
+ console.error(`Attempting to set property that failed on constraint. Setting '${this.propertyPath.join('.')}' to ${value}, stopped by constraint '${constraint}'`);
59
+ }
60
+ return false;
61
+ }
62
+ }
63
+
64
+ if(this.value === nv) {
65
+ this.value = nv;
66
+ return true;
67
+ }
68
+
69
+ this.value = nv;
70
+
71
+ this.multyx.ws.send(Message.Native({
72
+ instruction: 'edit',
73
+ path: this.propertyPath,
74
+ value: nv
75
+ }));
76
+ return true;
77
+ }
78
+
79
+ /**
80
+ * Unpack constraints sent from server and store
81
+ * @param constraints Packed constraints from server
82
+ */
83
+ [Unpack](constraints: RawObject) {
84
+ for(const [cname, args] of Object.entries(constraints)) {
85
+ const constraint = BuildConstraint(cname, args as Value[]);
86
+ if(!constraint) continue;
87
+ this.constraints[cname] = constraint;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Linearly interpolate value across frames
93
+ * Will run 1 frame behind on average
94
+ */
95
+ Lerp() {
96
+ this.interpolator = {
97
+ history: [
98
+ { value: this._value, time: Date.now() },
99
+ { value: this._value, time: Date.now() }
100
+ ],
101
+ get: () => {
102
+ const [e, s] = this.interpolator.history;
103
+ const ratio = Math.min(1, (Date.now() - e.time) / Math.min(250, e.time - s.time));
104
+ if(Number.isNaN(ratio) || typeof e.value != 'number' || typeof s.value != 'number') return e.value;
105
+ return e.value * ratio + s.value * (1 - ratio);
106
+ },
107
+ set: () => {
108
+ this.interpolator.history.pop();
109
+ this.interpolator.history.unshift({
110
+ value: this._value,
111
+ time: Date.now()
112
+ });
113
+ }
114
+ }
115
+ }
116
+
117
+ PredictiveLerp() {
118
+ this.interpolator = {
119
+ history: [
120
+ { value: this._value, time: Date.now() },
121
+ { value: this._value, time: Date.now() },
122
+ { value: this._value, time: Date.now() }
123
+ ],
124
+ get: () => {
125
+ const [e, s, p] = this.interpolator.history;
126
+ const ratio = Math.min(1, (Date.now() - e.time) / (e.time - s.time));
127
+
128
+ if(Number.isNaN(ratio) || typeof p.value != 'number') return e.value;
129
+ if(typeof e.value != 'number' || typeof s.value != 'number') return e.value;
130
+
131
+ // Speed changed too fast, don't interpolate, return new value
132
+ if(Math.abs((e.value - s.value) / (s.value - p.value) - 1) > 0.2) return e.value;
133
+
134
+ return e.value * (1 + ratio) - s.value * ratio;
135
+ },
136
+ set: () => {
137
+ this.interpolator.history.pop();
138
+ this.interpolator.history.unshift({
139
+ value: this._value,
140
+ time: Date.now()
141
+ });
142
+ }
143
+ }
144
+ }
145
+
146
+ /* Native methods to allow MultyxValue to be treated as primitive */
147
+ toString = () => this.value.toString();
148
+ valueOf = () => this.value;
149
+ [Symbol.toPrimitive] = () => this.value;
150
+ }
package/src/message.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { Update } from "./types";
2
+
3
+ export class Message {
4
+ name: string;
5
+ data: any;
6
+ time: number;
7
+ native: boolean;
8
+
9
+ private constructor(name: string, data: any, native: boolean = false) {
10
+ this.name = name;
11
+ this.data = data;
12
+ this.time = Date.now();
13
+ this.native = native;
14
+ }
15
+
16
+ static BundleOperations(deltaTime, operations) {
17
+ if(!Array.isArray(operations)) operations = [operations];
18
+ return JSON.stringify(new Message('_', { operations, deltaTime }));
19
+ }
20
+
21
+ static Native(update: Update) {
22
+ return JSON.stringify(new Message('_', update, true));
23
+ }
24
+
25
+ static Parse(str: string) {
26
+ const parsed = JSON.parse(str);
27
+ if(parsed.name[0] == '_') parsed.name = parsed.name.slice(1);
28
+
29
+ return new Message(parsed.name, parsed.data, parsed.name == '');
30
+ }
31
+
32
+ static Create(name: string, data: any) {
33
+ if(name.length == 0) throw new Error('Multyx message cannot have empty name');
34
+ if(name[0] == '_') name = '_' + name;
35
+
36
+ if(typeof data === 'function') {
37
+ throw new Error('Multyx data must be JSON storable');
38
+ }
39
+ return JSON.stringify(new Message(name, data));
40
+ }
41
+ }
package/src/options.ts ADDED
@@ -0,0 +1,15 @@
1
+ export type Options = {
2
+ port?: number,
3
+ secure?: boolean,
4
+ uri?: string,
5
+ verbose?: boolean,
6
+ logUpdateFrame?: boolean,
7
+ };
8
+
9
+ export const DefaultOptions: Options = {
10
+ port: 443,
11
+ secure: false,
12
+ uri: 'localhost',
13
+ verbose: false,
14
+ logUpdateFrame: false,
15
+ };
package/src/types.ts ADDED
@@ -0,0 +1,17 @@
1
+ export type RawObject<V=any> = { [key: string | number | symbol]: V };
2
+ export type Value = string | number | boolean;
3
+ export type Constraint = (n: Value) => Value | null;
4
+
5
+ export type EditUpdate = {
6
+ instruction: 'edit',
7
+ path: string[],
8
+ value: any
9
+ };
10
+
11
+ export type InputUpdate = {
12
+ instruction: 'input',
13
+ input: string,
14
+ data?: RawObject<Value>
15
+ };
16
+
17
+ export type Update = EditUpdate | InputUpdate;
package/src/utils.ts ADDED
@@ -0,0 +1,78 @@
1
+ import { Constraint, RawObject, Value } from "./types";
2
+
3
+ export const Unpack = Symbol("unpack");
4
+ export const Done = Symbol("done");
5
+ export const Add = Symbol("add");
6
+
7
+ export class EditWrapper<T> {
8
+ value: T;
9
+
10
+ constructor(value: T) {
11
+ this.value = value;
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Set a customized interpolation curve for values to follow
17
+ * @param values Slices to interpolate through. Time must be between 0 and 1, while progress is the percentage between the old value and new value at the respective time, where 0 represents old value and 1 represents new value
18
+ * @example
19
+ * ```js
20
+ * car.get('speed').interpolate([
21
+ * { time: 0, progress: 0 },
22
+ * { time: 0.2, progress: 0.6 },
23
+ * { time: 0.4, progress: 1.2 },
24
+ * { time: 0.6, progress: 1.4 },
25
+ * { time: 0.8, progress: 1.2 },
26
+ * { time: 1, progress: 1 }
27
+ * ]);
28
+ * ```
29
+ */
30
+ export function Interpolate(
31
+ object: RawObject,
32
+ property: string,
33
+ interpolationCurve: {
34
+ time: number,
35
+ progress: number,
36
+ }[]
37
+ ) {
38
+ let start = { value: object[property], time: Date.now() };
39
+ let end = { value: object[property], time: Date.now() };
40
+
41
+ Object.defineProperty(object, property, {
42
+ get: () => {
43
+ const time = end.time - start.time;
44
+ let lower = interpolationCurve[0];
45
+ let upper = interpolationCurve[0];
46
+
47
+ for(const slice of interpolationCurve) {
48
+ if(time > slice.time && slice.time > lower.time) lower = slice;
49
+ if(time < slice.time && slice.time < upper.time) upper = slice;
50
+ }
51
+
52
+ const sliceTime = (time - lower.time) / (upper.time - lower.time);
53
+ const ratio = lower.progress + sliceTime * (upper.progress - lower.progress);
54
+
55
+ if(Number.isNaN(ratio)) return start.value;
56
+ return end.value * ratio + start.value * (1 - ratio);
57
+ },
58
+ set: (value) => {
59
+ // Don't lerp between edit requests sent in same frame
60
+ if(Date.now() - end.time < 10) {
61
+ end.value = value;
62
+ return true;
63
+ }
64
+ start = { ...end };
65
+ end = { value, time: Date.now() }
66
+ return true;
67
+ }
68
+ });
69
+ }
70
+
71
+ export function BuildConstraint(name: string, args: Value[]): Constraint | void {
72
+ if(name == 'min') return n => n >= args[0] ? n : args[0];
73
+ if(name == 'max') return n => n <= args[0] ? n : args[0];
74
+ if(name == 'int') return n => Math.floor(n as number);
75
+ if(name == 'ban') return n => args.includes(n) ? null : n;
76
+ if(name == 'disabled') return n => args[0] ? null : n;
77
+ return I => I;
78
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es6", // Specify the target ECMAScript version.
4
+ "lib": ["es2017", "dom"],
5
+ "module": "commonjs", // Do not use any module system (for browser use).
6
+ "outDir": "./dist",
7
+ "rootDir": "./src", // Specify the root directory of your TypeScript source files.
8
+ }
9
+ }
@@ -0,0 +1,13 @@
1
+ const path = require('path');
2
+
3
+ module.exports = {
4
+ entry: './dist/index.js',
5
+ output: {
6
+ filename: 'multyx.js',
7
+ path: path.resolve(__dirname),
8
+ library: 'Multyx', // Specify the global variable name
9
+ libraryTarget: 'umd', // Attach to the global object in a universal module definition (UMD) fashion
10
+ libraryExport: 'default'
11
+ },
12
+ mode: 'production'
13
+ };