Added auto-saving to cloud, and loading cloud saves
This commit is contained in:
parent
ed86f57529
commit
27302267b9
3
TODO
3
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
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
@ -7,22 +7,23 @@
|
|||
|
||||
<link rel="stylesheet" href="vendor/jasmine-core/lib/jasmine-core/jasmine.css">
|
||||
<style>
|
||||
canvas {
|
||||
display: none;
|
||||
}
|
||||
canvas {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div style="display: none; visibility: hidden; height: 0; overflow: hidden;">
|
||||
<div id="-space-tac" class="game"></div>
|
||||
</div>
|
||||
<div id="-space-tac" class="game"></div>
|
||||
</div>
|
||||
|
||||
<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/boot.js"></script>
|
||||
<script src="vendor/phaser/build/phaser.min.js"></script>
|
||||
<script src="build.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/boot.js"></script>
|
||||
<script src="vendor/parse/parse.min.js"></script>
|
||||
<script src="vendor/phaser/build/phaser.min.js"></script>
|
||||
<script src="build.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
]
|
||||
})
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 28bf87b126d13f3144955cba4c07bbe03c65e224
|
||||
Subproject commit bc39ed78ef474e42cb20b3dbfc3dffa80c07ffe7
|
|
@ -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);
|
||||
|
|
86
src/multi/Connection.spec.ts
Normal file
86
src/multi/Connection.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -1,42 +1,53 @@
|
|||
/// <reference path="Parse.d.ts" />
|
||||
|
||||
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(<any>this.token_chars)).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an unused session token
|
||||
*/
|
||||
getUnusedToken(length = 5): string {
|
||||
let token = range(length).map(() => RandomGenerator.global.choice(<any>this.token_chars)).join("");
|
||||
// TODO check if it is unused on server
|
||||
async getUnusedToken(length = 5): Promise<string> {
|
||||
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<string> {
|
||||
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<GameSession | null> {
|
||||
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<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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
51
src/multi/RemoteStorage.spec.ts
Normal file
51
src/multi/RemoteStorage.spec.ts
Normal 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
125
src/multi/RemoteStorage.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
|
25
src/ui/common/UILabel.ts
Normal file
25
src/ui/common/UILabel.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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);
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,11 @@
|
|||
"out": "out/build.js",
|
||||
"strict": true,
|
||||
"sourceMap": true,
|
||||
"lib": [
|
||||
"dom",
|
||||
"es2015.promise",
|
||||
"es5"
|
||||
],
|
||||
"target": "es5"
|
||||
},
|
||||
"include": [
|
||||
|
|
Loading…
Reference in a new issue