@colyseus/schema
Version:
Binary state serializer with delta encoding for games
532 lines (471 loc) • 19.1 kB
text/typescript
import { Metadata } from "../../Metadata.js";
import { Collection, NonFunctionPropNames } from "../../types/HelperTypes.js";
import type { IRef, Ref } from "../../encoder/ChangeTree.js";
import { Decoder } from "../Decoder.js";
import { DataChange } from "../DecodeOperation.js";
import { OPERATION } from "../../encoding/spec.js";
import { Schema } from "../../Schema.js";
import { $refId } from "../../types/symbols.js";
import { MapSchema } from "../../types/custom/MapSchema.js";
import { ArraySchema } from "../../types/custom/ArraySchema.js";
import { getDecoderStateCallbacks, type SchemaCallbackProxy } from "./getDecoderStateCallbacks.js";
import { getRawChangesCallback } from "./RawChanges.js";
//
// C#-style Callbacks API (https://docs.colyseus.io/state/callbacks)
//
// Key features:
// - Uses string property names with TypeScript auto-completion
// - Parameter order (value, key) for onAdd/onRemove callbacks
// - Overloaded methods for nested instance callbacks
//
type PropertyChangeCallback<K> = (currentValue: K, previousValue: K) => void;
type KeyValueCallback<K, V> = (key: K, value: V) => void;
type ValueKeyCallback<V, K> = (value: V, key: K) => void;
type InstanceChangeCallback = () => void;
// Exclude internal properties from valid property names
type PublicPropNames<T> = Exclude<NonFunctionPropNames<T>, typeof $refId> & string;
// Extract only properties that extend Collection
type CollectionPropNames<T> = Exclude<{
[K in keyof T]: T[K] extends Collection<any, any> ? K : never
}[keyof T] & string, typeof $refId>;
// Infer the value type of a collection property
type CollectionValueType<T, K extends keyof T> =
T[K] extends MapSchema<infer V, any> ? V :
T[K] extends ArraySchema<infer V> ? V :
T[K] extends Collection<any, infer V, any> ? V : never;
// Infer the key type of a collection property
type CollectionKeyType<T, K extends keyof T> =
T[K] extends MapSchema<any, infer Key> ? Key :
T[K] extends ArraySchema<any> ? number :
T[K] extends Collection<infer Key, any, any> ? Key : never;
export class StateCallbackStrategy<TState extends IRef> {
protected decoder: Decoder<TState>;
protected uniqueRefIds: Set<number> = new Set();
protected isTriggering: boolean = false;
constructor(decoder: Decoder<TState>) {
this.decoder = decoder;
this.decoder.triggerChanges = this.triggerChanges.bind(this);
}
protected get callbacks() {
return this.decoder.root.callbacks;
}
protected get state() {
return this.decoder.state;
}
protected addCallback(
refId: number,
operationOrProperty: OPERATION | string,
handler: Function
): () => void {
const $root = this.decoder.root;
return $root.addCallback(refId, operationOrProperty, handler);
}
protected addCallbackOrWaitCollectionAvailable<TInstance extends IRef, TReturn extends Ref>(
instance: TInstance,
propertyName: string,
operation: OPERATION,
handler: Function,
immediate: boolean = true
): () => void {
let removeHandler: () => void = () => {};
const removeOnAdd = () => removeHandler();
const collection = (instance as any)[propertyName] as TReturn;
// Collection not available yet. Listen for its availability before attaching the handler.
if (!collection || collection[$refId] === undefined) {
let removePropertyCallback: () => void;
removePropertyCallback = this.addCallback(
instance[$refId]!,
propertyName,
(value: TReturn, _: TReturn) => {
if (value !== null && value !== undefined) {
// Remove the property listener now that collection is available
removePropertyCallback();
removeHandler = this.addCallback(value[$refId]!, operation, handler);
}
}
);
removeHandler = removePropertyCallback;
return removeOnAdd;
} else {
//
// Call immediately if collection is already available, if it's an ADD operation.
//
immediate = immediate && this.isTriggering === false;
if (operation === OPERATION.ADD && immediate) {
(collection as Collection<any, any>).forEach((value: any, key: any) => {
handler(value, key);
});
}
return this.addCallback(collection[$refId]!, operation, handler);
}
}
/**
* Listen to property changes on the root state.
*/
listen<K extends PublicPropNames<TState>>(
property: K,
handler: PropertyChangeCallback<TState[K]>,
immediate?: boolean
): () => void;
/**
* Listen to property changes on a nested instance.
*/
listen<TInstance, K extends PublicPropNames<TInstance>>(
instance: TInstance,
property: K,
handler: PropertyChangeCallback<TInstance[K]>,
immediate?: boolean
): () => void;
listen(...args: any[]): () => void {
if (typeof args[0] === 'string') {
// listen(property, handler, immediate?)
return this.listenInstance(this.state, args[0], args[1], args[2]);
} else {
// listen(instance, property, handler, immediate?)
return this.listenInstance(args[0], args[1], args[2], args[3]);
}
}
protected listenInstance<TInstance extends IRef>(
instance: TInstance,
propertyName: string,
handler: PropertyChangeCallback<any>,
immediate: boolean = true
): () => void {
immediate = immediate && this.isTriggering === false;
//
// Call handler immediately if property is already available.
//
const currentValue = (instance as any)[propertyName];
if (immediate && currentValue !== null && currentValue !== undefined) {
handler(currentValue, undefined as any);
}
return this.addCallback(instance[$refId]!, propertyName, handler);
}
/**
* Listen to any property change on an instance.
*/
onChange<TInstance extends object>(
instance: TInstance,
handler: InstanceChangeCallback
): () => void;
/**
* Listen to item changes in a collection on root state.
*/
onChange<K extends CollectionPropNames<TState>>(
property: K,
handler: KeyValueCallback<CollectionKeyType<TState, K>, CollectionValueType<TState, K>>
): () => void;
/**
* Listen to item changes in a nested collection.
*/
onChange<TInstance extends object, K extends CollectionPropNames<TInstance>>(
instance: TInstance,
property: K,
handler: KeyValueCallback<CollectionKeyType<TInstance, K>, CollectionValueType<TInstance, K>>
): () => void;
onChange(...args: any[]): () => void {
if (args.length === 2 && typeof args[0] !== 'string') {
// onChange(instance, handler) - instance change
const instance = args[0] as Schema;
const handler = args[1] as InstanceChangeCallback;
return this.addCallback(instance[$refId]!, OPERATION.REPLACE, handler);
}
if (typeof args[0] === 'string') {
// onChange(property, handler) - collection on root state
return this.addCallbackOrWaitCollectionAvailable(
this.state,
args[0],
OPERATION.REPLACE,
args[1]
);
} else {
// onChange(instance, property, handler) - nested collection
return this.addCallbackOrWaitCollectionAvailable(
args[0],
args[1],
OPERATION.REPLACE,
args[2]
);
}
}
/**
* Listen to items added to a collection on root state.
*/
onAdd<K extends CollectionPropNames<TState>>(
property: K,
handler: ValueKeyCallback<CollectionValueType<TState, K>, CollectionKeyType<TState, K>>,
immediate?: boolean
): () => void;
/**
* Listen to items added to a nested collection.
*/
onAdd<TInstance, K extends CollectionPropNames<TInstance>>(
instance: TInstance,
property: K,
handler: ValueKeyCallback<CollectionValueType<TInstance, K>, CollectionKeyType<TInstance, K>>,
immediate?: boolean
): () => void;
onAdd(...args: any[]): () => void {
if (typeof args[0] === 'string') {
// onAdd(property, handler, immediate?) - collection on root state
return this.addCallbackOrWaitCollectionAvailable(
this.state,
args[0],
OPERATION.ADD,
args[1],
args[2] !== false
);
} else {
// onAdd(instance, property, handler, immediate?) - nested collection
return this.addCallbackOrWaitCollectionAvailable(
args[0],
args[1],
OPERATION.ADD,
args[2],
args[3] !== false
);
}
}
/**
* Listen to items removed from a collection on root state.
*/
onRemove<K extends CollectionPropNames<TState>>(
property: K,
handler: ValueKeyCallback<CollectionValueType<TState, K>, CollectionKeyType<TState, K>>
): () => void;
/**
* Listen to items removed from a nested collection.
*/
onRemove<TInstance, K extends CollectionPropNames<TInstance>>(
instance: TInstance,
property: K,
handler: ValueKeyCallback<CollectionValueType<TInstance, K>, CollectionKeyType<TInstance, K>>
): () => void;
onRemove(...args: any[]): () => void {
if (typeof args[0] === 'string') {
// onRemove(property, handler) - collection on root state
return this.addCallbackOrWaitCollectionAvailable(
this.state,
args[0],
OPERATION.DELETE,
args[1]
);
} else {
// onRemove(instance, property, handler) - nested collection
return this.addCallbackOrWaitCollectionAvailable(
args[0],
args[1],
OPERATION.DELETE,
args[2]
);
}
}
/**
* Bind properties from a Schema instance to a target object.
* Changes will be automatically reflected on the target object.
*/
bindTo<TInstance, TTarget>(
from: TInstance,
to: TTarget,
properties?: string[],
immediate: boolean = true
): () => void {
const metadata: Metadata = (from.constructor as typeof Schema)[Symbol.metadata];
// If no properties specified, bind all properties
if (!properties) {
properties = Object.keys(metadata)
.filter(key => !isNaN(Number(key)))
.map((index) => metadata[index as any as number].name);
}
const action = () => {
for (const prop of properties!) {
const fromValue = (from as any)[prop];
if (fromValue !== undefined) {
(to as any)[prop] = fromValue;
}
}
};
if (immediate) {
action();
}
return this.addCallback((from as IRef)[$refId]!, OPERATION.REPLACE, action);
}
protected triggerChanges(allChanges: DataChange[]): void {
this.uniqueRefIds.clear();
for (let i = 0, l = allChanges.length; i < l; i++) {
const change = allChanges[i];
const refId = change.refId;
const ref = change.ref;
const $callbacks = this.callbacks[refId];
if (!$callbacks) {
continue;
}
//
// trigger onRemove on child structure.
//
if (
(change.op & OPERATION.DELETE) === OPERATION.DELETE &&
Schema.isSchema(change.previousValue)
) {
const childRefId = (change.previousValue as Ref)[$refId]!;
const deleteCallbacks = this.callbacks[childRefId]?.[OPERATION.DELETE];
if (deleteCallbacks) {
for (let j = deleteCallbacks.length - 1; j >= 0; j--) {
deleteCallbacks[j]();
}
}
}
if (Schema.isSchema(ref)) {
//
// Handle Schema instance
//
if (!this.uniqueRefIds.has(refId)) {
// trigger onChange
const replaceCallbacks = $callbacks[OPERATION.REPLACE];
if (replaceCallbacks) {
for (let j = replaceCallbacks.length - 1; j >= 0; j--) {
try {
replaceCallbacks[j]();
} catch (e) {
console.error(e);
}
}
}
}
// trigger field callbacks
const fieldCallbacks = $callbacks[change.field];
if (fieldCallbacks) {
for (let j = fieldCallbacks.length - 1; j >= 0; j--) {
try {
this.isTriggering = true;
fieldCallbacks[j](change.value, change.previousValue);
} catch (e) {
console.error(e);
} finally {
this.isTriggering = false;
}
}
}
} else {
//
// Handle collection of items
//
const dynamicIndex = change.dynamicIndex ?? change.field;
if ((change.op & OPERATION.DELETE) === OPERATION.DELETE) {
//
// FIXME: `previousValue` should always be available.
//
if (change.previousValue !== undefined) {
// trigger onRemove (value, key)
const deleteCallbacks = $callbacks[OPERATION.DELETE];
if (deleteCallbacks) {
for (let j = deleteCallbacks.length - 1; j >= 0; j--) {
deleteCallbacks[j](change.previousValue, dynamicIndex);
}
}
}
// Handle DELETE_AND_ADD operation
if ((change.op & OPERATION.ADD) === OPERATION.ADD) {
const addCallbacks = $callbacks[OPERATION.ADD];
if (addCallbacks) {
this.isTriggering = true;
for (let j = addCallbacks.length - 1; j >= 0; j--) {
addCallbacks[j](change.value, dynamicIndex);
}
this.isTriggering = false;
}
}
} else if (
(change.op & OPERATION.ADD) === OPERATION.ADD &&
change.previousValue !== change.value
) {
// trigger onAdd (value, key)
const addCallbacks = $callbacks[OPERATION.ADD];
if (addCallbacks) {
this.isTriggering = true;
for (let j = addCallbacks.length - 1; j >= 0; j--) {
addCallbacks[j](change.value, dynamicIndex);
}
this.isTriggering = false;
}
}
// trigger onChange (key, value)
if (change.value !== change.previousValue) {
const replaceCallbacks = $callbacks[OPERATION.REPLACE];
if (replaceCallbacks) {
for (let j = replaceCallbacks.length - 1; j >= 0; j--) {
replaceCallbacks[j](dynamicIndex, change.value);
}
}
}
}
this.uniqueRefIds.add(refId);
}
}
}
/**
* Factory class for retrieving the callbacks API.
*/
export const Callbacks = {
/**
* Get the new callbacks standard API.
*
* Usage:
* ```ts
* const callbacks = Callbacks.get(roomOrDecoder);
*
* // Listen to property changes
* callbacks.listen("currentTurn", (currentValue, previousValue) => { ... });
*
* // Listen to collection additions
* callbacks.onAdd("entities", (entity, sessionId) => {
* // Nested property listening
* callbacks.listen(entity, "hp", (currentHp, previousHp) => { ... });
* });
*
* // Listen to collection removals
* callbacks.onRemove("entities", (entity, sessionId) => { ... });
*
* // Listen to any property change on an instance
* callbacks.onChange(entity, () => { ... });
*
* // Bind properties to another object
* callbacks.bindTo(player, playerVisual);
* ```
*
* @param roomOrDecoder - Room or Decoder instance to get the callbacks for.
* @returns the new callbacks standard API.
*/
get<T extends IRef>(
roomOrDecoder: Decoder<T> | { serializer: { decoder: Decoder<T> } } | { state: T; serializer: object }
): StateCallbackStrategy<T> {
if (roomOrDecoder instanceof Decoder) {
return new StateCallbackStrategy<T>(roomOrDecoder);
} else if ('decoder' in roomOrDecoder.serializer) {
return new StateCallbackStrategy<T>(roomOrDecoder.serializer.decoder);
} else {
throw new Error('Invalid room or decoder');
}
},
/**
* Get the legacy callbacks API.
*
* We aim to deprecate this API on 1.0, and iterate on improving Callbacks.get() API.
*
* @param roomOrDecoder - Room or Decoder instance to get the legacy callbacks for.
* @returns the legacy callbacks API.
*/
getLegacy<T extends Schema>(
roomOrDecoder: Decoder<T> | { serializer: { decoder: Decoder<T> } } | { state: T; serializer: object }
): SchemaCallbackProxy<T> {
if (roomOrDecoder instanceof Decoder) {
return getDecoderStateCallbacks(roomOrDecoder);
} else if ('decoder' in roomOrDecoder.serializer) {
return getDecoderStateCallbacks((roomOrDecoder.serializer as { decoder: Decoder<T> }).decoder);
}
throw new Error('Invalid room or decoder');
},
getRawChanges(decoder: Decoder, callback: (changes: DataChange[]) => void) {
return getRawChangesCallback(decoder, callback);
}
};