1
0
Fork 0

Added auto-saving to cloud, and loading cloud saves

This commit is contained in:
Michaël Lemaire 2017-05-05 01:19:28 +02:00
parent ed86f57529
commit 27302267b9
27 changed files with 559 additions and 86 deletions

3
TODO
View file

@ -50,7 +50,6 @@
* Menu: fix background stars aggregating at right side when the game is not focused * Menu: fix background stars aggregating at right side when the game is not focused
* Add ship personality (with icons to identify ?) * Add ship personality (with icons to identify ?)
* Tutorial * Tutorial
* Campaign save slots, with auto-save
* Missions/quests system * Missions/quests system
* Main story arc * Main story arc
@ -58,5 +57,3 @@ Later, if possible:
* Replays * Replays
* Multiplayer * Multiplayer
* Formation or deployment phase * Formation or deployment phase
* Saving to external file
* Saving to cloud

View file

@ -11,7 +11,6 @@
"dependencies": { "dependencies": {
"phaser": "2.6.2", "phaser": "2.6.2",
"parse": "1.9.2", "parse": "1.9.2",
"jasmine-core": "jasmine#^2.5.2", "jasmine-core": "jasmine#^2.5.2"
"deep-diff": "0.3.0"
} }
} }

View file

@ -15,7 +15,7 @@
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
.game { .game {
width: 100%; width: 100%;
height: 100vh; height: 100vh;
@ -34,7 +34,6 @@
window.oncontextmenu = function (e) { e.preventDefault(); }; window.oncontextmenu = function (e) { e.preventDefault(); };
window.onload = function () { window.onload = function () {
window.ui = new TS.SpaceTac.MainUI(); window.ui = new TS.SpaceTac.MainUI();
window.connection = new TS.SpaceTac.Multi.Connection(window.ui);
}; };
</script> </script>
</body> </body>

View file

@ -7,22 +7,23 @@
<link rel="stylesheet" href="vendor/jasmine-core/lib/jasmine-core/jasmine.css"> <link rel="stylesheet" href="vendor/jasmine-core/lib/jasmine-core/jasmine.css">
<style> <style>
canvas { canvas {
display: none; display: none;
} }
</style> </style>
</head> </head>
<body> <body>
<div style="display: none; visibility: hidden; height: 0; overflow: hidden;"> <div style="display: none; visibility: hidden; height: 0; overflow: hidden;">
<div id="-space-tac" class="game"></div> <div id="-space-tac" class="game"></div>
</div> </div>
<script src="vendor/jasmine-core/lib/jasmine-core/jasmine.js"></script> <script src="vendor/jasmine-core/lib/jasmine-core/jasmine.js"></script>
<script src="vendor/jasmine-core/lib/jasmine-core/jasmine-html.js"></script> <script src="vendor/jasmine-core/lib/jasmine-core/jasmine-html.js"></script>
<script src="vendor/jasmine-core/lib/jasmine-core/boot.js"></script> <script src="vendor/jasmine-core/lib/jasmine-core/boot.js"></script>
<script src="vendor/phaser/build/phaser.min.js"></script> <script src="vendor/parse/parse.min.js"></script>
<script src="build.js"></script> <script src="vendor/phaser/build/phaser.min.js"></script>
<script src="build.js"></script>
</body> </body>
</html> </html>

View file

@ -4,6 +4,7 @@
"description": "A tactical RPG set in space", "description": "A tactical RPG set in space",
"main": "src/build.js", "main": "src/build.js",
"scripts": { "scripts": {
"shell": "${SHELL} || true",
"postinstall": "bower install && typings install", "postinstall": "bower install && typings install",
"build": "tsc -p .", "build": "tsc -p .",
"pretest": "tsc -p .", "pretest": "tsc -p .",
@ -19,6 +20,7 @@
"author": "Michael Lemaire", "author": "Michael Lemaire",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"babel-polyfill": "^6.23.0",
"bower": "~1.8", "bower": "~1.8",
"codecov": "~2.1", "codecov": "~2.1",
"jasmine": "~2.5", "jasmine": "~2.5",
@ -26,9 +28,9 @@
"karma-coverage": "~1.1", "karma-coverage": "~1.1",
"karma-jasmine": "~1.1", "karma-jasmine": "~1.1",
"karma-phantomjs-launcher": "~1.0", "karma-phantomjs-launcher": "~1.0",
"remap-istanbul": "~0.9",
"live-server": "~1.2", "live-server": "~1.2",
"remap-istanbul": "~0.9",
"typescript": "~2.3", "typescript": "~2.3",
"typings": "~2.1" "typings": "~2.1"
} }
} }

View file

@ -1,5 +1,5 @@
// karma.conf.js // karma.conf.js
module.exports = function(config) { module.exports = function (config) {
config.set({ config.set({
basePath: '../..', basePath: '../..',
frameworks: ['jasmine'], frameworks: ['jasmine'],
@ -12,14 +12,16 @@ module.exports = function(config) {
'out/build.js': ['coverage'] 'out/build.js': ['coverage']
}, },
coverageReporter: { coverageReporter: {
type : 'json', type: 'json',
dir : 'out/coverage/', dir: 'out/coverage/',
subdir: '.', subdir: '.',
file: 'coverage.json' file: 'coverage.json'
}, },
files: [ files: [
'node_modules/babel-polyfill/dist/polyfill.js',
'out/vendor/phaser/build/phaser.js', 'out/vendor/phaser/build/phaser.js',
'out/vendor/parse/parse.min.js',
'out/build.js' 'out/build.js'
] ]
}) })

View file

@ -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 * Load current game from local browser storage
*/ */
@ -99,5 +107,24 @@ module TS.SpaceTac {
return false; 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;
}
}
} }
} }

@ -1 +1 @@
Subproject commit 28bf87b126d13f3144955cba4c07bbe03c65e224 Subproject commit bc39ed78ef474e42cb20b3dbfc3dffa80c07ffe7

View file

@ -1,17 +1,34 @@
module TS.SpaceTac { 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 { export class GameSession {
// "Hopefully"" unique session id
id: string
// Game universe // Game universe
universe: Universe; universe: Universe
// Current connected player // Current connected player
player: Player; player: Player
constructor() { constructor() {
this.id = RandomGenerator.global.id(20);
this.universe = new Universe(); this.universe = new Universe();
this.player = new Player(this.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 // Load a game state from a string
static loadFromString(serialized: string): GameSession { static loadFromString(serialized: string): GameSession {
var serializer = new Serializer(TS.SpaceTac); var serializer = new Serializer(TS.SpaceTac);

View file

@ -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);
});
});
}

View file

@ -1,42 +1,53 @@
/// <reference path="Parse.d.ts" />
module TS.SpaceTac.Multi { module TS.SpaceTac.Multi {
/** /**
* Multiplayer connection to a Parse server * Multiplayer connection to a Parse server
*/ */
export class Connection { export class Connection {
ui: MainUI device_id: string
serializer = new Serializer(TS.SpaceTac) serializer = new Serializer(TS.SpaceTac)
model_session = Parse.Object.extend("SpaceTacSession")
token_chars = "abcdefghjkmnpqrstuvwxyz123456789" token_chars = "abcdefghjkmnpqrstuvwxyz123456789"
storage: IRemoteStorage
constructor(ui: MainUI) { constructor(device_id: string, storage: IRemoteStorage) {
this.ui = ui; 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(<any>this.token_chars)).join("");
} }
/** /**
* Find an unused session token * Find an unused session token
*/ */
getUnusedToken(length = 5): string { async getUnusedToken(length = 5): Promise<string> {
let token = range(length).map(() => RandomGenerator.global.choice(<any>this.token_chars)).join(""); let token = this.generateToken(length);
// TODO check if it is unused on server let existing = await this.storage.search("sessioninfo", { token: token });
if (existing.length > 0) {
token = await this.getUnusedToken(length + 1);
}
return token; 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 { async publish(session: GameSession, description: string): Promise<string> {
let session = new this.model_session(); await this.storage.upsert("session", { ref: session.id }, { data: this.serializer.serialize(session) });
let token = this.getUnusedToken();
session.set("token", token); let now = new Date();
session.set("data", this.serializer.serialize(this.ui.session)); 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; return token;
} }
@ -44,21 +55,35 @@ module TS.SpaceTac.Multi {
/** /**
* Load a session from a remote server, by its token * Load a session from a remote server, by its token
*/ */
load(token: string): void { async loadByToken(token: string): Promise<GameSession | null> {
let query = new Parse.Query(this.model_session); let info = await this.storage.find("sessioninfo", { token: token });
query.equalTo("token", token); if (info) {
query.find({ return this.loadById(info.ref);
success: (results: any) => { } else {
if (results.length == 1) { return null;
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'); * Load a session from a remote server, by its id
} */
} async loadById(id: string): Promise<GameSession | null> {
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]));
} }
} }
} }

View file

@ -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" }]);
});
});
}

125
src/multi/RemoteStorage.ts Normal file
View file

@ -0,0 +1,125 @@
/// <reference path="Parse.d.ts" />
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<any[]>
/**
* Find a single object with equality of some fields
*/
find(collection: string, fields: any): Promise<any>
/**
* Insert or update an object in a collection, based on some unicity fields
*/
upsert(collection: string, unicity: any, additional: any): Promise<void>
}
/**
* 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);
}
}
}
}

View file

@ -88,5 +88,32 @@ module TS.SpaceTac.UI {
let layer = this.add.group(this.layers); let layer = this.add.group(this.layers);
return layer; 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"));
}
} }
} }

View file

@ -12,6 +12,7 @@ module TS.SpaceTac.UI.Specs {
baseview: BaseView; baseview: BaseView;
battleview: BattleView; battleview: BattleView;
mapview: UniverseMapView; mapview: UniverseMapView;
multistorage: Multi.FakeRemoteStorage;
} }
/** /**
@ -35,6 +36,12 @@ module TS.SpaceTac.UI.Specs {
let [state, stateargs] = buildView(testgame); 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"); let orig_create = bound(state, "create");
spyOn(state, "create").and.callFake(() => { spyOn(state, "create").and.callFake(() => {
orig_create(); orig_create();

View file

@ -77,7 +77,7 @@ module TS.SpaceTac.UI {
}; };
// Events // Events
Tools.setHoverClick(this, show_info, hide_info, () => this.processClick()); UITools.setHoverClick(this, show_info, hide_info, () => this.processClick());
// Initialize // Initialize
this.updateActiveStatus(true); this.updateActiveStatus(true);

View file

@ -62,7 +62,7 @@ module TS.SpaceTac.UI {
this.addChild(this.effects); this.addChild(this.effects);
// Handle input on ship sprite // Handle input on ship sprite
Tools.setHoverClick(this.sprite, UITools.setHoverClick(this.sprite,
() => this.battleview.cursorOnShip(ship), () => this.battleview.cursorOnShip(ship),
() => this.battleview.cursorOffShip(ship), () => this.battleview.cursorOffShip(ship),
() => this.battleview.cursorClicked() () => this.battleview.cursorClicked()

View file

@ -66,7 +66,7 @@ module TS.SpaceTac.UI {
level.anchor.set(0.5, 0.5); level.anchor.set(0.5, 0.5);
this.addChild(level); 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 // Update attributes from associated ship

View file

@ -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 { 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) // 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; tween.target[property] = value;
// Compute destination angle // Compute destination angle
dest = Tools.normalizeAngle(dest); dest = UITools.normalizeAngle(dest);
if (value - dest > Math.PI) { if (value - dest > Math.PI) {
dest += 2 * Math.PI; dest += 2 * Math.PI;
} else if (value - dest < -Math.PI) { } else if (value - dest < -Math.PI) {
dest -= 2 * 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; let duration = distance * 1000 / speed;
// Update the tween // Update the tween

View file

@ -37,7 +37,7 @@ module TS.SpaceTac.UI {
this.background.endFill(); 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) { if (x != this.x || y != this.y) {
this.position.set(x, 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 * 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 { bind(obj: Phaser.Button, func: (container: Phaser.Group) => boolean): void {
Tools.setHoverClick(obj, UITools.setHoverClick(obj,
// enter // enter
() => { () => {
this.hide(); this.hide();

25
src/ui/common/UILabel.ts Normal file
View file

@ -0,0 +1,25 @@
/// <reference path="UIComponent.ts" />
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;
}
}
}

View file

@ -8,7 +8,7 @@ module TS.SpaceTac.UI {
private content: Phaser.Text private content: Phaser.Text
private maxlength: number 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); super(parent, width, height);
let input_bg = new Phaser.Image(this.game, 0, 0, "common-transparent"); let input_bg = new Phaser.Image(this.game, 0, 0, "common-transparent");
@ -19,7 +19,7 @@ module TS.SpaceTac.UI {
this.addInternalChild(input_bg); this.addInternalChild(input_bg);
let fontsize = Math.ceil(height * 0.8); 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.content.anchor.set(0.5, 0.5);
this.addInternalChild(this.content); this.addInternalChild(this.content);

View file

@ -1,5 +1,5 @@
module TS.SpaceTac.UI.Specs { module TS.SpaceTac.UI.Specs {
describe("Tools", function () { describe("UITools", function () {
let testgame = setupEmptyView(); let testgame = setupEmptyView();
it("keeps objects inside bounds", function () { it("keeps objects inside bounds", function () {
@ -8,19 +8,19 @@ module TS.SpaceTac.UI.Specs {
image.drawEllipse(50, 25, 50, 25); image.drawEllipse(50, 25, 50, 25);
image.endFill(); 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.x).toBe(100);
expect(image.y).toBe(100); expect(image.y).toBe(100);
}); });
it("normalizes angles", function () { it("normalizes angles", function () {
expect(Tools.normalizeAngle(0)).toEqual(0); expect(UITools.normalizeAngle(0)).toEqual(0);
expect(Tools.normalizeAngle(0.1)).toBeCloseTo(0.1, 0.000001); expect(UITools.normalizeAngle(0.1)).toBeCloseTo(0.1, 0.000001);
expect(Tools.normalizeAngle(Math.PI)).toBeCloseTo(Math.PI, 0.000001); expect(UITools.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(Math.PI + 0.5)).toBeCloseTo(-Math.PI + 0.5, 0.000001);
expect(Tools.normalizeAngle(-Math.PI)).toBeCloseTo(Math.PI, 0.000001); expect(UITools.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(-Math.PI - 0.5)).toBeCloseTo(Math.PI - 0.5, 0.000001);
}); });
it("handles hover and click on desktops and mobile targets", function (done) { 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, "enter");
spyOn(funcs, "leave"); spyOn(funcs, "leave");
spyOn(funcs, "click"); 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]; return [button, funcs];
} }

View file

@ -7,7 +7,7 @@ module TS.SpaceTac.UI {
} }
// Common UI tools functions // Common UI tools functions
export class Tools { export class UITools {
/** /**
* Get the position of an object, adjusted to remain inside a container * 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) { static keepInside(obj: Phaser.Button | Phaser.Sprite | Phaser.Image | Phaser.Group | Phaser.Graphics, rect: IBounded) {
let objbounds = obj.getBounds(); 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) { if (x != obj.x || y != obj.y) {
obj.position.set(x, y); obj.position.set(x, y);

View file

@ -103,6 +103,9 @@ module TS.SpaceTac.UI {
this.inputs.bindCheat("r", "Reveal whole map", this.revealAll); this.inputs.bindCheat("r", "Reveal whole map", this.revealAll);
this.setZoom(2); this.setZoom(2);
// Trigger an auto-save any time we go back to the map
this.autoSave();
} }
/** /**

View file

@ -5,16 +5,91 @@ module TS.SpaceTac.UI {
* Dialog to load a saved game, or join an online one * Dialog to load a saved game, or join an online one
*/ */
export class LoadDialog extends UIComponent { export class LoadDialog extends UIComponent {
saves: [string, string][] = []
save_selected = 0
save_name: UILabel
token_input: UITextInput
constructor(parent: MainMenu) { constructor(parent: MainMenu) {
super(parent, 1344, 566, "menu-load-bg"); super(parent, 1344, 566, "menu-load-bg");
this.addButton(600, 115, () => null, "common-arrow", "common-arrow", 180); this.addButton(600, 115, () => this.paginateSave(-1), "common-arrow", "common-arrow", 180);
this.addButton(1038, 115, () => null, "common-arrow", "common-arrow", 0); this.addButton(1038, 115, () => this.paginateSave(1), "common-arrow", "common-arrow", 0);
this.addButton(1224, 115, () => null, "common-button-cancel"); this.addButton(1224, 115, () => this.load(), "common-button-ok");
this.addButton(1224, 341, () => null, "common-button-cancel"); this.addButton(1224, 341, () => this.join(), "common-button-ok");
let input = new UITextInput(this, 468, 68, 10); this.save_name = new UILabel(this, 351, 185, "", 32, "#000000");
input.setPosition(585, 304); 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);
}
});
}
} }
} }
} }

View file

@ -9,6 +9,11 @@
"out": "out/build.js", "out": "out/build.js",
"strict": true, "strict": true,
"sourceMap": true, "sourceMap": true,
"lib": [
"dom",
"es2015.promise",
"es5"
],
"target": "es5" "target": "es5"
}, },
"include": [ "include": [