From c0ee40633a72ddcda98660a3c7b1dd7cf74cb78b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Lemaire?= Date: Mon, 24 Jul 2017 20:12:41 +0200 Subject: [PATCH] network: Added Exchange class (for communication between two peers) --- TODO.md | 1 + src/MainUI.ts | 17 +++-- src/common | 2 +- src/multi/Exchange.spec.ts | 64 +++++++++++++++++ src/multi/Exchange.ts | 121 ++++++++++++++++++++++++++++++++ src/multi/RemoteStorage.ts | 12 ++-- src/ui/battle/BattleView.ts | 19 +++++ src/ui/menu/LoadDialog.ts | 2 +- src/ui/options/OptionsDialog.ts | 50 +++++++++---- 9 files changed, 262 insertions(+), 26 deletions(-) create mode 100644 src/multi/Exchange.spec.ts create mode 100644 src/multi/Exchange.ts diff --git a/TODO.md b/TODO.md index bec0ceb..da60732 100644 --- a/TODO.md +++ b/TODO.md @@ -78,6 +78,7 @@ Artificial Intelligence Common UI --------- +* Better fonts, font effects... * Add caret/focus to text input * Fix hover being stuck when the cursor exits the window, or the item moves or is hidden * Add a standard confirm dialog diff --git a/src/MainUI.ts b/src/MainUI.ts index 8d11383..a38ab15 100644 --- a/src/MainUI.ts +++ b/src/MainUI.ts @@ -8,19 +8,20 @@ module TS.SpaceTac { // Router between game views export class MainUI extends Phaser.Game { // Current game session - session: GameSession; + session: GameSession + session_token: string | null // Audio manager - audio: UI.Audio; + audio: UI.Audio // Game options - options: UI.GameOptions; + options: UI.GameOptions // Storage used - storage: Storage; + storage: Storage // Headless mode - headless: boolean; + headless: boolean constructor(headless: boolean = false) { super(1920, 1080, headless ? Phaser.HEADLESS : Phaser.AUTO, '-space-tac'); @@ -31,6 +32,7 @@ module TS.SpaceTac { this.storage = localStorage; this.session = new GameSession(); + this.session_token = null; if (!headless) { this.state.onStateChange.add((state: string) => console.log(`View change: ${state}`)); @@ -77,6 +79,7 @@ module TS.SpaceTac { */ quitGame() { this.session = new GameSession(); + this.session_token = null; this.state.start('router'); } @@ -97,8 +100,9 @@ module TS.SpaceTac { /** * Set the current game session, and redirect to view router */ - setSession(session: GameSession): void { + setSession(session: GameSession, token?: string): void { this.session = session; + this.session_token = token || null; this.state.start("router"); } @@ -110,6 +114,7 @@ module TS.SpaceTac { var loaded = this.storage.getItem(name); if (loaded) { this.session = GameSession.loadFromString(loaded); + this.session_token = null; console.log("Game loaded"); return true; } else { diff --git a/src/common b/src/common index 95eb6e8..de41d5a 160000 --- a/src/common +++ b/src/common @@ -1 +1 @@ -Subproject commit 95eb6e8e69bace0fcc40392c04b0af96ffdffd3c +Subproject commit de41d5a99d9df6df6c5183c2e06185df7e2a055d diff --git a/src/multi/Exchange.spec.ts b/src/multi/Exchange.spec.ts new file mode 100644 index 0000000..0a230a7 --- /dev/null +++ b/src/multi/Exchange.spec.ts @@ -0,0 +1,64 @@ +module TS.SpaceTac.Multi.Specs { + describe("Exchange", function () { + function newExchange(token: string, storage = new FakeRemoteStorage()): [FakeRemoteStorage, Exchange, Exchange] { + let connection = new Connection("test", storage); + + let peer1 = new Exchange(connection, token, true, "peer1"); + let peer2 = new Exchange(connection, token, false, "peer2"); + peer1.timer = new Timer(true); + peer2.timer = new Timer(true); + /*peer1.debug = true; + peer2.debug = true;*/ + + return [storage, peer1, peer2]; + } + + beforeEach(function () { + spyOn(console, "log").and.stub(); + }); + + async_it("says hello on start", async function () { + let [storage, peer1, peer2] = newExchange("abc"); + spyOn(peer1, "getNextId").and.returnValues("1A", "1B", "1C"); + spyOn(peer2, "getNextId").and.returnValues("2A", "2B", "2C"); + + expect(peer1.next).toEqual("hello"); + expect(peer2.next).toEqual("hello"); + expect(peer1.remotepeer).toBeNull(); + expect(peer2.remotepeer).toBeNull(); + expect(peer1.writing).toBe(true); + expect(peer2.writing).toBe(false); + expect(peer1.count).toBe(0); + expect(peer2.count).toBe(0); + + await Promise.all([peer1.start(), peer2.start()]); + + expect(storage.collections["exchange"]).toEqual([ + { peer: "peer1", current: "hello", next: "1A", count: 0, token: "abc", over: true, data: null }, + { peer: "peer2", current: "1A", next: "2A", count: 1, token: "abc", over: true, data: null }, + ]); + + expect(peer1.next).toEqual("2A"); + expect(peer2.next).toEqual("2A"); + expect(peer1.remotepeer).toBe("peer2"); + expect(peer2.remotepeer).toBe("peer1"); + expect(peer1.writing).toBe(true); + expect(peer2.writing).toBe(false); + expect(peer1.count).toBe(2); + expect(peer2.count).toBe(2); + + // same peers, new message chain + [storage, peer1, peer2] = newExchange("abc", storage); + spyOn(peer1, "getNextId").and.returnValues("1R", "1S", "1T"); + spyOn(peer2, "getNextId").and.returnValues("2R", "2S", "2T"); + + await Promise.all([peer1.start(), peer2.start()]); + + expect(storage.collections["exchange"]).toEqual([ + { peer: "peer1", current: "hello", next: "1R", count: 0, token: "abc", over: true, data: null }, + { peer: "peer2", current: "1A", next: "2A", count: 1, token: "abc", over: true, data: null }, + { peer: "peer2", current: "1R", next: "2R", count: 1, token: "abc", over: true, data: null }, + ]); + }) + }) +} \ No newline at end of file diff --git a/src/multi/Exchange.ts b/src/multi/Exchange.ts new file mode 100644 index 0000000..9439879 --- /dev/null +++ b/src/multi/Exchange.ts @@ -0,0 +1,121 @@ +module TS.SpaceTac.Multi { + /** + * Network communication of two peers around a shared session + * + * An exchange is a sequence of messages + */ + export class Exchange { + connection: Connection + token: string + writing: boolean + localpeer: string + count = 0 + remotepeer: string | null = null + next = "hello" + closed = false + timer = new Timer() + debug = false + + constructor(connection: Connection, token: string, initiator = false, myid = connection.device_id) { + this.connection = connection; + this.token = token; + this.localpeer = myid; + this.writing = initiator; + } + + /** + * Initialize communication with the other peer + */ + async start(): Promise { + if (this.debug) { + console.debug("Exchange started", this.localpeer); + } + + if (this.writing) { + await this.writeMessage(null, true); + await this.readMessage(); + } else { + await this.readMessage(); + await this.writeMessage(null, true); + } + + console.log("Echange established", this.token, this.localpeer, this.remotepeer); + } + + /** + * Get a next frame id + */ + getNextId(): string { + return `${this.token}${this.count}${RandomGenerator.global.id(8)}`; + } + + /** + * Push a raw message + */ + async writeMessage(message: any, over: boolean) { + if (this.writing) { + if (this.debug) { + console.debug("Exchange write", this.localpeer, this.token, this.next); + } + + let futurenext = this.getNextId(); + let result = await this.connection.storage.upsert("exchange", { token: this.token, current: this.next }, + { peer: this.localpeer, over: over, data: message, next: futurenext, count: this.count }); + + this.count += 1; + this.next = futurenext; + if (over) { + this.writing = false; + } + return result; + } else { + throw new Error("[Exchange] Tried to write out-of-turn"); + } + } + + /** + * Wait for a single message + */ + async readMessage(): Promise { + if (this.writing) { + throw new Error("[Exchange] Tried to read out-of-turn"); + } else { + let message: any; + do { + if (this.debug) { + console.debug("Exchange read", this.localpeer, this.token, this.next); + } + message = await this.connection.storage.find("exchange", { token: this.token, current: this.next }); + if (!message) { + await this.timer.sleep(1000); + } + } while (!message); + + if (this.remotepeer) { + if (message.peer != this.remotepeer) { + throw new Error("[Exchange] Bad peer id"); + } + } else { + if (message.peer) { + this.remotepeer = message.peer; + } else { + throw new Error("[Exchange] No peer id"); + } + } + + if (message.count != this.count) { + throw new Error("[Exchange] Bad message count"); + } else { + this.count += 1; + } + + this.next = message.next; + if (message.over) { + this.writing = true; + } + + return message.data; + } + } + } +} \ No newline at end of file diff --git a/src/multi/RemoteStorage.ts b/src/multi/RemoteStorage.ts index 7b6e7f0..37f99f3 100644 --- a/src/multi/RemoteStorage.ts +++ b/src/multi/RemoteStorage.ts @@ -42,7 +42,7 @@ module TS.SpaceTac.Multi { return Parse.Object.extend("spacetac" + collection); } - async search(collection: string, fields: any) { + async search(collection: string, fields: any): Promise { let query = new Parse.Query(this.getModel(collection)); iteritems(fields, (key, value) => { query.equalTo(key, value); @@ -52,7 +52,7 @@ module TS.SpaceTac.Multi { return results.map(ParseRemoteStorage.unpack); } - async find(collection: string, fields: any) { + async find(collection: string, fields: any): Promise { let results = await this.search(collection, fields); if (results.length == 1) { return results[0]; @@ -61,7 +61,7 @@ module TS.SpaceTac.Multi { } } - async upsert(collection: string, unicity: any, additional: any) { + async upsert(collection: string, unicity: any, additional: any): Promise { let query = new Parse.Query(this.getModel(collection)); iteritems(unicity, (key, value) => { query.equalTo(key, value); @@ -99,12 +99,12 @@ module TS.SpaceTac.Multi { return this.collections[name]; } } - async search(collection: string, fields: any) { + async search(collection: string, fields: any): Promise { let objects = this.getCollection(collection); let result = objects.filter((obj: any) => !any(items(fields), ([key, value]) => obj[key] != value)); return result; } - async find(collection: string, fields: any) { + async find(collection: string, fields: any): Promise { let results = await this.search(collection, fields); if (results.length == 1) { return results[0]; @@ -112,7 +112,7 @@ module TS.SpaceTac.Multi { return null; } } - async upsert(collection: string, unicity: any, additional: any) { + async upsert(collection: string, unicity: any, additional: any): Promise { let existing = await this.find(collection, unicity); let base = existing || copy(unicity); copyfields(additional, base); diff --git a/src/ui/battle/BattleView.ts b/src/ui/battle/BattleView.ts index fc7f2b9..c4c1929 100644 --- a/src/ui/battle/BattleView.ts +++ b/src/ui/battle/BattleView.ts @@ -9,6 +9,9 @@ module TS.SpaceTac.UI { // Interacting player player: Player + // Exchange (for remote session only) + exchange: Multi.Exchange | null + // Layers layer_background: Phaser.Group layer_arena: Phaser.Group @@ -61,6 +64,7 @@ module TS.SpaceTac.UI { this.battle = battle; this.ship_hovered = null; this.background = null; + this.exchange = null; this.battle.timer = this.timer; @@ -134,6 +138,11 @@ module TS.SpaceTac.UI { // Start processing the log this.log_processor.start(); + + // If we are on a remote session, start the exchange + if (!this.session.primary && this.gameui.session_token) { + this.setupRemoteSession(this.gameui.session_token); + } } /** @@ -276,5 +285,15 @@ module TS.SpaceTac.UI { this.player.revertBattle(); this.game.state.start('router'); } + + /** + * Setup exchange for remote session + */ + async setupRemoteSession(token: string) { + this.exchange = new Multi.Exchange(this.getConnection(), token); + await this.exchange.start(); + + // TODO read actions and apply them + } } } diff --git a/src/ui/menu/LoadDialog.ts b/src/ui/menu/LoadDialog.ts index ee671d1..fd53608 100644 --- a/src/ui/menu/LoadDialog.ts +++ b/src/ui/menu/LoadDialog.ts @@ -76,7 +76,7 @@ module TS.SpaceTac.UI { session.primary = false; session.spectator = true; - this.view.gameui.setSession(session); + this.view.gameui.setSession(session, token); } }); } diff --git a/src/ui/options/OptionsDialog.ts b/src/ui/options/OptionsDialog.ts index a408a7a..372ed2d 100644 --- a/src/ui/options/OptionsDialog.ts +++ b/src/ui/options/OptionsDialog.ts @@ -54,23 +54,49 @@ module TS.SpaceTac.UI { /** * Show the invite page */ - pageInvite() { + async pageInvite() { this.pageCommon(); let conn = this.view.getConnection(); - conn.publish(this.view.session, "Multiplayer invitation").then(token => { - this.addText(this.width / 2, 540, "Give this invite code to your friend:", "#5398e9", 36, false, true); - this.addText(this.width / 2, 620, token, "#d6d6bd", 36, true, true); - this.addText(this.width / 2, 700, "Waiting for a connection...", "#b39256", 36, false, true); + try { + let token = await conn.publish(this.view.session, "Multiplayer invitation"); + this.displayMultiplayerToken(token); // TODO On cancel - this.addButton(this.width / 2, 840, () => this.pageMenu(), "options-button"); - this.addText(this.width / 2, 840, "Cancel", "#5398e9", 36, true, true); - }).catch(() => { - this.addText(this.width / 2, 620, "Could not establish connection to server", "#b35b56", 36, true, true); + let exchange = new Multi.Exchange(this.view.getConnection(), token, true); + await exchange.start(); - this.addButton(this.width / 2, 840, () => this.pageMenu(), "options-button"); - this.addText(this.width / 2, 840, "Cancel", "#5398e9", 36, true, true); - }); + // TODO Setup the exchange on current view + + this.close(); + } catch (err) { + this.displayConnectionError(); + } + } + + /** + * Display a multiplayer token page + */ + private displayMultiplayerToken(token: string) { + this.pageCommon(); + + this.addText(this.width / 2, 540, "Give this invite code to your friend:", "#5398e9", 36, false, true); + this.addText(this.width / 2, 620, token, "#d6d6bd", 36, true, true); + this.addText(this.width / 2, 700, "Waiting for a connection...", "#b39256", 36, false, true); + + this.addButton(this.width / 2, 840, () => this.pageMenu(), "options-button"); + this.addText(this.width / 2, 840, "Cancel", "#5398e9", 36, true, true); + } + + /** + * Display a connection error + */ + private displayConnectionError() { + this.pageCommon(); + + this.addText(this.width / 2, 620, "Could not establish connection to server", "#b35b56", 36, true, true); + + this.addButton(this.width / 2, 840, () => this.pageMenu(), "options-button"); + this.addText(this.width / 2, 840, "Cancel", "#5398e9", 36, true, true); } } }