1
0
mirror of https://github.com/tobspr/shapez.io.git synced 2025-06-13 13:04:03 +00:00

Puzzle mode, almost done

This commit is contained in:
tobspr 2021-05-01 18:04:33 +02:00
parent 846e66a9c8
commit f5c1e26256
23 changed files with 897 additions and 174 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,192 @@
#ingame_HUD_PuzzleCompleteNotification {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: auto;
pointer-events: all;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
& {
/* @load-async */
background: rgba(#333538, 0.98) uiResource("dialog_bg_pattern.png") top left / #{D(10px)} repeat;
}
@include InlineAnimation(0.1s ease-in-out) {
0% {
opacity: 0;
}
}
> .dialog {
// background: rgba(#222428, 0.5);
@include S(border-radius, $globalBorderRadius);
@include S(padding, 30px);
@include InlineAnimation(0.5s ease-in-out) {
0% {
opacity: 0;
}
}
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
color: #fff;
text-align: center;
> .title {
@include SuperHeading;
text-transform: uppercase;
@include S(font-size, 30px);
@include S(margin-bottom, 40px);
color: $colorGreenBright !important;
@include InlineAnimation(0.5s ease-in-out) {
0% {
transform: translateY(-50vh);
}
50% {
transform: translateY(5vh);
}
75% {
transform: translateY(-2vh);
}
}
}
.contents {
@include S(width, 400px);
@include S(height, 170px);
@include InlineAnimation(0.5s ease-in-out) {
0% {
transform: translateX(-100vw);
}
50% {
transform: translateX(5vw);
}
75% {
transform: translateX(-2vw);
}
}
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
> .stepLike {
display: flex;
flex-direction: column;
@include S(margin-bottom, 10px);
> .buttons {
display: flex;
align-items: center;
justify-content: center;
@include S(margin, 10px, 0);
> button {
@include S(width, 40px);
@include S(height, 40px);
background: green;
@include S(margin, 0, 10px);
box-sizing: border-box;
@include S(border-radius, $globalBorderRadius);
transition: opacity 0.12s ease-in-out, background-color 0.12s ease-in-out;
&.liked-yes {
/* @load-async */
background: uiResource("icons/puzzle_action_liked_yes.png") center center / 60%
no-repeat;
}
&.liked-no {
/* @load-async */
background: uiResource("icons/puzzle_action_liked_no.png") center center / 60%
no-repeat;
}
&:hover:not(.active) {
opacity: 0.5 !important;
}
&.active {
background-color: #151118 !important;
}
&:not(.active) {
opacity: 0.4;
}
}
}
}
> .stepDifficulty {
display: flex;
flex-direction: column;
align-items: center;
@include S(margin-bottom, 10px);
> .shapes {
@include S(margin-top, 10px);
display: flex;
align-items: center;
> canvas {
@include S(margin, 0, 5px);
@include S(width, 30px);
@include S(height, 30px);
@include S(border-radius, $globalBorderRadius);
transition: opacity 0.12s ease-in-out, background-color 0.12s ease-in-out,
box-shadow 0.12s ease-in-out;
pointer-events: all;
cursor: pointer;
&.active {
background-color: #151118 !important;
box-shadow: 0 0 0 D(2px) #151118;
}
&:not(.active) {
opacity: 0.4;
}
&:nth-child(1) {
transform: scale(0.8) !important;
}
&:nth-child(2) {
transform: scale(0.9) !important;
}
&:nth-child(3) {
transform: scale(1) !important;
}
&:nth-child(4) {
transform: scale(1.1) !important;
}
&:nth-child(5) {
transform: scale(1.2) !important;
}
&:nth-child(6) {
transform: scale(1.3) !important;
}
}
}
}
}
button.close {
border: 0;
position: relative;
@include S(margin-top, 30px);
&:not(.visible) {
opacity: 0;
pointer-events: none;
}
}
}
}

View File

@ -62,6 +62,7 @@
@import "ingame_hud/puzzle_editor_controls";
@import "ingame_hud/puzzle_editor_settings";
@import "ingame_hud/puzzle_play_metadata";
@import "ingame_hud/puzzle_complete_notification";
// prettier-ignore
$elements:
@ -109,6 +110,7 @@ ingame_HUD_Statistics,
ingame_HUD_ShapeViewer,
ingame_HUD_StandaloneAdvantages,
ingame_HUD_UnlockNotification,
ingame_HUD_PuzzleCompleteNotification,
ingame_HUD_SettingsMenu,
ingame_HUD_ModalDialogs,
ingame_HUD_CatMemes;

View File

@ -3,6 +3,10 @@
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
> h1 {
justify-self: start;
}
}
> .container {
@ -43,10 +47,9 @@
> .puzzles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(D(150px), 1fr));
@include S(grid-auto-rows, 120px);
@include S(grid-gap, 3px);
@include S(grid-auto-columns, 1fr);
@include S(margin-top, 10px);
@include S(padding-right, 4px);
@include S(height, 360px);
@ -113,38 +116,59 @@
white-space: nowrap;
}
> .playcount {
grid-column: 1 / 2;
display: none;
grid-row: 3 / 4;
@include SuperSmallText;
}
> .upvotes {
@include SuperSmallText;
> .stats {
grid-column: 2 / 3;
grid-row: 3 / 4;
color: #444;
align-self: end;
display: flex;
align-items: center;
justify-self: end;
font-weight: bold;
@include S(padding-right, 12px);
opacity: 0.89;
align-self: end;
& {
/* @load-async */
background: uiResource("icons/puzzle_upvotes.png") calc(100% - #{D(2px)}) #{D(
3.5px
)} / #{D(8px)} #{D(8px)} no-repeat;
> .downloads {
@include SuperSmallText;
color: #000;
align-self: start;
justify-self: start;
font-weight: bold;
@include S(margin-right, 10px);
@include S(padding-left, 14px);
opacity: 0.7;
display: inline-flex;
align-items: center;
justify-content: center;
& {
/* @load-async */
background: uiResource("icons/puzzle_plays.png") #{D(2px)} center / #{D(8px)}
#{D(8px)} no-repeat;
}
}
> .likes {
@include SuperSmallText;
align-items: center;
justify-content: center;
color: #000;
align-self: start;
justify-self: start;
font-weight: bold;
@include S(padding-left, 14px);
opacity: 0.7;
& {
/* @load-async */
background: uiResource("icons/puzzle_upvotes.png") #{D(2px)} center / #{D(
8px
)} #{D(8px)} no-repeat;
}
}
}
&.completed {
.icon,
.upvotes,
.playcount,
.author,
.title {
> .icon,
> .stats,
> .author,
> .title {
opacity: 0.5;
}
@ -168,12 +192,13 @@
}
}
> .loader {
> .loader,
> .empty {
grid-column: 1 / -1;
grid-row: 1 / 3;
display: flex;
align-items: center;
color: $accentColorBright;
color: $accentColorDark;
justify-content: center;
}
}

View File

@ -32,6 +32,8 @@ import { SettingsState } from "./states/settings";
import { ShapezGameAnalytics } from "./platform/browser/game_analytics";
import { RestrictionManager } from "./core/restriction_manager";
import { PuzzleMenuState } from "./states/puzzle_menu";
import { ClientAPI } from "./platform/api";
import { LoginState } from "./states/login";
/**
* @typedef {import("./platform/achievement_provider").AchievementProviderInterface} AchievementProviderInterface
@ -73,6 +75,7 @@ export class Application {
this.savegameMgr = new SavegameManager(this);
this.inputMgr = new InputDistributor(this);
this.backgroundResourceLoader = new BackgroundResourcesLoader(this);
this.clientApi = new ClientAPI(this);
// Restrictions (Like demo etc)
this.restrictionMgr = new RestrictionManager(this);
@ -161,6 +164,7 @@ export class Application {
AboutState,
ChangelogState,
PuzzleMenuState,
LoginState,
];
for (let i = 0; i < states.length; ++i) {

View File

@ -79,11 +79,9 @@ export class GameHUD {
}
const additionalParts = this.root.gameMode.additionalHudParts;
console.log(additionalParts);
for (const [partId, part] of Object.entries(additionalParts)) {
this.parts[partId] = new part(this.root);
}
console.log(this.parts);
const frag = document.createDocumentFragment();
for (const key in this.parts) {

View File

@ -0,0 +1,136 @@
import { InputReceiver } from "../../../core/input_receiver";
import { makeDiv } from "../../../core/utils";
import { SOUNDS } from "../../../platform/sound";
import { T } from "../../../translations";
import { enumColors } from "../../colors";
import { ColorItem } from "../../items/color_item";
import { PuzzlePlayGameMode } from "../../modes/puzzle_play";
import { finalGameShape, rocketShape } from "../../modes/regular";
import { BaseHUDPart } from "../base_hud_part";
import { DynamicDomAttach } from "../dynamic_dom_attach";
export class HUDPuzzleCompleteNotification extends BaseHUDPart {
initialize() {
this.visible = false;
this.domAttach = new DynamicDomAttach(this.root, this.element, {
timeToKeepSeconds: 0,
});
this.root.signals.puzzleComplete.add(this.show, this);
this.selectionLiked = null;
this.selectionDifficulty = null;
this.timeOfCompletion = 0;
}
createElements(parent) {
this.inputReciever = new InputReceiver("puzzle-complete");
this.element = makeDiv(parent, "ingame_HUD_PuzzleCompleteNotification", ["noBlur"]);
const dialog = makeDiv(this.element, null, ["dialog"]);
this.elemTitle = makeDiv(dialog, null, ["title"], T.ingame.puzzleCompletion.title);
this.elemContents = makeDiv(dialog, null, ["contents"]);
const stepLike = makeDiv(this.elemContents, null, ["step", "stepLike"]);
makeDiv(stepLike, null, ["title"], T.ingame.puzzleCompletion.titleLike);
const buttons = makeDiv(stepLike, null, ["buttons"]);
this.buttonLikeYes = document.createElement("button");
this.buttonLikeYes.classList.add("liked-yes");
buttons.appendChild(this.buttonLikeYes);
this.trackClicks(this.buttonLikeYes, () => {
this.selectionLiked = true;
this.updateState();
});
this.buttonLikeNo = document.createElement("button");
this.buttonLikeNo.classList.add("liked-no");
buttons.appendChild(this.buttonLikeNo);
this.trackClicks(this.buttonLikeNo, () => {
this.selectionLiked = false;
this.updateState();
});
const stepDifficulty = makeDiv(this.elemContents, null, ["step", "stepDifficulty"]);
makeDiv(stepDifficulty, null, ["title"], T.ingame.puzzleCompletion.titleRating);
const shapeContainer = makeDiv(stepDifficulty, null, ["shapes"]);
const items = [
new ColorItem(enumColors.red),
this.root.shapeDefinitionMgr.getShapeItemFromShortKey("CuCuCuCu"),
this.root.shapeDefinitionMgr.getShapeItemFromShortKey("WwWwWwWw"),
this.root.shapeDefinitionMgr.getShapeItemFromShortKey("WrRgWrRg:CwCrCwCr:SgSgSgSg"),
this.root.shapeDefinitionMgr.getShapeItemFromShortKey(finalGameShape),
this.root.shapeDefinitionMgr.getShapeItemFromShortKey(rocketShape),
];
this.difficultyCanvases = [];
let index = 0;
for (const shape of items) {
const localIndex = index;
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
shape.drawFullSizeOnCanvas(context, 128);
shapeContainer.appendChild(canvas);
this.trackClicks(canvas, () => {
this.selectionDifficulty = localIndex;
this.updateState();
});
this.difficultyCanvases.push(canvas);
++index;
}
this.btnClose = document.createElement("button");
this.btnClose.classList.add("close", "styledButton");
this.btnClose.innerText = T.ingame.puzzleCompletion.buttonSubmit;
dialog.appendChild(this.btnClose);
this.trackClicks(this.btnClose, this.close);
}
updateState() {
this.buttonLikeYes.classList.toggle("active", this.selectionLiked === true);
this.buttonLikeNo.classList.toggle("active", this.selectionLiked === false);
this.difficultyCanvases.forEach((canvas, index) =>
canvas.classList.toggle("active", index === this.selectionDifficulty)
);
this.btnClose.classList.toggle(
"visible",
typeof this.selectionDifficulty === "number" && typeof this.selectionLiked === "boolean"
);
}
show() {
this.root.soundProxy.playUi(SOUNDS.levelComplete);
this.root.app.inputMgr.makeSureAttachedAndOnTop(this.inputReciever);
this.visible = true;
this.timeOfCompletion = this.root.time.now();
}
cleanup() {
this.root.app.inputMgr.makeSureDetached(this.inputReciever);
}
isBlockingOverlay() {
return this.visible;
}
close() {
/** @type {PuzzlePlayGameMode} */ (this.root.gameMode)
.trackCompleted(this.selectionLiked, this.selectionDifficulty, Math.round(this.timeOfCompletion))
.then(() => {
this.root.gameState.moveToState("PuzzleMenuState");
});
}
update() {
this.domAttach.update(this.visible);
}
}

View File

@ -58,14 +58,14 @@ export class HUDPuzzleEditorReview extends BaseHUDPart {
};
}
startSubmit() {
const regex = /^[a-zA-Z0-9_\- ]{1,20}$/;
startSubmit(title = "", shortKey = "") {
const regex = /^[a-zA-Z0-9_\- ]{4,20}$/;
const nameInput = new FormElementInput({
id: "nameInput",
label: T.dialogs.submitPuzzle.descName,
placeholder: T.dialogs.submitPuzzle.placeholderName,
defaultValue: "",
validator: val => val.match(regex) && trim(val).length > 0,
defaultValue: title,
validator: val => trim(val).match(regex) && trim(val).length > 0,
});
let items = new Set();
@ -93,7 +93,7 @@ export class HUDPuzzleEditorReview extends BaseHUDPart {
id: "shapeKeyInput",
label: null,
placeholder: "CuCuCuCu",
defaultValue: "",
defaultValue: shortKey,
validator: val => ShapeDefinition.isValidShortKey(trim(val)),
});
@ -126,7 +126,32 @@ export class HUDPuzzleEditorReview extends BaseHUDPart {
const closeLoading = this.root.hud.parts.dialogs.showLoadingDialog(T.puzzleMenu.submittingPuzzle);
// @todo
this.root.app.clientApi
.apiSubmitPuzzle({
title,
shortKey,
data: serialized,
})
.then(
() => {
closeLoading();
const { ok } = this.root.hud.parts.dialogs.showInfo(
T.dialogs.puzzleSubmitOk.title,
T.dialogs.puzzleSubmitOk.desc
);
ok.add(() => this.root.gameState.moveToState("PuzzleMenuState"));
},
err => {
closeLoading();
logger.warn("Failed to submit puzzle:", err);
const signals = this.root.hud.parts.dialogs.showWarning(
T.dialogs.puzzleSubmitError.title,
T.dialogs.puzzleSubmitError.desc + " " + err,
["cancel", "retry:good"]
);
signals.retry.add(() => this.startSubmit(title, shortKey));
}
);
}
update() {

View File

@ -25,6 +25,10 @@ import { HUDConstantSignalEdit } from "../hud/parts/constant_signal_edit";
import { PuzzleSerializer } from "../../savegame/puzzle_serializer";
import { T } from "../../translations";
import { HUDPuzzlePlayMetadata } from "../hud/parts/puzzle_play_metadata";
import { createLogger } from "../../core/logging";
import { HUDPuzzleCompleteNotification } from "../hud/parts/puzzle_complete_notification";
const logger = createLogger("puzzle-play");
export class PuzzlePlayGameMode extends PuzzleGameMode {
static getId() {
@ -62,6 +66,7 @@ export class PuzzlePlayGameMode extends PuzzleGameMode {
];
this.additionalHudParts.puzzlePlayMetadata = HUDPuzzlePlayMetadata;
this.additionalHudParts.puzzleCompleteNotification = HUDPuzzleCompleteNotification;
root.signals.postLoadHook.add(this.loadPuzzle, this);
@ -70,6 +75,7 @@ export class PuzzlePlayGameMode extends PuzzleGameMode {
loadPuzzle() {
let errorText;
logger.log("Loading puzzle", this.puzzle);
try {
errorText = new PuzzleSerializer().deserializePuzzle(this.root, this.puzzle.game);
@ -81,11 +87,40 @@ export class PuzzlePlayGameMode extends PuzzleGameMode {
}
if (errorText) {
const signals = this.root.hud.parts.dialogs.showWarning(
T.dialogs.puzzleLoadError.title,
T.dialogs.puzzleLoadError.desc + " " + errorText
);
signals.ok.add(() => this.root.gameState.moveToState("PuzzleMenuState"));
this.root.gameState.moveToState("PuzzleMenuState", {
error: {
title: T.dialogs.puzzleLoadError.title,
desc: T.dialogs.puzzleLoadError.desc + " " + errorText,
},
});
// const signals = this.root.hud.parts.dialogs.showWarning(
// T.dialogs.puzzleLoadError.title,
// T.dialogs.puzzleLoadError.desc + " " + errorText
// );
// signals.ok.add(() => this.root.gameState.moveToState("PuzzleMenuState"));
}
}
/**
*
* @param {boolean} liked
* @param {number} difficulty
* @param {number} time
*/
trackCompleted(liked, difficulty, time) {
const closeLoading = this.root.hud.parts.dialogs.showLoadingDialog();
return this.root.app.clientApi
.apiCompletePuzzle(this.puzzle.meta.id, {
time,
difficulty,
liked,
})
.catch(err => {
logger.warn("Failed to complete puzzle:", err);
})
.then(() => {
closeLoading();
});
}
}

View File

@ -57,8 +57,8 @@ import { queryParamOptions } from "../../core/query_parameters";
* throughputOnly?: boolean
* }} LevelDefinition */
const rocketShape = "CbCuCbCu:Sr------:--CrSrCr:CwCwCwCw";
const finalGameShape = "RuCw--Cw:----Ru--";
export const rocketShape = "CbCuCbCu:Sr------:--CrSrCr:CwCwCwCw";
export const finalGameShape = "RuCw--Cw:----Ru--";
const preparementShape = "CpRpCp--:SwSwSwSw";
// Tiers need % of the previous tier as requirement too

View File

@ -183,6 +183,9 @@ export class GameRoot {
// Called with an achievement key and necessary args to validate it can be unlocked.
achievementCheck: /** @type {TypedSignal<[string, any]>} */ (new Signal()),
bulkAchievementCheck: /** @type {TypedSignal<(string|any)[]>} */ (new Signal()),
// Puzzle mode
puzzleComplete: /** @type {TypedSignal<[]>} */ (new Signal()),
};
// RNG's

View File

@ -6,10 +6,9 @@ import { fillInLinkIntoTranslation } from "../../core/utils";
import { T } from "../../translations";
import { BaseItem } from "../base_item";
import { enumColors } from "../colors";
import { enumConstantSignalType, ConstantSignalComponent } from "../components/constant_signal";
import { ConstantSignalComponent, enumConstantSignalType } from "../components/constant_signal";
import { Entity } from "../entity";
import { GameSystemWithFilter } from "../game_system_with_filter";
import { HUDPinnedShapes } from "../hud/parts/pinned_shapes";
import { BOOL_FALSE_SINGLETON, BOOL_TRUE_SINGLETON } from "../items/boolean_item";
import { COLOR_ITEM_SINGLETONS } from "../items/color_item";
import { ShapeDefinition } from "../shape_definition";
@ -60,13 +59,20 @@ export class ConstantSignalSystem extends GameSystemWithFilter {
validator: val => this.parseSignalCode(entity.components.ConstantSignal.type, val),
});
const items = [
...Object.values(COLOR_ITEM_SINGLETONS),
this.root.shapeDefinitionMgr.getShapeItemFromShortKey(this.root.gameMode.getBlueprintShapeKey()),
];
const items = [...Object.values(COLOR_ITEM_SINGLETONS)];
if (entity.components.ConstantSignal.type === enumConstantSignalType.wired) {
items.unshift(BOOL_FALSE_SINGLETON, BOOL_TRUE_SINGLETON);
items.push(
this.root.shapeDefinitionMgr.getShapeItemFromShortKey(
this.root.gameMode.getBlueprintShapeKey()
)
);
} else if (entity.components.ConstantSignal.type === enumConstantSignalType.wireless) {
const shapes = ["CuCuCuCu", "RuRuRuRu", "WuWuWuWu", "SuSuSuSu"];
items.unshift(
...shapes.reverse().map(key => this.root.shapeDefinitionMgr.getShapeItemFromShortKey(key))
);
}
if (this.root.gameMode.hasHub()) {

View File

@ -41,7 +41,7 @@ export class GoalAcceptorSystem extends GameSystemWithFilter {
allAccepted &&
!this.root.gameMode.getIsEditor()
) {
this.root.hud.parts.dialogs.showInfo("Puzzle completed", "Congrats!");
this.root.signals.puzzleComplete.dispatch();
this.puzzleCompleted = true;
}
}

163
src/js/platform/api.js Normal file
View File

@ -0,0 +1,163 @@
/* typehints:start */
import { Application } from "../application";
/* typehints:end */
import { createLogger } from "../core/logging";
const logger = createLogger("puzzle-api");
export class ClientAPI {
/**
*
* @param {Application} app
*/
constructor(app) {
this.app = app;
/**
* The current users session token
* @type {string|null}
*/
this.token = null;
}
getEndpoint() {
if (G_IS_DEV) {
return "http://localhost:15001";
}
if (window.location.host === "beta.shapez.io") {
return "https://api-staging.shapez.io";
}
return "https://api.shapez.io";
}
isLoggedIn() {
return Boolean(this.token);
}
/**
*
* @param {string} endpoint
* @param {object} options
* @param {"GET"|"POST"=} options.method
* @param {any=} options.body
*/
_request(endpoint, options) {
const headers = {
"x-api-key": "d5c54aaa491f200709afff082c153ef1",
"Content-Type": "application/json",
};
if (this.token) {
headers["x-token"] = this.token;
}
return Promise.race([
fetch(this.getEndpoint() + endpoint, {
cache: "no-cache",
mode: "cors",
headers,
method: options.method || "GET",
body: options.body ? JSON.stringify(options.body) : undefined,
})
.then(res => {
if (res.status !== 200) {
throw "bad-status: " + res.status + " / " + res.statusText;
}
return res;
})
.then(res => res.json()),
new Promise(resolve => setTimeout(resolve, 5000)),
])
.then(data => {
if (data.error) {
throw data.error;
}
return data;
})
.catch(err => {
logger.warn("Failure:", endpoint, ":", err);
throw err;
});
}
tryLogin() {
return this.apiTryLogin()
.then(({ token }) => {
this.token = token;
return true;
})
.catch(err => {
logger.warn("Failed to login:", err);
return false;
});
}
/**
* @returns {Promise<{token: string}>}
*/
apiTryLogin() {
return this._request("/v1/public/login", {
method: "POST",
body: {
hello: "world",
},
});
}
/**
* @param {"new"|"top-rated"|"mine"} category
* @returns {Promise<import("../savegame/savegame_typedefs").PuzzleMetadata[]>}
*/
apiListPuzzles(category) {
if (!this.isLoggedIn()) {
return Promise.reject("not-logged-in");
}
return this._request("/v1/puzzles/list/" + category, {});
}
/**
* @param {number} puzzleId
* @returns {Promise<import("../savegame/savegame_typedefs").PuzzleFullData>}
*/
apiDownloadPuzzle(puzzleId) {
if (!this.isLoggedIn()) {
return Promise.reject("not-logged-in");
}
return this._request("/v1/puzzles/download/" + puzzleId, {});
}
/**
* @param {number} puzzleId
* @param {object} payload
* @param {number} payload.time
* @param {number} payload.difficulty
* @param {boolean} payload.liked
* @returns {Promise<{ success: true }>}
*/
apiCompletePuzzle(puzzleId, payload) {
if (!this.isLoggedIn()) {
return Promise.reject("not-logged-in");
}
return this._request("/v1/puzzles/complete/" + puzzleId, {
method: "POST",
body: payload,
});
}
/**
* @param {object} payload
* @param {string} payload.title
* @param {string} payload.shortKey
* @param {import("../savegame/savegame_typedefs").PuzzleGameData} payload.data
* @returns {Promise<{ success: true }>}
*/
apiSubmitPuzzle(payload) {
if (!this.isLoggedIn()) {
return Promise.reject("not-logged-in");
}
return this._request("/v1/puzzles/submit", {
method: "POST",
body: payload,
});
}
}

View File

@ -41,14 +41,16 @@
* }} SavegamesData
*/
// Notice: Update backend too
/**
* @typedef {{
* shortKey: string;
* upvotes: number;
* playcount: number;
* title: string;
* author: string;
* completed: boolean;
* id: number;
* shortKey: string;
* likes: number;
* downloads: number;
* title: string;
* author: string;
* completed: boolean;
* }} PuzzleMetadata
*/

102
src/js/states/login.js Normal file
View File

@ -0,0 +1,102 @@
import { GameState } from "../core/game_state";
import { getRandomHint } from "../game/hints";
import { HUDModalDialogs } from "../game/hud/parts/modal_dialogs";
import { T } from "../translations";
export class LoginState extends GameState {
constructor() {
super("LoginState");
}
getInnerHTML() {
return `
<div class="loadingImage"></div>
<div class="loadingStatus">
<span class="desc">${T.global.loggingIn}</span>
</div>
</div>
<span class="prefab_GameHint"></span>
`;
}
/**
*
* @param {object} payload
* @param {string} payload.nextStateId
*/
onEnter(payload) {
this.payload = payload;
if (!this.payload.nextStateId) {
throw new Error("No next state id");
}
if (this.app.clientApi.isLoggedIn()) {
this.finishLoading();
return;
}
this.dialogs = new HUDModalDialogs(null, this.app);
const dialogsElement = document.body.querySelector(".modalDialogParent");
this.dialogs.initializeToElement(dialogsElement);
this.htmlElement.classList.add("prefab_LoadingState");
/** @type {HTMLElement} */
this.hintsText = this.htmlElement.querySelector(".prefab_GameHint");
this.lastHintShown = -1000;
this.nextHintDuration = 0;
this.tryLogin();
}
tryLogin() {
this.app.clientApi.tryLogin().then(success => {
console.log("Logged in:", success);
if (!success) {
const signals = this.dialogs.showWarning(
T.dialogs.offlineMode.title,
T.dialogs.offlineMode.desc,
["retry", "playOffline:bad"]
);
signals.retry.add(() => setTimeout(() => this.tryLogin(), 2000), this);
signals.playOffline.add(this.finishLoading, this);
} else {
this.finishLoading();
}
});
}
finishLoading() {
this.moveToState(this.payload.nextStateId);
}
getDefaultPreviousState() {
return "MainMenuState";
}
update() {
const now = performance.now();
if (now - this.lastHintShown > this.nextHintDuration) {
this.lastHintShown = now;
const hintText = getRandomHint();
this.hintsText.innerHTML = hintText;
/**
* Compute how long the user will need to read the hint.
* We calculate with 130 words per minute, with an average of 5 chars
* that is 650 characters / minute
*/
this.nextHintDuration = Math.max(2500, (hintText.length / 650) * 60 * 1000);
}
}
onRender() {
this.update();
}
onBackgroundTick() {
this.update();
}
}

View File

@ -323,53 +323,10 @@ export class MainMenuState extends GameState {
this.trackClicks(puzzleModeButton, this.onPuzzleModeButtonClicked);
}
renderPuzzleModeMenu() {
const savegames = this.htmlElement.querySelector(".mainContainer .savegames");
if (savegames) {
savegames.remove();
}
const buttonContainer = this.htmlElement.querySelector(".mainContainer .buttons");
removeAllChildren(buttonContainer);
const playButtonElement = makeButtonElement(["playModeButton", "styledButton"], T.puzzleMenu.play);
const editButtonElement = makeButtonElement(["editModeButton", "styledButton"], T.puzzleMenu.edit);
buttonContainer.appendChild(playButtonElement);
this.trackClicks(playButtonElement, this.onPuzzlePlayButtonClicked);
buttonContainer.appendChild(editButtonElement);
this.trackClicks(editButtonElement, this.onPuzzleEditButtonClicked);
const bottomButtonContainer = this.htmlElement.querySelector(".bottomContainer .buttons");
removeAllChildren(bottomButtonContainer);
const backButton = makeButton(bottomButtonContainer, ["styledButton"], T.mainMenu.back);
bottomButtonContainer.appendChild(backButton);
this.trackClicks(backButton, this.onBackButtonClicked);
}
onPuzzlePlayButtonClicked() {
const savegame = this.app.savegameMgr.createNewSavegame();
this.moveToState("InGameState", {
gameModeId: enumGameModeIds.puzzlePlay,
savegame,
});
}
onPuzzleEditButtonClicked() {
const savegame = this.app.savegameMgr.createNewSavegame();
this.moveToState("InGameState", {
gameModeId: enumGameModeIds.puzzleEdit,
savegame,
});
}
onPuzzleModeButtonClicked() {
this.moveToState("PuzzleMenuState");
this.moveToState("LoginState", {
nextStateId: "PuzzleMenuState",
});
}
onBackButtonClicked() {

View File

@ -57,8 +57,6 @@ export class PreloadState extends GameState {
this.lastHintShown = -1000;
this.nextHintDuration = 0;
this.currentStatus = "booting";
this.startLoading();
}

View File

@ -1,36 +1,48 @@
import { globalConfig } from "../core/config";
import { createLogger } from "../core/logging";
import { TextualGameState } from "../core/textual_game_state";
import { formatBigNumberFull } from "../core/utils";
import { enumGameModeIds } from "../game/game_mode";
import { ShapeDefinition } from "../game/shape_definition";
import { T } from "../translations";
const categories = ["levels", "new", "topRated", "myPuzzles"];
const categories = ["levels", "new", "top-rated", "mine"];
/**
* @type {import("../savegame/savegame_typedefs").PuzzleMetadata}
*/
const SAMPLE_PUZZLE = {
id: 1,
shortKey: "CuCuCuCu",
upvotes: 10000,
playcount: 1000,
downloads: 0,
likes: 0,
title: "Level 1",
author: "verylongsteamnamewhichbreaks",
completed: false,
};
const BUILTIN_PUZZLES = [
{ ...SAMPLE_PUZZLE, completed: true },
{ ...SAMPLE_PUZZLE, completed: true },
SAMPLE_PUZZLE,
SAMPLE_PUZZLE,
SAMPLE_PUZZLE,
SAMPLE_PUZZLE,
SAMPLE_PUZZLE,
SAMPLE_PUZZLE,
SAMPLE_PUZZLE,
SAMPLE_PUZZLE,
SAMPLE_PUZZLE,
SAMPLE_PUZZLE,
SAMPLE_PUZZLE,
];
/**
* @type {import("../savegame/savegame_typedefs").PuzzleMetadata[]}
*/
const BUILTIN_PUZZLES = G_IS_DEV
? [
// { ...SAMPLE_PUZZLE, completed: true },
// { ...SAMPLE_PUZZLE, completed: true },
// SAMPLE_PUZZLE,
// SAMPLE_PUZZLE,
// SAMPLE_PUZZLE,
// SAMPLE_PUZZLE,
// SAMPLE_PUZZLE,
// SAMPLE_PUZZLE,
// SAMPLE_PUZZLE,
// SAMPLE_PUZZLE,
// SAMPLE_PUZZLE,
// SAMPLE_PUZZLE,
// SAMPLE_PUZZLE,
]
: [];
const logger = createLogger("puzzle-menu");
export class PuzzleMenuState extends TextualGameState {
constructor() {
@ -124,6 +136,7 @@ export class PuzzleMenuState extends TextualGameState {
T.dialogs.puzzleLoadFailed.title,
T.dialogs.puzzleLoadFailed.desc + " " + error
);
this.renderPuzzles([]);
}
)
.then(() => (this.loading = false));
@ -158,19 +171,19 @@ export class PuzzleMenuState extends TextualGameState {
elem.appendChild(author);
}
if (puzzle.upvotes) {
const upvotes = document.createElement("div");
upvotes.classList.add("upvotes");
upvotes.innerText = formatBigNumberFull(puzzle.upvotes);
elem.appendChild(upvotes);
}
const stats = document.createElement("div");
stats.classList.add("stats");
elem.appendChild(stats);
if (puzzle.playcount) {
const playcount = document.createElement("div");
playcount.classList.add("playcount");
playcount.innerText = String(puzzle.playcount) + " plays";
elem.appendChild(playcount);
}
const downloads = document.createElement("div");
downloads.classList.add("downloads");
downloads.innerText = String(puzzle.downloads);
stats.appendChild(downloads);
const likes = document.createElement("div");
likes.classList.add("likes");
likes.innerText = formatBigNumberFull(puzzle.likes);
stats.appendChild(likes);
const definition = ShapeDefinition.fromShortKey(puzzle.shortKey);
const canvas = definition.generateAsCanvas(100 * this.app.getEffectiveUiScale());
@ -184,10 +197,30 @@ export class PuzzleMenuState extends TextualGameState {
this.trackClicks(elem, () => this.playPuzzle(puzzle));
}
if (puzzles.length === 0) {
const elem = document.createElement("div");
elem.classList.add("empty");
elem.innerText = T.puzzleMenu.noPuzzles;
container.appendChild(elem);
}
}
/**
*
* @param {*} category
* @returns {Promise<import("../savegame/savegame_typedefs").PuzzleMetadata[]}
*/
getPuzzlesForCategory(category) {
return new Promise(resolve => setTimeout(() => resolve(BUILTIN_PUZZLES), 100));
if (category === "levels") {
return Promise.resolve(BUILTIN_PUZZLES);
}
const result = this.app.clientApi.apiListPuzzles(category);
return result.catch(err => {
logger.error("Failed to get", category, ":", err);
throw err;
});
}
/**
@ -195,53 +228,46 @@ export class PuzzleMenuState extends TextualGameState {
* @param {import("../savegame/savegame_typedefs").PuzzleMetadata} puzzle
*/
playPuzzle(puzzle) {
/**
* @type {import("../savegame/savegame_typedefs").PuzzleGameData}
*/
const puzzleData = {
version: 1,
buildings: [
{
type: "emitter",
item: "CuCuCuCu",
pos: { x: -2, y: 2, r: 0 },
},
{
type: "emitter",
item: "red",
pos: { x: 1, y: 2, r: 0 },
},
{
type: "goal",
item: "CrCrCrCr",
pos: { x: 0, y: -3, r: 0 },
},
],
bounds: { w: 4, h: 6 },
};
const closeLoading = this.dialogs.showLoadingDialog();
const savegame = this.app.savegameMgr.createNewSavegame();
this.moveToState("InGameState", {
gameModeId: enumGameModeIds.puzzlePlay,
gameModeParameters: {
puzzle: {
meta: puzzle,
game: puzzleData,
},
this.app.clientApi.apiDownloadPuzzle(puzzle.id).then(
puzzleData => {
closeLoading();
logger.log("Got puzzle:", puzzleData);
const savegame = this.app.savegameMgr.createNewSavegame();
this.moveToState("InGameState", {
gameModeId: enumGameModeIds.puzzlePlay,
gameModeParameters: {
puzzle: puzzleData,
},
savegame,
});
},
savegame,
});
err => {
closeLoading();
logger.error("Failed to download puzzle", puzzle.id, ":", err);
this.dialogs.showWarning(
T.dialogs.puzzleDownloadError.title,
T.dialogs.puzzleDownloadError.desc + " " + err
);
}
);
}
onEnter() {
onEnter(payload) {
this.selectCategory("levels");
if (payload && payload.error) {
this.dialogs.showWarning(payload.error.title, payload.error.desc);
}
for (const category of categories) {
const button = this.htmlElement.querySelector(`[data-category="${category}"]`);
this.trackClicks(button, () => this.selectCategory(category));
}
this.trackClicks(this.htmlElement.querySelector("button.createPuzzle"), this.createNewPuzzle);
this.trackClicks(this.htmlElement.querySelector("button.createPuzzle"), () => this.createNewPuzzle());
if (G_IS_DEV && globalConfig.debug.testPuzzleMode) {
// this.createNewPuzzle();
@ -249,7 +275,17 @@ export class PuzzleMenuState extends TextualGameState {
}
}
createNewPuzzle() {
createNewPuzzle(force = false) {
if (!force && !this.app.clientApi.isLoggedIn()) {
const signals = this.dialogs.showWarning(
T.dialogs.puzzleCreateOffline.title,
T.dialogs.puzzleCreateOffline.desc,
["cancel:good", "continue:bad"]
);
signals.continue.add(() => this.createNewPuzzle(true));
return;
}
const savegame = this.app.savegameMgr.createNewSavegame();
this.moveToState("InGameState", {
gameModeId: enumGameModeIds.puzzleEdit,

View File

@ -48,6 +48,7 @@ steamPage:
global:
loading: Loading
error: Error
loggingIn: Logging in
# How big numbers are rendered, e.g. "10,000"
thousandsDivider: ","
@ -127,12 +128,13 @@ puzzleMenu:
reviewPuzzle: Review & Publish
validtingPuzzle: Validating Puzzle
submittingPuzzle: Submitting Puzzle
noPuzzles: There are currently no puzzles in this section.
categories:
levels: Levels
new: New
topRated: Top Rated
myPuzzles: My Puzzles
top-rated: Top Rated
mine: My Puzzles
validation:
title: Invalid Puzzle
@ -158,6 +160,9 @@ dialogs:
viewUpdate: View Update
showUpgrades: Show Upgrades
showKeybindings: Show Keybindings
retry: Retry
continue: Continue
playOffline: Play Offline
importSavegameError:
title: Import Error
@ -307,6 +312,31 @@ dialogs:
desc: >-
The puzzle failed to load:
offlineMode:
title: Offline Mode
desc: >-
We couldn't reach the backend servers, so the game has to run in offline mode. Please make sure you have an active internect connection.
puzzleDownloadError:
title: Download Error
desc: >-
Failed to download the puzzle:
puzzleSubmitError:
title: Submission Error
desc: >-
Failed to submit your puzzle:
puzzleSubmitOk:
title: Puzzle Published
desc: >-
Congratulations! Your puzzle has been published and can now be played by others. You can now find it in the "My puzzles" section.
puzzleCreateOffline:
title: Offline Mode
desc: >-
Since you are offline, you will not be able to save and/or publish your puzzle. Would you still like to continue?
ingame:
# This is shown in the top left corner and displays useful keybindings in
# every situation
@ -547,6 +577,15 @@ ingame:
- 4. Once you click review, your puzzle will be validated and you can publish it.
- 5. Upon release, <strong>all buildings will be removed</strong> except for the Producers and Goal Acceptors - That's the part that the player is supposed to figure out for themselves, after all :)
puzzleCompletion:
title: Puzzle Completed!
titleLike: >-
Please rate the puzzle:
titleRating: How difficult did you find the puzzle?
buttonSubmit: Submit
# All shop upgrades
shopUpgrades:
belt: