Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
arthuro555 committed Aug 3, 2022
0 parents commit e5a07f4
Show file tree
Hide file tree
Showing 335 changed files with 23,244 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
dist
code/t-h-n-k
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
code/t-h-n-k
25 changes: 25 additions & 0 deletions LICENSE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
The MIT License (MIT)
=====================

Copyright © 2022 Arthur "arthuro555" Pacaud

Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the “Software”), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# 🤔 THNK

An authoritative multiplayer games framework for the FLOSS engine GDevelop.

## Links

- [📰 Introduction blog post](https://bit.ly/thnk-introduction)
- [📅 Roadmap](https://bit.ly/thnk-roadmap)
- [💖 Support the project](https://ko-fi.com/arthuro555)

## Contributing

### Installing

To install all dependencies, run `yarn`. You may use `npm install`, but note that only a yarn lockfile will be provided and accepted in PRs.
If you have disabled postinstall scripts, run `yarn generate-protocol` to run the code generator on the flatbuffer files.

### Building

Run `yarn build` to execute the full build pipeline. You can also build individual parts with the other build scripts in package.json.

Building THNK outputs an `index.global.js` file in the `dist` folder. Import the THNK extension file in `extensions` into GDevelop, and in the `onFirstSceneLoaded` functions, replace the contents of the JS code block with the contents of `index.global.js`. At the beginning of the code block , replace `var THNK` with `window.THNK`. You now are running your custom build of THNK.

For the adapters, the process is similar. Each produce another file in `dist`, with code that can be copied to the `onFirstSceneLoaded` of their respective extensions. The difference lies within the fact that you need to rename the two references to THNK at the bottom at the file with `window.THNK`, **not the `var THNK` at the top of the file**.

### Testing

Before submitting a PR, make sure that your code passes both typescript and jest tests.
Run `yarn ts && yarn test` to run both checks.

### Understanding the architecture

There are a few main folders that you need to keep in mind while contributing:

- `protocol` - Contains FlatBuffers protocol definitions. Anything that transits between the server and client **must** be defined through a FlatBuffer `ServerMessage` or `ClientMessage`, depenfing on which side will be sending that message.
- After changing, you need to run `yarn generate-protocol` to regenerate the source code from the FlatBuffers files before using it in `code`
- `code/server` - All of the server-only code.
- `code/client` - All of the client-only code.
- `code/adapters` - All the different adapters implementations. Each file contains a server and client adapter for a single backend.
- `extensions` - Contains the GDevelop extensions files. While most of the mportant code is in `code`, the extensions themselves need to be modified to add actions, conditions, etc. You also need them to actually use the THNK built code.
94 changes: 94 additions & 0 deletions code/Adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { setConnectionState } from "./client/ClientConnectionState";
import {
ClientMessage,
ServerMessage,
ByteBuffer,
type Builder,
} from "./t-h-n-k";

export abstract class ClientAdapter {
/** Ensure returns a promise that resolves once fully connected to a server. */
abstract prepare(runtimeScene: gdjs.RuntimeScene): Promise<void>;
/** Called when the adapter is no longer needed to gracefully shutdown. */
abstract close(): void;

sendClientMessage(builder: Builder, messageOffset: number): void {
builder.finish(messageOffset);
this.doSendMessage(builder.asUint8Array());
}

private readonly pendingMessages: ServerMessage[] = [];
getPendingMessages(): readonly ServerMessage[] {
return this.pendingMessages;
}
markPendingMessagesAsRead(): void {
this.pendingMessages.length = 0;
}

/** Override to send an Uint8Array to the server/client. */
protected abstract doSendMessage(message: Uint8Array): void;
/** Call this whenever the server/client has sent a message. */
protected onMessage(bytes: Uint8Array): void {
this.pendingMessages.push(
ServerMessage.getRootAsServerMessage(new ByteBuffer(bytes))
);
}
protected onDisconnection(): void {
setConnectionState("disconnected");
}
}

export abstract class ServerAdapter {
/** Ensure returns a promise that resolves once fully connected to a server. */
abstract prepare(): Promise<void>;
/** Called when the adapter is no longer needed to gracefully shutdown. */
abstract close(): void;

sendServerMessageTo(
userID: string,
builder: Builder,
messageOffset: number
): void {
builder.finish(messageOffset);
this.doSendMessageTo(userID, builder.asUint8Array());
}
sendServerMessageToAll(builder: Builder, messageOffset: number) {
builder.finish(messageOffset);
const messageAsArray = builder.asUint8Array();
for (const userID of this.usersPendingMessages.keys())
this.doSendMessageTo(userID, messageAsArray);
}

private readonly usersPendingMessages = new Map<string, ClientMessage[]>();
getUsersPendingMessages(): IterableIterator<[string, ClientMessage[]]> {
return this.usersPendingMessages.entries();
}
getConnectedUsers(): IterableIterator<string> {
return this.usersPendingMessages.keys();
}

private readonly disconnectedUsers: string[] = [];
getDisconnectedUsers(): string[] {
return this.disconnectedUsers;
}

/** Override to return the playerID of the server. */
abstract getServerID(): string;
/** Override to send an Uint8Array to all the clients. */
protected abstract doSendMessageTo(userID: string, message: Uint8Array): void;
/** Call this whenever the server/client has sent a message. */
protected onMessage(userID: string, bytes: Uint8Array): void {
this.usersPendingMessages
.get(userID)!
.push(ClientMessage.getRootAsClientMessage(new ByteBuffer(bytes)));
}
/** Call this whenever a client connects. */
protected onConnection(userID: string) {
this.usersPendingMessages.set(userID, []);
}
/** Call this whenever a client disconnects. */
protected onDisconnection(userID: string) {
this.usersPendingMessages.delete(userID);
this.disconnectedUsers.push(userID);
}
}
6 changes: 6 additions & 0 deletions code/PlayerContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
let currentPlayerID: string = "";

export const getCurrentPlayerID = () => currentPlayerID;
export const switchPlayerContext = (playerID: string) => {
currentPlayerID = playerID;
};
16 changes: 16 additions & 0 deletions code/Settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/** Set to true to disable client code. This is meant for dedicated servers. */
let DEDICATED = false;

/** The amount of target ticks per second. */
let TICK_RATE = 120; // TODO set to lower (20?) when that becomes a possibility.

export const setDedicated = () => {
DEDICATED = true;
};

export const setTickRate = (newTPS: number) => {
TICK_RATE = Math.max(1, newTPS);
};

export const getTickRate = () => TICK_RATE;
export const isDedicated = () => DEDICATED;
12 changes: 12 additions & 0 deletions code/VariablePacker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { pack, unpack } from "msgpackr";

export const packVariable = (variable: gdjs.Variable) => {
return pack(variable.toJSObject());
};

export const unpackVariable = (
variable: gdjs.Variable,
packedVar: Uint8Array
) => {
return variable.fromJSObject(unpack(packedVar));
};
121 changes: 121 additions & 0 deletions code/adapters/p2p.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/// <reference path="../global.d.ts"/>
const logger = new gdjs.Logger("THNK - P2P Adapter");
namespace THNK {
class P2PConnectionAwaiter extends gdjs.AsyncTask {
peerID: string;
constructor(peerID: string) {
super();
this.peerID = peerID;
}
update(): boolean {
return !!gdjs.evtTools.p2p.getConnectionInstance(this.peerID);
}
}

export class P2PClientAdapter extends THNK.ClientAdapter {
peerID: string;
connection: Peer.DataConnection<ArrayBuffer> | null = null;
constructor(peerID: string) {
super();
this.peerID = peerID;
}

boundPreEventsCallback = () => this.preEventsCallback();

preEventsCallback() {
if (!gdjs.evtTools.p2p.getConnectionInstance(this.peerID)) {
this.onDisconnection();
}
}

async prepare(runtimeScene: gdjs.RuntimeScene): Promise<void> {
this.connection = gdjs.evtTools.p2p.getConnectionInstance(
this.peerID
) as Peer.DataConnection<ArrayBuffer> | null;

if (!this.connection) {
gdjs.evtTools.p2p.connect(this.peerID);

this.connection = await new Promise<Peer.DataConnection<ArrayBuffer>>(
(resolve) => {
runtimeScene
.getAsyncTasksManager()
.addTask(new P2PConnectionAwaiter(this.peerID), () =>
resolve(
gdjs.evtTools.p2p.getConnectionInstance(
this.peerID
) as Peer.DataConnection<ArrayBuffer>
)
);
}
);
}

this.connection.on("data", (data) =>
this.onMessage(new Uint8Array(data))
);

gdjs.registerRuntimeScenePreEventsCallback(this.boundPreEventsCallback);
}

close() {
gdjs.evtTools.p2p.disconnectFromPeer(this.peerID);
gdjs._unregisterCallback(this.boundPreEventsCallback);
}

protected doSendMessage(message: Uint8Array): void {
if (!this.connection) {
return logger.error(
"Tried to send a message on an unestablished connection!"
);
}
this.connection.send(
message.buffer.slice(message.buffer.byteLength - message.byteLength)
);
}
}

export class P2PServerAdapter extends THNK.ServerAdapter {
boundPreEventsCallback = () => this.preEventsCallback();

preEventsCallback() {
if (gdjs.evtTools.p2p.onConnection()) {
const connectedPeer = gdjs.evtTools.p2p.getConnectedPeer();
this.onConnection(connectedPeer);

const connectionInstance = gdjs.evtTools.p2p.getConnectionInstance(
connectedPeer
) as Peer.DataConnection<ArrayBuffer>;
connectionInstance.on("data", (data: ArrayBuffer) => {
this.onMessage(connectedPeer, new Uint8Array(data));
});
}

if (gdjs.evtTools.p2p.onDisconnect()) {
const disconnectedPeer = gdjs.evtTools.p2p.getDisconnectedPeer();
this.onDisconnection(disconnectedPeer);
}
}

async prepare(): Promise<void> {
gdjs.registerRuntimeScenePreEventsCallback(this.boundPreEventsCallback);
}

close() {
gdjs._unregisterCallback(this.boundPreEventsCallback);
}

protected doSendMessageTo(userID: string, message: Uint8Array): void {
const connection = gdjs.evtTools.p2p.getConnectionInstance(userID);
if (connection) {
connection.send(
message.buffer.slice(message.buffer.byteLength - message.byteLength)
);
}
}

getServerID(): string {
return gdjs.evtTools.p2p.getCurrentId();
}
}
}
37 changes: 37 additions & 0 deletions code/client/ApplyGameStateSnapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { GameStateSnapshot } from "../t-h-n-k";
import { deserializeObject } from "./ObjectDeserializer";
import { clientObjectsRegistery } from "./ClientObjectsRegistery";
import { unpackVariable } from "../VariablePacker";

export const applyGameStateSnapshotToScene = (
gameState: GameStateSnapshot,
runtimeScene: gdjs.RuntimeScene
) => {
if (!runtimeScene.thnkClient) return;

const rootVariable = gameState.variablesArray();
if (rootVariable)
unpackVariable(runtimeScene.getVariables().get("State"), rootVariable);

// Delete previous objects, as the snapshot expects to be applied to a blank state.
clientObjectsRegistery.forEach((runtimeObject) =>
runtimeObject.deleteFromScene(runtimeScene)
);
clientObjectsRegistery.clear();

if (gameState.objectsLength() !== 0) {
for (
let len = gameState.objectsLength(),
i = 0,
gameObject = gameState.objects(0)!;
i < len;
gameObject = gameState.objects(++i)!
) {
const name = gameObject.name();
if (!name) continue;
const obj = runtimeScene.createObject(name)!;console.log(obj);
deserializeObject(gameObject, obj);
clientObjectsRegistery.set(gameObject.id(), obj);
}
}
};
Loading

0 comments on commit e5a07f4

Please sign in to comment.