diff --git a/TODO b/TODO index 0eb814f..59fe0c1 100644 --- a/TODO +++ b/TODO @@ -50,7 +50,6 @@ * Menu: fix background stars aggregating at right side when the game is not focused * Add ship personality (with icons to identify ?) * Tutorial -* Campaign save slots, with auto-save * Missions/quests system * Main story arc @@ -58,5 +57,3 @@ Later, if possible: * Replays * Multiplayer * Formation or deployment phase -* Saving to external file -* Saving to cloud diff --git a/bower.json b/bower.json index c7adf49..e870061 100644 --- a/bower.json +++ b/bower.json @@ -11,7 +11,6 @@ "dependencies": { "phaser": "2.6.2", "parse": "1.9.2", - "jasmine-core": "jasmine#^2.5.2", - "deep-diff": "0.3.0" + "jasmine-core": "jasmine#^2.5.2" } } \ No newline at end of file diff --git a/out/index.html b/out/index.html index c9e57ab..e23261a 100644 --- a/out/index.html +++ b/out/index.html @@ -15,7 +15,7 @@ padding: 0; margin: 0; } - + .game { width: 100%; height: 100vh; @@ -34,7 +34,6 @@ window.oncontextmenu = function (e) { e.preventDefault(); }; window.onload = function () { window.ui = new TS.SpaceTac.MainUI(); - window.connection = new TS.SpaceTac.Multi.Connection(window.ui); }; diff --git a/out/tests.html b/out/tests.html index 46ea9b5..ce694f7 100644 --- a/out/tests.html +++ b/out/tests.html @@ -7,22 +7,23 @@
-
-
+
+ - - - - - + + + + + + - + \ No newline at end of file diff --git a/package.json b/package.json index 6474803..7b9340e 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "description": "A tactical RPG set in space", "main": "src/build.js", "scripts": { + "shell": "${SHELL} || true", "postinstall": "bower install && typings install", "build": "tsc -p .", "pretest": "tsc -p .", @@ -19,6 +20,7 @@ "author": "Michael Lemaire", "license": "MIT", "devDependencies": { + "babel-polyfill": "^6.23.0", "bower": "~1.8", "codecov": "~2.1", "jasmine": "~2.5", @@ -26,9 +28,9 @@ "karma-coverage": "~1.1", "karma-jasmine": "~1.1", "karma-phantomjs-launcher": "~1.0", - "remap-istanbul": "~0.9", "live-server": "~1.2", + "remap-istanbul": "~0.9", "typescript": "~2.3", "typings": "~2.1" } -} \ No newline at end of file +} diff --git a/spec/support/karma.conf.js b/spec/support/karma.conf.js index 1fab6e3..2f068e6 100644 --- a/spec/support/karma.conf.js +++ b/spec/support/karma.conf.js @@ -1,5 +1,5 @@ // karma.conf.js -module.exports = function(config) { +module.exports = function (config) { config.set({ basePath: '../..', frameworks: ['jasmine'], @@ -12,14 +12,16 @@ module.exports = function(config) { 'out/build.js': ['coverage'] }, coverageReporter: { - type : 'json', - dir : 'out/coverage/', + type: 'json', + dir: 'out/coverage/', subdir: '.', file: 'coverage.json' }, files: [ + 'node_modules/babel-polyfill/dist/polyfill.js', 'out/vendor/phaser/build/phaser.js', + 'out/vendor/parse/parse.min.js', 'out/build.js' ] }) diff --git a/src/MainUI.ts b/src/MainUI.ts index 7f8d4dc..77d51f2 100644 --- a/src/MainUI.ts +++ b/src/MainUI.ts @@ -80,6 +80,14 @@ module TS.SpaceTac { } } + /** + * Set the current game session, and redirect to view router + */ + setSession(session: GameSession): void { + this.session = session; + this.state.start("router"); + } + /** * Load current game from local browser storage */ @@ -99,5 +107,24 @@ module TS.SpaceTac { return false; } } + + /** + * Get an hopefully unique device identifier + */ + getDeviceId(): string | null { + if (this.storage) { + const key = "spacetac-device-id"; + let stored = this.storage.getItem(key); + if (stored) { + return stored; + } else { + let generated = RandomGenerator.global.id(20); + this.storage.setItem(key, generated); + return generated; + } + } else { + return null; + } + } } } diff --git a/src/common b/src/common index 28bf87b..bc39ed7 160000 --- a/src/common +++ b/src/common @@ -1 +1 @@ -Subproject commit 28bf87b126d13f3144955cba4c07bbe03c65e224 +Subproject commit bc39ed78ef474e42cb20b3dbfc3dffa80c07ffe7 diff --git a/src/core/GameSession.ts b/src/core/GameSession.ts index 2c8ed68..e102ed1 100644 --- a/src/core/GameSession.ts +++ b/src/core/GameSession.ts @@ -1,17 +1,34 @@ module TS.SpaceTac { - // A game session, binding a universe and a player + /** + * A game session, binding a universe and a player + * + * This represents the current state of game + */ export class GameSession { + // "Hopefully"" unique session id + id: string + // Game universe - universe: Universe; + universe: Universe // Current connected player - player: Player; + player: Player constructor() { + this.id = RandomGenerator.global.id(20); this.universe = new Universe(); this.player = new Player(this.universe); } + /** + * Get an indicative description of the session (to help identify game saves) + */ + getDescription(): string { + let level = this.player.fleet.getLevel(); + let ships = this.player.fleet.ships.length; + return `Level ${level} - ${ships} ships`; + } + // Load a game state from a string static loadFromString(serialized: string): GameSession { var serializer = new Serializer(TS.SpaceTac); diff --git a/src/multi/Connection.spec.ts b/src/multi/Connection.spec.ts new file mode 100644 index 0000000..7c526ec --- /dev/null +++ b/src/multi/Connection.spec.ts @@ -0,0 +1,86 @@ +module TS.SpaceTac.Multi.Specs { + describe("Connection", function () { + async_it("finds an unused token", async function () { + let storage = new FakeRemoteStorage(); + let connection = new Connection("test", storage); + + let token = await connection.getUnusedToken(5); + expect(token.length).toBe(5); + + await storage.upsert("sessioninfo", { token: token }, {}); + + spyOn(connection, "generateToken").and.returnValues(token, "123456"); + + let other = await connection.getUnusedToken(5); + expect(other).toEqual("123456"); + }); + + async_it("loads a session by its id", async function () { + let session = new GameSession(); + let serializer = new Serializer(TS.SpaceTac); + let storage = new FakeRemoteStorage(); + let connection = new Connection("test", storage); + + let result = await connection.loadById("abc"); + expect(result).toBeNull(); + + await storage.upsert("session", { ref: "abc" }, { data: serializer.serialize(session) }); + + result = await connection.loadById("abc"); + expect(result).toEqual(session); + result = await connection.loadById("abcd"); + expect(result).toBeNull(); + + // even from another device + let other = new Connection("notest", storage); + result = await other.loadById("abc"); + expect(result).toEqual(session); + + // do not load if it is not a GameSession + await storage.upsert("session", { ref: "abcd" }, { data: serializer.serialize(new Player()) }); + result = await connection.loadById("abcd"); + expect(result).toBeNull(); + }); + + async_it("lists saves from a device", async function () { + let storage = new FakeRemoteStorage(); + let connection = new Connection("test", storage); + + let result = await connection.listSaves(); + expect(result).toEqual({}); + + await storage.upsert("sessioninfo", { device: "test", ref: "abc" }, { info: "ABC" }); + await storage.upsert("sessioninfo", { device: "other", ref: "abcd" }, { info: "ABCD" }); + await storage.upsert("sessioninfo", { device: "test", ref: "cba" }, { info: "CBA" }); + + result = await connection.listSaves(); + expect(result).toEqual({ abc: "ABC", cba: "CBA" }); + }); + + async_it("publishes saves and retrieves them by token", async function () { + let session = new GameSession(); + let storage = new FakeRemoteStorage(); + let connection = new Connection("test", storage); + + let saves = await connection.listSaves(); + expect(items(saves).length).toEqual(0); + + let token = await connection.publish(session, "TEST"); + + saves = await connection.listSaves(); + expect(items(saves).length).toEqual(1); + + let loaded = await connection.loadByToken(token); + expect(loaded).toEqual(session); + + let newtoken = await connection.publish(nn(loaded), "TEST"); + expect(token).toEqual(newtoken); + + loaded = await connection.loadByToken(token); + expect(loaded).toEqual(session); + + saves = await connection.listSaves(); + expect(items(saves).length).toEqual(1); + }); + }); +} diff --git a/src/multi/Connection.ts b/src/multi/Connection.ts index ef39842..052926f 100644 --- a/src/multi/Connection.ts +++ b/src/multi/Connection.ts @@ -1,42 +1,53 @@ -/// - module TS.SpaceTac.Multi { /** * Multiplayer connection to a Parse server */ export class Connection { - ui: MainUI + device_id: string serializer = new Serializer(TS.SpaceTac) - model_session = Parse.Object.extend("SpaceTacSession") token_chars = "abcdefghjkmnpqrstuvwxyz123456789" + storage: IRemoteStorage - constructor(ui: MainUI) { - this.ui = ui; + constructor(device_id: string, storage: IRemoteStorage) { + this.device_id = device_id; + this.storage = storage; + } - Parse.initialize("thunderk.net"); - Parse.serverURL = 'https://rs.thunderk.net/parse'; + /** + * Generate a random token + */ + generateToken(length: number): string { + return range(length).map(() => RandomGenerator.global.choice(this.token_chars)).join(""); } /** * Find an unused session token */ - getUnusedToken(length = 5): string { - let token = range(length).map(() => RandomGenerator.global.choice(this.token_chars)).join(""); - // TODO check if it is unused on server + async getUnusedToken(length = 5): Promise { + let token = this.generateToken(length); + let existing = await this.storage.search("sessioninfo", { token: token }); + if (existing.length > 0) { + token = await this.getUnusedToken(length + 1); + } return token; } /** - * Publish current session to remote server, and return a session token + * Publish a session to remote server, and return an invitation token */ - publish(): string { - let session = new this.model_session(); - let token = this.getUnusedToken(); + async publish(session: GameSession, description: string): Promise { + await this.storage.upsert("session", { ref: session.id }, { data: this.serializer.serialize(session) }); - session.set("token", token); - session.set("data", this.serializer.serialize(this.ui.session)); + let now = new Date(); + let date = now.toISOString().substr(0, 10) + " " + now.toTimeString().substr(0, 5); + let info = `${date}\n${description}`; - session.save(); + let sessinfo = await this.storage.find("sessioninfo", { ref: session.id, device: this.device_id }); + let token: string = sessinfo ? sessinfo.token : ""; + if (token.length == 0) { + token = await this.getUnusedToken(); + } + await this.storage.upsert("sessioninfo", { ref: session.id, device: this.device_id, token: token }, { info: info }); return token; } @@ -44,21 +55,35 @@ module TS.SpaceTac.Multi { /** * Load a session from a remote server, by its token */ - load(token: string): void { - let query = new Parse.Query(this.model_session); - query.equalTo("token", token); - query.find({ - success: (results: any) => { - if (results.length == 1) { - let data = results[0].get("data"); - let session = this.serializer.unserialize(data); - if (session instanceof GameSession) { - this.ui.session = session; - this.ui.state.start('router'); - } - } + async loadByToken(token: string): Promise { + let info = await this.storage.find("sessioninfo", { token: token }); + if (info) { + return this.loadById(info.ref); + } else { + return null; + } + } + + /** + * Load a session from a remote server, by its id + */ + async loadById(id: string): Promise { + let session = await this.storage.find("session", { ref: id }); + if (session) { + let loaded = this.serializer.unserialize(session.data); + if (loaded instanceof GameSession) { + return loaded; } - }); + } + return null; + } + + /** + * List cloud saves, associated with current device + */ + async listSaves(): Promise<{ [id: string]: string }> { + let results = await this.storage.search("sessioninfo", { device: this.device_id }); + return dict(results.map(obj => <[string, string]>[obj.ref, obj.info])); } } } diff --git a/src/multi/RemoteStorage.spec.ts b/src/multi/RemoteStorage.spec.ts new file mode 100644 index 0000000..c8b81ef --- /dev/null +++ b/src/multi/RemoteStorage.spec.ts @@ -0,0 +1,51 @@ +module TS.SpaceTac.Multi.Specs { + describe("FakeRemoteStorage", function () { + async_it("can fetch a single record", async function () { + let storage = new FakeRemoteStorage(); + + let result = await storage.find("test", { key: 5 }); + expect(result).toBeNull(); + + await storage.upsert("test", { key: 5 }, { text: "thingy" }); + + result = await storage.find("test", { key: 5 }); + expect(result).toEqual({ key: 5, text: "thingy" }); + + result = await storage.find("test", { key: 6 }); + expect(result).toBeNull(); + + result = await storage.find("test", { key: 5, text: "thingy" }); + expect(result).toEqual({ key: 5, text: "thingy" }); + + result = await storage.find("notest", { key: 5 }); + expect(result).toBeNull(); + }); + + async_it("inserts or updates objects", async function () { + let storage = new FakeRemoteStorage(); + + let result = await storage.search("test", { key: 5 }); + expect(result).toEqual([]); + + await storage.upsert("test", { key: 5 }, {}); + + result = await storage.search("test", { key: 5 }); + expect(result).toEqual([{ key: 5 }]); + + await storage.upsert("test", { key: 5 }, { text: "thingy" }); + + result = await storage.search("test", { key: 5 }); + expect(result).toEqual([{ key: 5, text: "thingy" }]); + + await storage.upsert("test", { key: 5 }, { text: "other thingy" }); + + result = await storage.search("test", { key: 5 }); + expect(result).toEqual([{ key: 5, text: "other thingy" }]); + + await storage.upsert("test", { key: 5, text: "things" }, {}); + + result = await storage.search("test", { key: 5 }); + expect(result.sort((a: any, b: any) => cmp(a.text, b.text))).toEqual([{ key: 5, text: "other thingy" }, { key: 5, text: "things" }]); + }); + }); +} \ No newline at end of file diff --git a/src/multi/RemoteStorage.ts b/src/multi/RemoteStorage.ts new file mode 100644 index 0000000..7b6e7f0 --- /dev/null +++ b/src/multi/RemoteStorage.ts @@ -0,0 +1,125 @@ +/// + +module TS.SpaceTac.Multi { + /** + * Interface for a remote storage, used for networking/multiplayer features + */ + export interface IRemoteStorage { + /** + * Search through a collection for equality of some fields + */ + search(collection: string, fields: any): Promise + /** + * Find a single object with equality of some fields + */ + find(collection: string, fields: any): Promise + /** + * Insert or update an object in a collection, based on some unicity fields + */ + upsert(collection: string, unicity: any, additional: any): Promise + } + + /** + * Remote storage using the Parse protocol + */ + export class ParseRemoteStorage implements IRemoteStorage { + constructor() { + Parse.initialize("thunderk.net"); + Parse.serverURL = 'https://rs.thunderk.net/parse'; + } + + /** + * Unpack a Parse.Object to a javascript object + */ + static unpack(obj: Parse.Object): Object { + return obj.toJSON(); + } + + /** + * Get the Parse model for a given collection name. + */ + private getModel(collection: string): any { + return Parse.Object.extend("spacetac" + collection); + } + + async search(collection: string, fields: any) { + let query = new Parse.Query(this.getModel(collection)); + iteritems(fields, (key, value) => { + query.equalTo(key, value); + }); + + let results = await query.find(); + return results.map(ParseRemoteStorage.unpack); + } + + async find(collection: string, fields: any) { + let results = await this.search(collection, fields); + if (results.length == 1) { + return results[0]; + } else { + return null; + } + } + + async upsert(collection: string, unicity: any, additional: any) { + let query = new Parse.Query(this.getModel(collection)); + iteritems(unicity, (key, value) => { + query.equalTo(key, value); + }); + + let results = await query.find(); + let model = this.getModel(collection); + let base = new model(); + if (results.length == 1) { + base = results[0]; + } else { + iteritems(unicity, (key, value) => { + base.set(key, value); + }); + } + + iteritems(additional, (key, value) => { + base.set(key, value); + }); + await base.save(); + } + } + + /** + * Fake remote storage in memory (for testing purposes) + */ + export class FakeRemoteStorage implements IRemoteStorage { + collections: { [collection: string]: any[] } = {} + getCollection(name: string): any { + let collection = this.collections[name]; + if (collection) { + return collection; + } else { + this.collections[name] = []; + return this.collections[name]; + } + } + async search(collection: string, fields: any) { + 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) { + let results = await this.search(collection, fields); + if (results.length == 1) { + return results[0]; + } else { + return null; + } + } + async upsert(collection: string, unicity: any, additional: any) { + let existing = await this.find(collection, unicity); + let base = existing || copy(unicity); + copyfields(additional, base); + if (!existing) { + let objects = this.getCollection(collection); + objects.push(base); + } + } + } +} diff --git a/src/ui/BaseView.ts b/src/ui/BaseView.ts index cdc5080..80411a3 100644 --- a/src/ui/BaseView.ts +++ b/src/ui/BaseView.ts @@ -88,5 +88,32 @@ module TS.SpaceTac.UI { let layer = this.add.group(this.layers); return layer; } + + /** + * Get a network connection to the backend server + */ + getConnection(): Multi.Connection { + let device_id = this.gameui.getDeviceId(); + if (device_id) { + return new Multi.Connection(device_id, new Multi.ParseRemoteStorage()); + } else { + // TODO Should warn the user ! + return new Multi.Connection("fake", new Multi.FakeRemoteStorage()); + } + } + + /** + * Auto-save current session to cloud + * + * This may be called at key points during the gameplay + */ + autoSave(): void { + let session = this.gameui.session; + let connection = this.getConnection(); + connection.publish(session, session.getDescription()) + .then(() => this.messages.addMessage("Auto-saved to cloud")) + .catch(console.error) + //.catch(() => this.messages.addMessage("Error saving game to cloud")); + } } } diff --git a/src/ui/TestGame.ts b/src/ui/TestGame.ts index ef8849d..aff59e5 100644 --- a/src/ui/TestGame.ts +++ b/src/ui/TestGame.ts @@ -12,6 +12,7 @@ module TS.SpaceTac.UI.Specs { baseview: BaseView; battleview: BattleView; mapview: UniverseMapView; + multistorage: Multi.FakeRemoteStorage; } /** @@ -35,6 +36,12 @@ module TS.SpaceTac.UI.Specs { let [state, stateargs] = buildView(testgame); + if (state instanceof BaseView) { + testgame.multistorage = new Multi.FakeRemoteStorage(); + let connection = new Multi.Connection(RandomGenerator.global.id(12), testgame.multistorage); + spyOn(state, "getConnection").and.returnValue(connection); + } + let orig_create = bound(state, "create"); spyOn(state, "create").and.callFake(() => { orig_create(); diff --git a/src/ui/battle/ActionIcon.ts b/src/ui/battle/ActionIcon.ts index 129bac8..75c3a1e 100644 --- a/src/ui/battle/ActionIcon.ts +++ b/src/ui/battle/ActionIcon.ts @@ -77,7 +77,7 @@ module TS.SpaceTac.UI { }; // Events - Tools.setHoverClick(this, show_info, hide_info, () => this.processClick()); + UITools.setHoverClick(this, show_info, hide_info, () => this.processClick()); // Initialize this.updateActiveStatus(true); diff --git a/src/ui/battle/ArenaShip.ts b/src/ui/battle/ArenaShip.ts index 205e795..314e980 100644 --- a/src/ui/battle/ArenaShip.ts +++ b/src/ui/battle/ArenaShip.ts @@ -62,7 +62,7 @@ module TS.SpaceTac.UI { this.addChild(this.effects); // Handle input on ship sprite - Tools.setHoverClick(this.sprite, + UITools.setHoverClick(this.sprite, () => this.battleview.cursorOnShip(ship), () => this.battleview.cursorOffShip(ship), () => this.battleview.cursorClicked() diff --git a/src/ui/battle/ShipListItem.ts b/src/ui/battle/ShipListItem.ts index e930b2d..7fe7245 100644 --- a/src/ui/battle/ShipListItem.ts +++ b/src/ui/battle/ShipListItem.ts @@ -66,7 +66,7 @@ module TS.SpaceTac.UI { level.anchor.set(0.5, 0.5); this.addChild(level); - Tools.setHoverClick(this, () => list.battleview.cursorOnShip(ship), () => list.battleview.cursorOffShip(ship), () => list.battleview.cursorClicked()); + UITools.setHoverClick(this, () => list.battleview.cursorOnShip(ship), () => list.battleview.cursorOffShip(ship), () => list.battleview.cursorClicked()); } // Update attributes from associated ship diff --git a/src/ui/common/Animations.ts b/src/ui/common/Animations.ts index d2ae8a3..9e847ea 100644 --- a/src/ui/common/Animations.ts +++ b/src/ui/common/Animations.ts @@ -111,17 +111,17 @@ module TS.SpaceTac.UI { */ static rotationTween(tween: Phaser.Tween, dest: number, speed = 1, easing = Phaser.Easing.Cubic.InOut, property = "rotation"): number { // Immediately change the object's current rotation to be in range (-pi,pi) - let value = Tools.normalizeAngle(tween.target[property]); + let value = UITools.normalizeAngle(tween.target[property]); tween.target[property] = value; // Compute destination angle - dest = Tools.normalizeAngle(dest); + dest = UITools.normalizeAngle(dest); if (value - dest > Math.PI) { dest += 2 * Math.PI; } else if (value - dest < -Math.PI) { dest -= 2 * Math.PI; } - let distance = Math.abs(Tools.normalizeAngle(dest - value)) / Math.PI; + let distance = Math.abs(UITools.normalizeAngle(dest - value)) / Math.PI; let duration = distance * 1000 / speed; // Update the tween diff --git a/src/ui/common/Tooltip.ts b/src/ui/common/Tooltip.ts index 2a863e6..c82e264 100644 --- a/src/ui/common/Tooltip.ts +++ b/src/ui/common/Tooltip.ts @@ -37,7 +37,7 @@ module TS.SpaceTac.UI { this.background.endFill(); } - let [x, y] = Tools.positionInside({ x: this.anchorpoint[0], y: this.anchorpoint[1], width: width, height: height }, { x: 0, y: 0, width: this.view.getWidth(), height: this.view.getHeight() }); + let [x, y] = UITools.positionInside({ x: this.anchorpoint[0], y: this.anchorpoint[1], width: width, height: height }, { x: 0, y: 0, width: this.view.getWidth(), height: this.view.getHeight() }); if (x != this.x || y != this.y) { this.position.set(x, y); } @@ -68,7 +68,7 @@ module TS.SpaceTac.UI { * When the component is hovered, the function is called to allow filling the tooltip container */ bind(obj: Phaser.Button, func: (container: Phaser.Group) => boolean): void { - Tools.setHoverClick(obj, + UITools.setHoverClick(obj, // enter () => { this.hide(); diff --git a/src/ui/common/UILabel.ts b/src/ui/common/UILabel.ts new file mode 100644 index 0000000..b10fe07 --- /dev/null +++ b/src/ui/common/UILabel.ts @@ -0,0 +1,25 @@ +/// + +module TS.SpaceTac.UI { + /** + * UI component to display a text + */ + export class UILabel extends UIComponent { + private content: Phaser.Text + + constructor(parent: UIComponent, width: number, height: number, content = "", fontsize = 20, fontcolor = "#FFFFFF") { + super(parent, width, height); + + this.content = new Phaser.Text(this.game, width / 2, height / 2, content, { align: "center", font: `${fontsize}px Arial`, fill: fontcolor }) + this.content.anchor.set(0.5, 0.5); + this.addInternalChild(this.content); + } + + /** + * Set the label content + */ + setContent(text: string): void { + this.content.text = text; + } + } +} \ No newline at end of file diff --git a/src/ui/common/UITextInput.ts b/src/ui/common/UITextInput.ts index bd67179..111445d 100644 --- a/src/ui/common/UITextInput.ts +++ b/src/ui/common/UITextInput.ts @@ -8,7 +8,7 @@ module TS.SpaceTac.UI { private content: Phaser.Text private maxlength: number - constructor(parent: UIComponent, width: number, height: number, maxlength?: number) { + constructor(parent: UIComponent, width: number, height: number, maxlength?: number, fontcolor = "#FFFFFF") { super(parent, width, height); let input_bg = new Phaser.Image(this.game, 0, 0, "common-transparent"); @@ -19,7 +19,7 @@ module TS.SpaceTac.UI { this.addInternalChild(input_bg); let fontsize = Math.ceil(height * 0.8); - this.content = new Phaser.Text(this.game, width / 2, height / 2, "", { align: "center", font: `${fontsize}px Arial`, fill: "#FFFFFF" }); + this.content = new Phaser.Text(this.game, width / 2, height / 2, "", { align: "center", font: `${fontsize}px Arial`, fill: fontcolor }); this.content.anchor.set(0.5, 0.5); this.addInternalChild(this.content); diff --git a/src/ui/common/Tools.spec.ts b/src/ui/common/UITools.spec.ts similarity index 77% rename from src/ui/common/Tools.spec.ts rename to src/ui/common/UITools.spec.ts index f11037c..81439d6 100644 --- a/src/ui/common/Tools.spec.ts +++ b/src/ui/common/UITools.spec.ts @@ -1,5 +1,5 @@ module TS.SpaceTac.UI.Specs { - describe("Tools", function () { + describe("UITools", function () { let testgame = setupEmptyView(); it("keeps objects inside bounds", function () { @@ -8,19 +8,19 @@ module TS.SpaceTac.UI.Specs { image.drawEllipse(50, 25, 50, 25); image.endFill(); - Tools.keepInside(image, { x: 0, y: 0, width: 200, height: 200 }); + UITools.keepInside(image, { x: 0, y: 0, width: 200, height: 200 }); expect(image.x).toBe(100); expect(image.y).toBe(100); }); it("normalizes angles", function () { - expect(Tools.normalizeAngle(0)).toEqual(0); - expect(Tools.normalizeAngle(0.1)).toBeCloseTo(0.1, 0.000001); - expect(Tools.normalizeAngle(Math.PI)).toBeCloseTo(Math.PI, 0.000001); - expect(Tools.normalizeAngle(Math.PI + 0.5)).toBeCloseTo(-Math.PI + 0.5, 0.000001); - expect(Tools.normalizeAngle(-Math.PI)).toBeCloseTo(Math.PI, 0.000001); - expect(Tools.normalizeAngle(-Math.PI - 0.5)).toBeCloseTo(Math.PI - 0.5, 0.000001); + expect(UITools.normalizeAngle(0)).toEqual(0); + expect(UITools.normalizeAngle(0.1)).toBeCloseTo(0.1, 0.000001); + expect(UITools.normalizeAngle(Math.PI)).toBeCloseTo(Math.PI, 0.000001); + expect(UITools.normalizeAngle(Math.PI + 0.5)).toBeCloseTo(-Math.PI + 0.5, 0.000001); + expect(UITools.normalizeAngle(-Math.PI)).toBeCloseTo(Math.PI, 0.000001); + expect(UITools.normalizeAngle(-Math.PI - 0.5)).toBeCloseTo(Math.PI - 0.5, 0.000001); }); it("handles hover and click on desktops and mobile targets", function (done) { @@ -36,7 +36,7 @@ module TS.SpaceTac.UI.Specs { spyOn(funcs, "enter"); spyOn(funcs, "leave"); spyOn(funcs, "click"); - Tools.setHoverClick(button, funcs.enter, funcs.leave, funcs.click, 50, 100); + UITools.setHoverClick(button, funcs.enter, funcs.leave, funcs.click, 50, 100); return [button, funcs]; } diff --git a/src/ui/common/Tools.ts b/src/ui/common/UITools.ts similarity index 96% rename from src/ui/common/Tools.ts rename to src/ui/common/UITools.ts index a87f4b1..89b9e2f 100644 --- a/src/ui/common/Tools.ts +++ b/src/ui/common/UITools.ts @@ -7,7 +7,7 @@ module TS.SpaceTac.UI { } // Common UI tools functions - export class Tools { + export class UITools { /** * Get the position of an object, adjusted to remain inside a container */ @@ -36,7 +36,7 @@ module TS.SpaceTac.UI { */ static keepInside(obj: Phaser.Button | Phaser.Sprite | Phaser.Image | Phaser.Group | Phaser.Graphics, rect: IBounded) { let objbounds = obj.getBounds(); - let [x, y] = Tools.positionInside({ x: obj.x, y: obj.y, width: objbounds.width, height: objbounds.height }, rect); + let [x, y] = UITools.positionInside({ x: obj.x, y: obj.y, width: objbounds.width, height: objbounds.height }, rect); if (x != obj.x || y != obj.y) { obj.position.set(x, y); diff --git a/src/ui/map/UniverseMapView.ts b/src/ui/map/UniverseMapView.ts index eb88e0c..db54b1b 100644 --- a/src/ui/map/UniverseMapView.ts +++ b/src/ui/map/UniverseMapView.ts @@ -103,6 +103,9 @@ module TS.SpaceTac.UI { this.inputs.bindCheat("r", "Reveal whole map", this.revealAll); this.setZoom(2); + + // Trigger an auto-save any time we go back to the map + this.autoSave(); } /** diff --git a/src/ui/menu/LoadDialog.ts b/src/ui/menu/LoadDialog.ts index 7b75207..4617b3d 100644 --- a/src/ui/menu/LoadDialog.ts +++ b/src/ui/menu/LoadDialog.ts @@ -5,16 +5,91 @@ module TS.SpaceTac.UI { * Dialog to load a saved game, or join an online one */ export class LoadDialog extends UIComponent { + saves: [string, string][] = [] + save_selected = 0 + save_name: UILabel + token_input: UITextInput + constructor(parent: MainMenu) { super(parent, 1344, 566, "menu-load-bg"); - this.addButton(600, 115, () => null, "common-arrow", "common-arrow", 180); - this.addButton(1038, 115, () => null, "common-arrow", "common-arrow", 0); - this.addButton(1224, 115, () => null, "common-button-cancel"); - this.addButton(1224, 341, () => null, "common-button-cancel"); + this.addButton(600, 115, () => this.paginateSave(-1), "common-arrow", "common-arrow", 180); + this.addButton(1038, 115, () => this.paginateSave(1), "common-arrow", "common-arrow", 0); + this.addButton(1224, 115, () => this.load(), "common-button-ok"); + this.addButton(1224, 341, () => this.join(), "common-button-ok"); - let input = new UITextInput(this, 468, 68, 10); - input.setPosition(585, 304); + this.save_name = new UILabel(this, 351, 185, "", 32, "#000000"); + this.save_name.setPosition(645, 28); + + this.token_input = new UITextInput(this, 468, 68, 10, "#000000"); + this.token_input.setPosition(585, 304); + + this.refreshSaves(); + } + + /** + * Refresh available save games + */ + private refreshSaves(): void { + let connection = this.view.getConnection(); + + // TODO include local save + // TODO Disable interaction, with loading icon + + connection.listSaves().then(results => { + this.saves = items(results).sort(([id1, info1], [id2, info2]) => cmp(info2, info1)); + this.setCurrentSave(0); + }); + } + + /** + * Set the current selected save game + */ + private setCurrentSave(position: number): void { + if (this.saves.length == 0) { + this.save_name.setContent("No save game found"); + } else { + this.save_selected = clamp(position, 0, this.saves.length - 1); + + let [saveid, saveinfo] = this.saves[this.save_selected]; + this.save_name.setContent(saveinfo); + } + } + + /** + * Change the selected save + */ + private paginateSave(offset: number) { + this.setCurrentSave(this.save_selected + offset); + } + + /** + * Join an online game + */ + private join(): void { + let token = this.token_input.getContent(); + let connection = this.view.getConnection(); + + connection.loadByToken(token).then(session => { + if (session) { + this.view.gameui.setSession(session); + } + }); + } + + /** + * Load selected save game + */ + private load(): void { + if (this.save_selected >= 0 && this.saves.length > this.save_selected) { + let connection = this.view.getConnection(); + let [saveid, saveinfo] = this.saves[this.save_selected]; + connection.loadById(saveid).then(session => { + if (session) { + this.view.gameui.setSession(session); + } + }); + } } } } diff --git a/tsconfig.json b/tsconfig.json index bac090e..2183898 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,11 @@ "out": "out/build.js", "strict": true, "sourceMap": true, + "lib": [ + "dom", + "es2015.promise", + "es5" + ], "target": "es5" }, "include": [