Add ability to import savegames, add game menu, multiple smaller improvements

This commit is contained in:
tobspr 2020-05-16 17:57:25 +02:00
parent c1d720ca52
commit 224bc6c7e5
31 changed files with 1422 additions and 47 deletions

BIN
res/ui/demo_badge.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
res/ui/get_on_steam.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
res/ui/icons/delete.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 B

View File

@ -22,9 +22,15 @@
opacity: 0;
}
&.loadingDialog {
* {
color: #fff;
}
}
> .dialogInner {
background: #fff;
@include S(min-width, 500px);
@include S(min-width, 300px);
max-width: calc(100vw - #{D(40px)});
max-height: calc(100vh - #{D(40px)});
@include S(border-radius, 4px);
@ -60,5 +66,27 @@
overflow-y: auto;
pointer-events: all;
}
> .buttons {
@include S(margin-top, 15px);
display: flex;
justify-content: flex-end;
> button {
@include S(margin-left, 8px);
@include Text;
@include S(min-width, 60px);
@include S(padding, 5px, 15px);
&.good {
background-color: $colorGreenBright;
color: #fff;
}
&.bad {
background-color: $colorRedBright;
color: #fff;
}
}
}
}
}

View File

@ -24,6 +24,8 @@
transition-property: opacity, transform;
opacity: 0.9;
@include S(margin-left, 5px);
position: relative;
@include IncreasedClickArea(0px);
&:hover {
opacity: 0.8;
@ -80,7 +82,7 @@
border-radius: 0 0 #{D(4px)} #{D(4px)};
@include S(padding-left, 30px);
@include S(margin-right, 3px);
@include IncreasedClickArea(10px);
@include IncreasedClickArea(0px);
@include ButtonText;
@include S(min-height, 30px);
transition: all 0.12s ease-in-out;

View File

@ -21,8 +21,8 @@
}
transform-origin: 100% 50%;
@include InlineAnimation(5s ease-in-out) {
opacity: 0;
@include InlineAnimation(3s ease-in-out) {
0% {
opacity: 1;
}

View File

@ -0,0 +1,32 @@
#ingame_HUD_SettingsMenu {
.timePlayed {
position: absolute;
@include S(left, 30px);
@include S(bottom, 30px);
color: #fff;
display: flex;
flex-direction: column;
strong {
text-transform: uppercase;
@include PlainText;
}
span {
@include Heading;
}
}
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.buttons {
display: grid;
grid-auto-flow: row;
@include S(grid-gap, 10px);
background: rgba(0, 10, 20, 0.1);
@include S(padding, 20px);
@include S(border-radius, 2px);
}
}

View File

@ -3,6 +3,8 @@
@include S(padding-right, 10px);
display: flex;
flex-direction: column;
@include S(width, 500px);
.upgrade {
display: grid;
grid-template-columns: auto 1fr auto;
@ -99,6 +101,7 @@
display: flex;
flex-direction: column;
align-items: center;
@include S(width, 65px);
button.pin {
@include S(width, 12px);

View File

@ -1,4 +1,8 @@
#ingame_HUD_Statistics {
.content {
@include S(width, 500px);
}
.filterHeader {
display: grid;
grid-template-columns: auto 1fr;

View File

@ -37,12 +37,13 @@
@import "ingame_hud/statistics";
@import "ingame_hud/pinned_shapes";
@import "ingame_hud/notifications";
@import "ingame_hud/settings_menu";
// Z-Index
$elements: ingame_Canvas, ingame_VignetteOverlay, ingame_HUD_building_placer, ingame_HUD_PinnedShapes,
ingame_HUD_buildings_toolbar, ingame_HUD_GameMenu, ingame_HUD_KeybindingOverlay, ingame_HUD_Notifications,
ingame_HUD_Shop, ingame_HUD_Statistics, ingame_HUD_BetaOverlay, ingame_HUD_MassSelector,
ingame_HUD_UnlockNotification;
ingame_HUD_UnlockNotification, ingame_HUD_SettingsMenu;
$zindex: 100;
@ -59,16 +60,15 @@ body.uiHidden {
#ingame_HUD_building_placer,
#ingame_HUD_GameMenu,
#ingame_HUD_MassSelector,
#ingame_HUD_PinnedShapes {
#ingame_HUD_PinnedShapes,
#ingame_HUD_Notifications {
display: none !important;
}
}
body.modalDialogActive,
body.ingameDialogOpen {
#ingame_Canvas,
#ingame_HUD_GameMenu,
#ingame_HUD_KeybindingOverlay,
#ingame_HUD_buildings_toolbar,
#ingame_HUD_PinnedShapes {
filter: blur(5px);
> *:not(.ingameDialog):not(.modalDialogParent) {
filter: blur(5px) !important;
}
}

View File

@ -5,7 +5,6 @@
flex-direction: column;
background: rgb(140, 165, 194) center center / cover !important;
// background: $colorGreenBright !important;
.fullscreenBackgroundVideo {
z-index: -1;
@ -36,14 +35,88 @@
}
}
.mainWrapper {
display: grid;
grid-template-columns: 1fr auto 1fr;
@include S(padding, 0, 10px);
align-items: center;
justify-items: center;
@include S(grid-column-gap, 10px);
.standaloneBanner {
background: rgb(255, 225, 238);
@include S(border-radius, 4px);
height: 100%;
box-sizing: border-box;
@include S(padding, 15px);
display: flex;
flex-direction: column;
strong {
font-weight: bold;
}
h3 {
@include Heading;
font-weight: bold;
@include S(margin-bottom, 15px);
text-transform: uppercase;
color: $colorRedBright;
}
p {
@include Text;
}
ul {
@include S(margin-top, 15px);
@include S(padding-left, 20px);
li {
@include Text;
}
}
.steamLink {
width: 100%;
@include S(height, 50px);
background: uiResource("get_on_steam.png") center center / contain no-repeat;
overflow: hidden;
display: block;
text-indent: -999em;
cursor: pointer;
@include S(margin-top, 20px);
pointer-events: all;
transition: all 0.12s ease-in;
transition-property: opacity, transform;
transform: skewX(-0.5deg);
&:hover {
transform: skewX(-1deg) scale(1.02);
opacity: 0.9;
}
}
}
}
.logo {
display: flex;
flex-grow: 1;
align-items: center;
justify-content: center;
flex-direction: column;
@include S(padding-top, 20px);
img {
@include S(width, 350px);
}
.demoBadge {
@include S(margin, 10px, 0);
@include S(width, 100px);
@include S(height, 30px);
background: uiResource("demo_badge.png") center center / contain no-repeat;
display: inline-block;
}
}
.betaWarning {
@ -53,12 +126,11 @@
@include S(padding, 10px);
@include S(border-radius, 4px);
color: #fff;
@include S(margin-bottom, 10px);
@include S(margin-top, 10px);
border: #{D(2px)} solid rgba(0, 10, 20, 0.1);
}
.mainContainer {
@include S(margin-top, 10px);
display: flex;
align-items: center;
justify-content: flex-start;
@ -67,6 +139,8 @@
@include S(padding, 20px);
@include S(border-radius, 4px);
// border: #{D(2px)} solid rgba(0, 10, 20, 0.1);
height: 100%;
box-sizing: border-box;
.playButton {
@include SuperHeading;
@ -82,8 +156,12 @@
}
}
.importButton {
@include S(margin-top, 15px);
}
.savegames {
@include S(max-height, 92px);
@include S(max-height, 105px);
overflow-y: auto;
@include S(width, 250px);
pointer-events: all;
@ -101,6 +179,7 @@
grid-template-columns: 1fr auto auto;
grid-template-rows: auto auto;
@include S(grid-column-gap, 5px);
@include S(grid-row-gap, 3px);
.internalId {
grid-column: 1 / 2;
@ -116,7 +195,8 @@
}
button.resumeGame,
button.downloadGame {
button.downloadGame,
button.deleteGame {
grid-column: 3 / 4;
grid-row: 1 / 3;
@include S(width, 30px);
@ -128,8 +208,22 @@
button.downloadGame {
grid-column: 2 / 3;
grid-row: 1 / 2;
background-image: uiResource("icons/download.png");
@include S(width, 15px);
@include IncreasedClickArea(0px);
@include S(height, 15px);
align-self: end;
background-size: 60%;
}
button.deleteGame {
grid-column: 2 / 3;
grid-row: 2 / 3;
background-color: $colorRedBright;
@include IncreasedClickArea(0px);
background-image: uiResource("icons/delete.png");
@include S(width, 15px);
@include S(height, 15px);
align-self: end;
background-size: 60%;

View File

@ -30,6 +30,7 @@ import { GameAnalyticsInterface } from "./platform/game_analytics";
import { ShapezGameAnalytics } from "./platform/browser/game_analytics";
import { queryParamOptions } from "./core/query_parameters";
import { NoGameAnalytics } from "./platform/browser/no_game_analytics";
import { StorageImplBrowserIndexedDB } from "./platform/browser/storage_indexed_db";
const logger = createLogger("application");
@ -119,7 +120,12 @@ export class Application {
// Start with empty ad provider
this.adProvider = new NoAdProvider(this);
this.storage = new StorageImplBrowser(this);
if (window.indexedDB) {
this.storage = new StorageImplBrowserIndexedDB(this);
} else {
this.storage = new StorageImplBrowser(this);
}
this.sound = new SoundImplBrowser(this);
this.platformWrapper = new PlatformWrapperImplBrowser(this);
this.analytics = new GoogleAnalyticsImpl(this);

View File

@ -13,7 +13,7 @@ import { round1Digit } from "./utils";
const logger = createLogger("buffers");
const bufferGcDurationSeconds = 3;
const bufferGcDurationSeconds = 10;
export class BufferMaintainer {
/**

View File

@ -32,7 +32,7 @@ export const globalConfig = {
// Map
mapChunkSize: 32,
mapChunkPrerenderMinZoom: 1.5,
mapChunkPrerenderMinZoom: 1.3,
mapChunkOverviewMinZoom: 0.7,
// Belt speeds

View File

@ -0,0 +1,430 @@
/* typehints:start */
import { Application } from "../application";
/* typehints:end */
import { Signal, STOP_PROPAGATION } from "./signal";
import { arrayDeleteValue, waitNextFrame } from "./utils";
import { ClickDetector } from "./click_detector";
import { SOUNDS } from "../platform/sound";
import { InputReceiver } from "./input_receiver";
import { FormElement } from "./modal_dialog_forms";
import { globalConfig } from "./config";
import { getStringForKeyCode } from "../game/key_action_mapper";
import { createLogger } from "./logging";
const kbEnter = 13;
const kbCancel = 27;
const logger = createLogger("dialogs");
/**
* Basic text based dialog
*/
export class Dialog {
/**
*
* Constructs a new dialog with the given options
* @param {object} param0
* @param {Application} param0.app
* @param {string} param0.title Title of the dialog
* @param {string} param0.contentHTML Inner dialog html
* @param {Array<string>} param0.buttons
* Button list, each button contains of up to 3 parts seperated by ':'.
* Part 0: The id, one of the one defined in dialog_buttons.yaml
* Part 1: The style, either good, bad or misc
* Part 2 (optional): Additional parameters seperated by '/', available are:
* timeout: This button is only available after some waiting time
* kb_enter: This button is triggered by the enter key
* kb_escape This button is triggered by the escape key
* @param {string=} param0.type The dialog type, either "info" or "warn"
* @param {boolean=} param0.closeButton Whether this dialog has a close button
*/
constructor({ app, title, contentHTML, buttons, type = "info", closeButton = false }) {
this.app = app;
this.title = title;
this.contentHTML = contentHTML;
this.type = type;
this.buttonIds = buttons;
this.closeButton = closeButton;
this.closeRequested = new Signal();
this.buttonSignals = {};
for (let i = 0; i < buttons.length; ++i) {
if (G_IS_DEV && globalConfig.debug.disableTimedButtons) {
this.buttonIds[i] = this.buttonIds[i].replace(":timeout", "");
}
const buttonId = this.buttonIds[i].split(":")[0];
this.buttonSignals[buttonId] = new Signal();
}
this.timeouts = [];
this.clickDetectors = [];
this.inputReciever = new InputReceiver("dialog-" + this.title);
this.inputReciever.keydown.add(this.handleKeydown, this);
this.enterHandler = null;
this.escapeHandler = null;
}
/**
* Internal keydown handler
* @param {object} param0
* @param {number} param0.keyCode
* @param {boolean} param0.shift
* @param {boolean} param0.alt
*/
handleKeydown({ keyCode, shift, alt }) {
if (keyCode === kbEnter && this.enterHandler) {
this.internalButtonHandler(this.enterHandler);
return STOP_PROPAGATION;
}
if (keyCode === kbCancel && this.escapeHandler) {
this.internalButtonHandler(this.escapeHandler);
return STOP_PROPAGATION;
}
}
internalButtonHandler(id, ...payload) {
this.app.inputMgr.popReciever(this.inputReciever);
if (id !== "close-button") {
this.buttonSignals[id].dispatch(...payload);
}
this.closeRequested.dispatch();
}
createElement() {
const elem = document.createElement("div");
elem.classList.add("ingameDialog");
this.dialogElem = document.createElement("div");
this.dialogElem.classList.add("dialogInner");
if (this.type) {
this.dialogElem.classList.add(this.type);
}
elem.appendChild(this.dialogElem);
const title = document.createElement("h1");
title.innerText = this.title;
title.classList.add("title");
this.dialogElem.appendChild(title);
if (this.closeButton) {
this.dialogElem.classList.add("hasCloseButton");
const closeBtn = document.createElement("button");
closeBtn.classList.add("closeButton");
this.trackClicks(closeBtn, () => this.internalButtonHandler("close-button"), {
applyCssClass: "pressedSmallElement",
});
title.appendChild(closeBtn);
this.inputReciever.backButton.add(() => this.internalButtonHandler("close-button"));
}
const content = document.createElement("div");
content.classList.add("content");
content.innerHTML = this.contentHTML;
this.dialogElem.appendChild(content);
if (this.buttonIds.length > 0) {
const buttons = document.createElement("div");
buttons.classList.add("buttons");
// Create buttons
for (let i = 0; i < this.buttonIds.length; ++i) {
const [buttonId, buttonStyle, rawParams] = this.buttonIds[i].split(":");
const button = document.createElement("button");
button.classList.add("button");
button.classList.add("styledButton");
button.classList.add(buttonStyle);
// button.innerText = T.dialog_buttons[buttonId];
button.innerText = buttonId;
const params = (rawParams || "").split("/");
const useTimeout = params.indexOf("timeout") >= 0;
const isEnter = params.indexOf("enter") >= 0;
const isEscape = params.indexOf("escape") >= 0;
if (isEscape && this.closeButton) {
logger.warn("Showing dialog with close button, and additional cancel button");
}
if (useTimeout) {
button.classList.add("timedButton");
const timeout = setTimeout(() => {
button.classList.remove("timedButton");
arrayDeleteValue(this.timeouts, timeout);
}, 5000);
this.timeouts.push(timeout);
}
if (isEnter || isEscape) {
// if (this.app.settings.getShowKeyboardShortcuts()) {
// Show keybinding
const spacer = document.createElement("code");
spacer.classList.add("keybinding");
spacer.innerHTML = getStringForKeyCode(isEnter ? kbEnter : kbCancel);
button.appendChild(spacer);
// }
if (isEnter) {
this.enterHandler = buttonId;
}
if (isEscape) {
this.escapeHandler = buttonId;
}
}
this.trackClicks(button, () => this.internalButtonHandler(buttonId));
buttons.appendChild(button);
}
this.dialogElem.appendChild(buttons);
} else {
this.dialogElem.classList.add("buttonless");
}
this.element = elem;
this.app.inputMgr.pushReciever(this.inputReciever);
return this.element;
}
setIndex(index) {
this.element.style.zIndex = index;
}
destroy() {
if (!this.element) {
assert(false, "Tried to destroy dialog twice");
return;
}
// We need to do this here, because if the backbutton event gets
// dispatched to the modal dialogs, it will not call the internalButtonHandler,
// and thus our receiver stays attached the whole time
this.app.inputMgr.destroyReceiver(this.inputReciever);
for (let i = 0; i < this.clickDetectors.length; ++i) {
this.clickDetectors[i].cleanup();
}
this.clickDetectors = [];
this.element.remove();
this.element = null;
for (let i = 0; i < this.timeouts.length; ++i) {
clearTimeout(this.timeouts[i]);
}
this.timeouts = [];
}
hide() {
this.element.classList.remove("visible");
}
show() {
this.element.classList.add("visible");
}
/**
* Helper method to track clicks on an element
* @param {Element} elem
* @param {function():void} handler
* @param {import("./click_detector").ClickDetectorConstructorArgs=} args
* @returns {ClickDetector}
*/
trackClicks(elem, handler, args = {}) {
const detector = new ClickDetector(elem, args);
detector.click.add(handler, this);
this.clickDetectors.push(detector);
return detector;
}
}
/**
* Dialog which simply shows a loading spinner
*/
export class DialogLoading extends Dialog {
constructor(app) {
super({
app,
title: "",
contentHTML: "",
buttons: [],
type: "loading",
});
// Loading dialog can not get closed with back button
this.inputReciever.backButton.removeAll();
this.inputReciever.context = "dialog-loading";
}
createElement() {
const elem = document.createElement("div");
elem.classList.add("ingameDialog");
elem.classList.add("loadingDialog");
this.element = elem;
const loader = document.createElement("div");
loader.classList.add("prefab_LoadingTextWithAnim");
loader.classList.add("loadingIndicator");
loader.innerText = "Loading";
elem.appendChild(loader);
this.app.inputMgr.pushReciever(this.inputReciever);
return elem;
}
}
export class DialogOptionChooser extends Dialog {
constructor({ app, title, options }) {
let html = "<div class='optionParent'>";
options.options.forEach(({ value, text, desc = null, iconPrefix = null }) => {
const descHtml = desc ? `<span class="desc">${desc}</span>` : "";
let iconHtml = iconPrefix ? `<span class="icon icon-${iconPrefix}-${value}"></span>` : "";
html += `
<div class='option ${value === options.active ? "active" : ""} ${
iconPrefix ? "hasIcon" : ""
}' data-optionvalue='${value}'>
${iconHtml}
<span class='title'>${text}</span>
${descHtml}
</div>
`;
});
html += "</div>";
super({
app,
title,
contentHTML: html,
buttons: [],
type: "info",
closeButton: true,
});
this.options = options;
this.initialOption = options.active;
this.buttonSignals.optionSelected = new Signal();
}
createElement() {
const div = super.createElement();
this.dialogElem.classList.add("optionChooserDialog");
div.querySelectorAll("[data-optionvalue]").forEach(handle => {
const value = handle.getAttribute("data-optionvalue");
if (!handle) {
logger.error("Failed to bind option value in dialog:", value);
return;
}
// Need click detector here to forward elements, otherwise scrolling does not work
const detector = new ClickDetector(handle, {
consumeEvents: false,
preventDefault: false,
clickSound: null,
applyCssClass: "pressedOption",
targetOnly: true,
});
this.clickDetectors.push(detector);
if (value !== this.initialOption) {
detector.click.add(() => {
const selected = div.querySelector(".option.active");
if (selected) {
selected.classList.remove("active");
} else {
logger.warn("No selected option");
}
handle.classList.add("active");
this.app.sound.playUiSound(SOUNDS.uiClick);
this.internalButtonHandler("optionSelected", value);
});
}
});
return div;
}
}
export class DialogWithForm extends Dialog {
/**
*
* @param {object} param0
* @param {Application} param0.app
* @param {string} param0.title
* @param {string} param0.desc
* @param {string=} param0.confirmButton
* @param {Array<FormElement>} param0.formElements
*/
constructor({ app, title, desc, formElements, confirmButton = "ok:good" }) {
let html = "";
html += desc + "<br>";
for (let i = 0; i < formElements.length; ++i) {
html += formElements[i].getHtml();
}
super({
app,
title: title,
contentHTML: html,
buttons: ["cancel:bad", confirmButton],
type: "info",
closeButton: true,
});
this.confirmButtonId = confirmButton.split(":")[0];
this.formElements = formElements;
}
internalButtonHandler(id, ...payload) {
if (id === this.confirmButtonId) {
if (this.hasAnyInvalid()) {
this.dialogElem.classList.remove("errorShake");
waitNextFrame().then(() => {
if (this.dialogElem) {
this.dialogElem.classList.add("errorShake");
}
});
this.app.sound.playUiSound(SOUNDS.uiError);
return;
}
}
super.internalButtonHandler(id, payload);
}
hasAnyInvalid() {
for (let i = 0; i < this.formElements.length; ++i) {
if (!this.formElements[i].isValid()) {
return true;
}
}
return false;
}
createElement() {
const div = super.createElement();
for (let i = 0; i < this.formElements.length; ++i) {
const elem = this.formElements[i];
elem.bindEvents(div, this.clickDetectors);
}
waitNextFrame().then(() => {
this.formElements[0].focus();
});
return div;
}
}

View File

@ -0,0 +1,150 @@
import { ClickDetector } from "./click_detector";
export class FormElement {
constructor(id, label) {
this.id = id;
this.label = label;
}
getHtml() {
abstract;
return "";
}
getFormElement(parent) {
return parent.querySelector("[data-formId='" + this.id + "']");
}
bindEvents(parent, clickTrackers) {
abstract;
}
focus(parent) {}
isValid() {
return true;
}
/** @returns {any} */
getValue() {
abstract;
}
}
export class FormElementInput extends FormElement {
constructor({ id, label = null, placeholder, defaultValue = "", inputType = "text", validator = null }) {
super(id, label);
this.placeholder = placeholder;
this.defaultValue = defaultValue;
this.inputType = inputType;
this.validator = validator;
this.element = null;
}
getHtml() {
let classes = [];
let inputType = "text";
let maxlength = 256;
switch (this.inputType) {
case "text": {
classes.push("input-text");
break;
}
case "email": {
classes.push("input-email");
inputType = "email";
break;
}
case "token": {
classes.push("input-token");
inputType = "text";
maxlength = 4;
break;
}
}
return `
<div class="formElement input">
${this.label ? `<label>${this.label}</label>` : ""}
<input
type="${inputType}"
value="${this.defaultValue.replace(/["\\]+/gi, "")}"
maxlength="${maxlength}"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
class="${classes.join(" ")}"
placeholder="${this.placeholder.replace(/["\\]+/gi, "")}"
data-formId="${this.id}">
</div>
`;
}
bindEvents(parent, clickTrackers) {
this.element = this.getFormElement(parent);
this.element.addEventListener("input", event => this.updateErrorState());
this.updateErrorState();
}
updateErrorState() {
this.element.classList.toggle("errored", !this.isValid());
}
isValid() {
return !this.validator || this.validator(this.element.value);
}
getValue() {
return this.element.value;
}
focus() {
this.element.focus();
}
}
export class FormElementCheckbox extends FormElement {
constructor({ id, label, defaultValue = true }) {
super(id, label);
this.defaultValue = defaultValue;
this.value = this.defaultValue;
this.element = null;
}
getHtml() {
return `
<div class="formElement checkBoxFormElem">
${this.label ? `<label>${this.label}</label>` : ""}
<div class="checkbox ${this.defaultValue ? "checked" : ""}" data-formId='${this.id}'>
<span class="knob"></span >
</div >
</div>
`;
}
bindEvents(parent, clickTrackers) {
this.element = this.getFormElement(parent);
const detector = new ClickDetector(this.element, {
consumeEvents: false,
preventDefault: false,
});
clickTrackers.push(detector);
detector.click.add(this.toggle, this);
}
getValue() {
return this.value;
}
toggle() {
this.value = !this.value;
this.element.classList.toggle("checked", this.value);
}
focus(parent) {}
}

View File

@ -89,6 +89,35 @@ export class ReadWriteProxy {
return compressionPrefix + compressX64(checksum + jsonString);
}
/**
*
* @param {object} text
*/
static deserializeObject(text) {
const decompressed = decompressX64(text.substr(compressionPrefix.length));
if (!decompressed) {
// LZ string decompression failure
throw new Error("bad-content / decompression-failed");
}
if (decompressed.length < 40) {
// String too short
throw new Error("bad-content / payload-too-small");
}
// Compare stored checksum with actual checksum
const checksum = decompressed.substring(0, 40);
const jsonString = decompressed.substr(40);
const desiredChecksum = sha1(jsonString + salt);
if (desiredChecksum !== checksum) {
// Checksum mismatch
throw new Error("bad-content / checksum-mismatch");
}
const parsed = JSON.parse(jsonString);
const decoded = decompressObject(parsed);
return decoded;
}
/**
* Writes the data asychronously, fails if verify() fails
* @returns {Promise<string>}

View File

@ -668,9 +668,11 @@ export function makeButton(parent, classes = [], innerHTML = "") {
* @param {Element} elem
*/
export function removeAllChildren(elem) {
var range = document.createRange();
range.selectNodeContents(elem);
range.deleteContents();
if (elem) {
var range = document.createRange();
range.selectNodeContents(elem);
range.deleteContents();
}
}
export function smartFadeNumber(current, newOne, minFade = 0.01, maxFade = 0.9) {

View File

@ -57,7 +57,6 @@ export class UndergroundBeltComponent extends Component {
return false;
}
console.log("Takes", 1 / beltSpeed);
this.pendingItems.push([item, 1 / beltSpeed]);
return true;
}
@ -85,7 +84,6 @@ export class UndergroundBeltComponent extends Component {
// This corresponds to the item ejector - it needs 0.5 additional tiles to eject the item.
// So instead of adding 1 we add 0.5 only.
const travelDuration = (travelDistance + 0.5) / beltSpeed;
console.log(travelDistance, "->", travelDuration);
this.pendingItems.push([item, travelDuration]);

View File

@ -20,6 +20,7 @@ import { MetaBuilding } from "../meta_building";
import { HUDPinnedShapes } from "./parts/pinned_shapes";
import { ShapeDefinition } from "../shape_definition";
import { HUDNotifications, enumNotificationType } from "./parts/notifications";
import { HUDSettingsMenu } from "./parts/settings_menu";
export class GameHUD {
/**
@ -53,6 +54,7 @@ export class GameHUD {
pinnedShapes: new HUDPinnedShapes(this.root),
notifications: new HUDNotifications(this.root),
settingsMenu: new HUDSettingsMenu(this.root),
// betaOverlay: new HUDBetaOverlay(this.root),
};

View File

@ -71,6 +71,7 @@ export class HUDGameMenu extends BaseHUDPart {
this.trackClicks(this.musicButton, this.toggleMusic);
this.trackClicks(this.sfxButton, this.toggleSfx);
this.trackClicks(this.saveButton, this.startSave);
this.trackClicks(this.settingsButton, this.openSettings);
this.musicButton.classList.toggle("muted", this.root.app.settings.getAllSettings().musicMuted);
this.sfxButton.classList.toggle("muted", this.root.app.settings.getAllSettings().soundsMuted);
@ -117,6 +118,10 @@ export class HUDGameMenu extends BaseHUDPart {
this.root.gameState.doSave();
}
openSettings() {
this.root.hud.parts.settingsMenu.show();
}
toggleMusic() {
const newValue = !this.root.app.settings.getAllSettings().musicMuted;
this.root.app.settings.updateSetting("musicMuted", newValue);

View File

@ -61,7 +61,7 @@ export class HUDMassSelector extends BaseHUDPart {
*/
onBack() {
// Clear entities on escape
if (this.entityUidsMarkedForDeletion) {
if (this.entityUidsMarkedForDeletion.size > 0) {
this.entityUidsMarkedForDeletion = new Set();
return STOP_PROPAGATION;
}

View File

@ -0,0 +1,188 @@
/* typehints:start */
import { Application } from "../../../application";
/* typehints:end */
import { SOUNDS } from "../../../platform/sound";
import { DynamicDomAttach } from "../dynamic_dom_attach";
import { BaseHUDPart } from "../base_hud_part";
import {
Dialog,
DialogLoading,
DialogVideoTutorial,
DialogOptionChooser,
} from "../../../core/modal_dialog_elements";
import { makeDiv } from "../../../core/utils";
export class HUDModalDialogs extends BaseHUDPart {
constructor(root, app) {
// Important: Root is not always available here! Its also used in the main menu
super(root);
/** @type {Application} */
this.app = app;
this.dialogParent = null;
this.dialogStack = [];
}
// For use inside of the game, implementation of base hud part
initialize() {
this.dialogParent = document.getElementById("rg_HUD_ModalDialogs");
this.domWatcher = new DynamicDomAttach(this.root, this.dialogParent);
}
shouldPauseRendering() {
return this.dialogStack.length > 0;
}
shouldPauseGame() {
return this.shouldPauseRendering();
}
createElements(parent) {
return makeDiv(parent, "rg_HUD_ModalDialogs");
}
// For use outside of the game
initializeToElement(element) {
assert(element, "No element for dialogs given");
this.dialogParent = element;
}
// Methods
showInfo(title, text, buttons = ["ok:good"]) {
const dialog = new Dialog({
app: this.app,
title: title,
contentHTML: text,
buttons: buttons,
type: "info",
});
this.internalShowDialog(dialog);
if (this.app) {
this.app.sound.playUiSound(SOUNDS.dialogOk);
}
return dialog.buttonSignals;
}
showWarning(title, text, buttons = ["ok:good"]) {
const dialog = new Dialog({
app: this.app,
title: title,
contentHTML: text,
buttons: buttons,
type: "warning",
});
this.internalShowDialog(dialog);
if (this.app) {
this.app.sound.playUiSound(SOUNDS.dialogError);
}
return dialog.buttonSignals;
}
showVideoTutorial(title, text, videoUrl) {
const dialog = new DialogVideoTutorial({
app: this.app,
title: title,
contentHTML: text,
videoUrl,
});
this.internalShowDialog(dialog);
if (this.app) {
this.app.sound.playUiSound(SOUNDS.dialogOk);
}
return dialog.buttonSignals;
}
showOptionChooser(title, options) {
const dialog = new DialogOptionChooser({
app: this.app,
title,
options,
});
this.internalShowDialog(dialog);
return dialog.buttonSignals;
}
// Returns method to be called when laoding finishd
showLoadingDialog() {
const dialog = new DialogLoading(this.app);
this.internalShowDialog(dialog);
return this.closeDialog.bind(this, dialog);
}
internalShowDialog(dialog) {
const elem = dialog.createElement();
dialog.setIndex(this.dialogStack.length);
// Hide last dialog in queue
if (this.dialogStack.length > 0) {
this.dialogStack[this.dialogStack.length - 1].hide();
}
this.dialogStack.push(dialog);
// Append dialog
dialog.show();
dialog.closeRequested.add(this.closeDialog.bind(this, dialog));
// Append to HTML
this.dialogParent.appendChild(elem);
document.body.classList.toggle("modalDialogActive", this.dialogStack.length > 0);
// IMPORTANT: Attach element directly, otherwise double submit is possible
this.update();
}
update() {
if (this.domWatcher) {
this.domWatcher.update(this.dialogStack.length > 0);
}
}
closeDialog(dialog) {
dialog.destroy();
let index = -1;
for (let i = 0; i < this.dialogStack.length; ++i) {
if (this.dialogStack[i] === dialog) {
index = i;
break;
}
}
assert(index >= 0, "Dialog not in dialog stack");
this.dialogStack.splice(index, 1);
if (this.dialogStack.length > 0) {
// Show the dialog which was previously open
this.dialogStack[this.dialogStack.length - 1].show();
}
document.body.classList.toggle("modalDialogActive", this.dialogStack.length > 0);
}
close() {
for (let i = 0; i < this.dialogStack.length; ++i) {
const dialog = this.dialogStack[i];
dialog.destroy();
}
this.dialogStack = [];
}
cleanup() {
super.cleanup();
for (let i = 0; i < this.dialogStack.length; ++i) {
this.dialogStack[i].destroy();
}
this.dialogStack = [];
this.dialogParent = null;
}
}

View File

@ -8,6 +8,8 @@ export const enumNotificationType = {
success: "success",
};
const notificationDuration = 3;
export class HUDNotifications extends BaseHUDPart {
createElements(parent) {
this.element = makeDiv(parent, "ingame_HUD_Notifications", [], ``);
@ -35,7 +37,7 @@ export class HUDNotifications extends BaseHUDPart {
this.notificationElements.push({
element,
expireAt: this.root.time.realtimeNow() + 5,
expireAt: this.root.time.realtimeNow() + notificationDuration,
});
}

View File

@ -0,0 +1,97 @@
import { BaseHUDPart } from "../base_hud_part";
import { makeDiv } from "../../../core/utils";
import { DynamicDomAttach } from "../dynamic_dom_attach";
import { InputReceiver } from "../../../core/input_receiver";
import { KeyActionMapper } from "../../key_action_mapper";
export class HUDSettingsMenu extends BaseHUDPart {
createElements(parent) {
this.background = makeDiv(parent, "ingame_HUD_SettingsMenu", ["ingameDialog"]);
this.menuElement = makeDiv(this.background, null, ["menuElement"]);
this.timePlayed = makeDiv(
this.background,
null,
["timePlayed"],
`<strong>Playtime</strong><span class="playtime"></span>`
);
this.buttonContainer = makeDiv(this.menuElement, null, ["buttons"]);
const buttons = [
{
title: "Continue",
action: () => this.close(),
},
{
title: "Return to menu",
action: () => this.returnToMenu(),
},
];
for (let i = 0; i < buttons.length; ++i) {
const { title, action } = buttons[i];
const element = document.createElement("button");
element.classList.add("styledButton");
element.innerText = title;
this.buttonContainer.appendChild(element);
this.trackClicks(element, action);
}
}
returnToMenu() {
this.root.gameState.goBackToMenu();
}
shouldPauseGame() {
return this.visible;
}
shouldPauseRendering() {
return this.visible;
}
initialize() {
this.root.gameState.keyActionMapper.getBinding("back").add(this.show, this);
this.domAttach = new DynamicDomAttach(this.root, this.background, {
attachClass: "visible",
});
this.inputReciever = new InputReceiver("settingsmenu");
this.keyActionMapper = new KeyActionMapper(this.root, this.inputReciever);
this.keyActionMapper.getBinding("back").add(this.close, this);
this.close();
}
cleanup() {
document.body.classList.remove("ingameDialogOpen");
}
show() {
this.visible = true;
document.body.classList.add("ingameDialogOpen");
// this.background.classList.add("visible");
this.root.app.inputMgr.makeSureAttachedAndOnTop(this.inputReciever);
const totalMinutesPlayed = Math.ceil(this.root.time.now() / 60.0);
const playtimeString = totalMinutesPlayed === 1 ? "1 minute" : totalMinutesPlayed + " minutes";
this.timePlayed.querySelector(".playtime").innerText = playtimeString;
}
close() {
this.visible = false;
document.body.classList.remove("ingameDialogOpen");
this.root.app.inputMgr.makeSureDetached(this.inputReciever);
this.update();
}
update() {
this.domAttach.update(this.visible);
}
}

View File

@ -20,6 +20,7 @@ export class StorageImplBrowser extends StorageInterface {
}
initialize() {
logger.error("Using localStorage, please update to a newer browser");
return new Promise((resolve, reject) => {
// Check for local storage availability in general
if (!window.localStorage) {

View File

@ -0,0 +1,155 @@
import { FILE_NOT_FOUND, StorageInterface } from "../storage";
import { createLogger } from "../../core/logging";
const logger = createLogger("storage/browserIDB");
const LOCAL_STORAGE_UNAVAILABLE = "local-storage-unavailable";
const LOCAL_STORAGE_NO_WRITE_PERMISSION = "local-storage-no-write-permission";
let randomDelay = () => 0;
if (G_IS_DEV) {
// Random delay for testing
// randomDelay = () => 500;
}
export class StorageImplBrowserIndexedDB extends StorageInterface {
constructor(app) {
super(app);
this.currentBusyFilename = false;
/** @type {IDBDatabase} */
this.database = null;
}
initialize() {
logger.log("Using indexed DB storage");
return new Promise((resolve, reject) => {
const request = window.indexedDB.open("app_storage", 10);
request.onerror = event => {
logger.error("IDB error:", event);
reject("Indexed DB access error");
};
request.onsuccess = event => resolve(event.target.result);
request.onupgradeneeded = /** @type {IDBVersionChangeEvent} */ event => {
/** @type {IDBDatabase} */
const database = event.target.result;
const objectStore = database.createObjectStore("files", {
keyPath: "filename",
});
objectStore.createIndex("filename", "filename", { unique: true });
objectStore.transaction.onerror = event => {
logger.error("IDB transaction error:", event);
reject("Indexed DB transaction error during migration, check console output.");
};
objectStore.transaction.oncomplete = event => {
logger.log("Object store completely initialized");
resolve(database);
};
};
}).then(database => {
this.database = database;
});
}
writeFileAsync(filename, contents) {
if (this.currentBusyFilename === filename) {
logger.warn("Attempt to write", filename, "while write process is not finished!");
}
if (!this.database) {
return Promise.reject("Storage not ready");
}
this.currentBusyFilename = filename;
const transaction = this.database.transaction(["files"], "readwrite");
return new Promise((resolve, reject) => {
transaction.oncomplete = () => {
this.currentBusyFilename = null;
resolve();
};
transaction.onerror = error => {
this.currentBusyFilename = null;
logger.error("Error while writing", filename, ":", error);
reject(error);
};
const store = transaction.objectStore("files");
store.put({
filename,
contents,
});
});
}
writeFileSyncIfSupported(filename, contents) {
// Not supported
this.writeFileAsync(filename, contents);
return true;
}
readFileAsync(filename) {
if (!this.database) {
return Promise.reject("Storage not ready");
}
this.currentBusyFilename = filename;
const transaction = this.database.transaction(["files"], "readonly");
return new Promise((resolve, reject) => {
const store = transaction.objectStore("files");
const request = store.get(filename);
request.onsuccess = event => {
this.currentBusyFilename = null;
if (!request.result) {
reject(FILE_NOT_FOUND);
return;
}
resolve(request.result.contents);
};
request.onerror = error => {
this.currentBusyFilename = null;
logger.error("Error while reading", filename, ":", error);
reject(error);
};
});
}
deleteFileAsync(filename) {
if (this.currentBusyFilename === filename) {
logger.warn("Attempt to delete", filename, "while write progres on it is ongoing!");
}
if (!this.database) {
return Promise.reject("Storage not ready");
}
this.currentBusyFilename = filename;
const transaction = this.database.transaction(["files"], "readwrite");
return new Promise((resolve, reject) => {
transaction.oncomplete = () => {
this.currentBusyFilename = null;
resolve();
};
transaction.onerror = error => {
this.currentBusyFilename = null;
logger.error("Error while deleting", filename, ":", error);
reject(error);
};
const store = transaction.objectStore("files");
store.delete(filename);
});
}
}

View File

@ -76,13 +76,9 @@ export class Savegame extends ReadWriteProxy {
* @param {SavegameData} data
*/
migrate(data) {
// if (data.version === 1014) {
// if (data.dump) {
// const reader = new SavegameInterface_V1015(fakeLogger, data);
// reader.migrateFrom1014();
// }
// data.version = 1015;
// }
if (data.version < 1000) {
return ExplainedResult.bad("Can not migrate savegame, too old");
}
return ExplainedResult.good();
}
@ -218,7 +214,6 @@ export class Savegame extends ReadWriteProxy {
* Updates the savegames metadata
*/
saveMetadata() {
const reader = this.getDumpReader();
this.metaDataRef.lastUpdate = new Date().getTime();
this.metaDataRef.version = this.getCurrentVersion();
return this.app.savegameMgr.writeAsync();

View File

@ -154,6 +154,22 @@ export class SavegameManager extends ReadWriteProxy {
});
}
importSavegame(data) {
const savegame = this.createNewSavegame();
const migrationResult = savegame.migrate(data);
if (migrationResult.isBad()) {
return Promise.reject("Failed to migrate: " + migrationResult.reason);
}
savegame.currentData = data;
const verification = savegame.verify(data);
if (verification.isBad()) {
return Promise.reject("Verification failed: " + verification.result);
}
return savegame.writeSavegameAndMetadata().then(() => this.sortSavegames());
}
/**
* Sorts all savegames by their creation time descending
* @returns {Promise<any>}

View File

@ -1,8 +1,15 @@
import { GameState } from "../core/game_state";
import { cachebust } from "../core/cachebust";
import { globalConfig } from "../core/config";
import { makeDiv, formatSecondsToTimeAgo, generateFileDownload } from "../core/utils";
import {
makeDiv,
formatSecondsToTimeAgo,
generateFileDownload,
removeAllChildren,
waitNextFrame,
} from "../core/utils";
import { ReadWriteProxy } from "../core/read_write_proxy";
import { HUDModalDialogs } from "../game/hud/parts/modal_dialogs";
export class MainMenuState extends GameState {
constructor() {
@ -10,23 +17,62 @@ export class MainMenuState extends GameState {
}
getInnerHTML() {
const bannerHtml = `
<h3>This is a Demo Version</h3>
<p>Get <strong>shapez.io on steam</strong> for:</p>
<ul>
<li>No advertisements and demo banners.</li>
<li>Unlimited savegame slots.</li>
<li>Supporting the developer </li>
</ul>
<a href="https://steam.shapez.io" class="steamLink" target="_blank">Get shapez.io on steam!</a>
`;
return `
<video autoplay muted loop class="fullscreenBackgroundVideo">
<source src="${cachebust("res/bg_render.webm")}" type="video/webm">
</video>
<div class="logo">
<img src="${cachebust("res/logo.png")}" alt="shapez.io Logo">
</div>
<div class="betaWarning">
This game is still under development - Please report any issues!
${
G_IS_STANDALONE
? ""
: `
<div class="demoBadge"></div>
`
}
</div>
<div class="mainContainer">
<button class="playButton styledButton">Play</button>
<div class="mainWrapper">
${
G_IS_STANDALONE
? ""
: `
<div class="standaloneBanner leftSide">${bannerHtml}</div>
`
}
<div class="mainContainer">
<button class="playButton styledButton">Play</button>
<button class="importButton styledButton">Import savegame</button>
</div>
${
G_IS_STANDALONE
? ""
: `
<div class="standaloneBanner rightSide">${bannerHtml}</div>
`
}
</div>
<div class="footer">
@ -45,6 +91,60 @@ export class MainMenuState extends GameState {
`;
}
requestImportSavegame() {
var input = document.createElement("input");
input.type = "file";
input.accept = ".bin";
input.onchange = e => {
const file = input.files[0];
if (file) {
waitNextFrame().then(() => {
const closeLoader = this.dialogs.showLoadingDialog();
const reader = new FileReader();
reader.addEventListener("load", event => {
const contents = event.target.result;
let realContent;
try {
realContent = ReadWriteProxy.deserializeObject(contents);
} catch (err) {
closeLoader();
this.dialogs.showWarning(
"Import error",
"Failed to import your savegame:<br><br>" + err
);
return;
}
this.app.savegameMgr.importSavegame(realContent).then(
() => {
closeLoader();
this.dialogs.showWarning("Imported", "Your savegame has been imported.");
this.renderSavegames();
},
err => {
closeLoader();
this.dialogs.showWarning(
"Import error",
"Failed to import savegame. Please check the console output."
);
}
);
});
reader.addEventListener("error", error => {
console.error(error);
alert("Failed to read file: " + error);
});
reader.readAsText(file, "utf-8");
});
}
};
input.click();
}
onBackButton() {
this.app.platformWrapper.exitApp();
}
@ -54,8 +154,13 @@ export class MainMenuState extends GameState {
alert("Error while loading game: " + payload.loadError);
}
this.dialogs = new HUDModalDialogs(null, this.app);
const dialogsElement = document.body.querySelector(".modalDialogParent");
this.dialogs.initializeToElement(dialogsElement);
const qs = this.htmlElement.querySelector.bind(this.htmlElement);
this.trackClicks(qs(".mainContainer .playButton"), this.onPlayButtonClicked);
this.trackClicks(qs(".mainContainer .importButton"), this.requestImportSavegame);
if (G_IS_DEV && globalConfig.debug.fastGameEnter) {
this.onPlayButtonClicked();
@ -76,6 +181,10 @@ export class MainMenuState extends GameState {
}
renderSavegames() {
const oldContainer = this.htmlElement.querySelector(".mainContainer .savegames");
if (oldContainer) {
oldContainer.remove();
}
const games = this.app.savegameMgr.getSavegamesMetaData();
if (games.length > 0) {
const parent = makeDiv(this.htmlElement.querySelector(".mainContainer"), null, ["savegames"]);
@ -91,6 +200,10 @@ export class MainMenuState extends GameState {
formatSecondsToTimeAgo((new Date().getTime() - games[i].lastUpdate) / 1000.0)
);
const deleteButton = document.createElement("button");
deleteButton.classList.add("styledButton", "deleteGame");
elem.appendChild(deleteButton);
const downloadButton = document.createElement("button");
downloadButton.classList.add("styledButton", "downloadGame");
elem.appendChild(downloadButton);
@ -99,6 +212,7 @@ export class MainMenuState extends GameState {
resumeBtn.classList.add("styledButton", "resumeGame");
elem.appendChild(resumeBtn);
this.trackClicks(deleteButton, () => this.deleteGame(games[i]));
this.trackClicks(downloadButton, () => this.downloadGame(games[i]));
this.trackClicks(resumeBtn, () => this.resumeGame(games[i]));
}
@ -117,6 +231,28 @@ export class MainMenuState extends GameState {
});
}
/**
* @param {object} game
*/
deleteGame(game) {
const signals = this.dialogs.showWarning(
"Confirm Deletion",
"Are you sure you want to delete the game?",
["delete:bad", "cancel:good"]
);
signals.delete.add(() => {
this.app.savegameMgr.deleteSavegame(game).then(
() => {
this.renderSavegames();
},
err => {
this.dialogs.showWarning("Failed to delete", "Error: " + err);
}
);
});
}
/**
* @param {object} game
*/
@ -138,6 +274,6 @@ export class MainMenuState extends GameState {
}
onLeave() {
// this.dialogs.cleanup();
this.dialogs.cleanup();
}
}

View File

@ -67,10 +67,6 @@ export class PreloadState extends GameState {
.then(() => this.setStatus("Creating platform wrapper"))
.then(() => this.app.platformWrapper.initialize())
.then(() => this.setStatus("Initializing libraries"))
.then(() => this.app.analytics.initialize())
.then(() => this.app.gameAnalytics.initialize())
.then(() => this.setStatus("Initializing local storage"))
.then(() => {
const wrapper = this.app.platformWrapper;
@ -95,6 +91,10 @@ export class PreloadState extends GameState {
return this.app.storage.initialize();
})
.then(() => this.setStatus("Initializing libraries"))
.then(() => this.app.analytics.initialize())
.then(() => this.app.gameAnalytics.initialize())
.then(() => this.setStatus("Initializing settings"))
.then(() => {
return this.app.settings.initialize();