diff --git a/res/ui/icons/puzzle_action_liked_no.png b/res/ui/icons/puzzle_action_liked_no.png new file mode 100644 index 00000000..7b30f81e Binary files /dev/null and b/res/ui/icons/puzzle_action_liked_no.png differ diff --git a/res/ui/icons/puzzle_action_liked_yes.png b/res/ui/icons/puzzle_action_liked_yes.png new file mode 100644 index 00000000..07b8bbcf Binary files /dev/null and b/res/ui/icons/puzzle_action_liked_yes.png differ diff --git a/res/ui/icons/puzzle_plays.png b/res/ui/icons/puzzle_plays.png new file mode 100644 index 00000000..358b5362 Binary files /dev/null and b/res/ui/icons/puzzle_plays.png differ diff --git a/res/ui/icons/puzzle_upvotes.png b/res/ui/icons/puzzle_upvotes.png index cf6ba212..685d4bd7 100644 Binary files a/res/ui/icons/puzzle_upvotes.png and b/res/ui/icons/puzzle_upvotes.png differ diff --git a/src/css/ingame_hud/puzzle_complete_notification.scss b/src/css/ingame_hud/puzzle_complete_notification.scss new file mode 100644 index 00000000..59a2be21 --- /dev/null +++ b/src/css/ingame_hud/puzzle_complete_notification.scss @@ -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; + } + } + } +} diff --git a/src/css/main.scss b/src/css/main.scss index e0bb389c..02679948 100644 --- a/src/css/main.scss +++ b/src/css/main.scss @@ -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; diff --git a/src/css/states/puzzle_menu.scss b/src/css/states/puzzle_menu.scss index 75541d8d..e6be2dda 100644 --- a/src/css/states/puzzle_menu.scss +++ b/src/css/states/puzzle_menu.scss @@ -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; } } diff --git a/src/js/application.js b/src/js/application.js index 3921c474..4e74b014 100644 --- a/src/js/application.js +++ b/src/js/application.js @@ -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) { diff --git a/src/js/game/hud/hud.js b/src/js/game/hud/hud.js index fffecdd5..10daa561 100644 --- a/src/js/game/hud/hud.js +++ b/src/js/game/hud/hud.js @@ -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) { diff --git a/src/js/game/hud/parts/puzzle_complete_notification.js b/src/js/game/hud/parts/puzzle_complete_notification.js new file mode 100644 index 00000000..bfc89dc1 --- /dev/null +++ b/src/js/game/hud/parts/puzzle_complete_notification.js @@ -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); + } +} diff --git a/src/js/game/hud/parts/puzzle_editor_review.js b/src/js/game/hud/parts/puzzle_editor_review.js index 70129c4a..e3f74920 100644 --- a/src/js/game/hud/parts/puzzle_editor_review.js +++ b/src/js/game/hud/parts/puzzle_editor_review.js @@ -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() { diff --git a/src/js/game/modes/puzzle_play.js b/src/js/game/modes/puzzle_play.js index 05825bef..fb4fcc29 100644 --- a/src/js/game/modes/puzzle_play.js +++ b/src/js/game/modes/puzzle_play.js @@ -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(); + }); + } } diff --git a/src/js/game/modes/regular.js b/src/js/game/modes/regular.js index e3e1e14e..ce66eea6 100644 --- a/src/js/game/modes/regular.js +++ b/src/js/game/modes/regular.js @@ -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 diff --git a/src/js/game/root.js b/src/js/game/root.js index 82d1e49f..cc6cc444 100644 --- a/src/js/game/root.js +++ b/src/js/game/root.js @@ -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 diff --git a/src/js/game/systems/constant_signal.js b/src/js/game/systems/constant_signal.js index 326ef342..bcaa0583 100644 --- a/src/js/game/systems/constant_signal.js +++ b/src/js/game/systems/constant_signal.js @@ -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()) { diff --git a/src/js/game/systems/goal_acceptor.js b/src/js/game/systems/goal_acceptor.js index 3abf5651..75b286d3 100644 --- a/src/js/game/systems/goal_acceptor.js +++ b/src/js/game/systems/goal_acceptor.js @@ -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; } } diff --git a/src/js/platform/api.js b/src/js/platform/api.js new file mode 100644 index 00000000..9616ad5d --- /dev/null +++ b/src/js/platform/api.js @@ -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} + */ + apiListPuzzles(category) { + if (!this.isLoggedIn()) { + return Promise.reject("not-logged-in"); + } + return this._request("/v1/puzzles/list/" + category, {}); + } + + /** + * @param {number} puzzleId + * @returns {Promise} + */ + 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, + }); + } +} diff --git a/src/js/savegame/savegame_typedefs.js b/src/js/savegame/savegame_typedefs.js index b28c222d..f8efcf34 100644 --- a/src/js/savegame/savegame_typedefs.js +++ b/src/js/savegame/savegame_typedefs.js @@ -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 */ diff --git a/src/js/states/login.js b/src/js/states/login.js new file mode 100644 index 00000000..64f599e4 --- /dev/null +++ b/src/js/states/login.js @@ -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 ` +
+
+ ${T.global.loggingIn} +
+ + + `; + } + + /** + * + * @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(); + } +} diff --git a/src/js/states/main_menu.js b/src/js/states/main_menu.js index 13cabdaa..da96d748 100644 --- a/src/js/states/main_menu.js +++ b/src/js/states/main_menu.js @@ -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() { diff --git a/src/js/states/preload.js b/src/js/states/preload.js index 9d843ea3..40261b7d 100644 --- a/src/js/states/preload.js +++ b/src/js/states/preload.js @@ -57,8 +57,6 @@ export class PreloadState extends GameState { this.lastHintShown = -1000; this.nextHintDuration = 0; - this.currentStatus = "booting"; - this.startLoading(); } diff --git a/src/js/states/puzzle_menu.js b/src/js/states/puzzle_menu.js index 8f3fd5c6..bed8cf26 100644 --- a/src/js/states/puzzle_menu.js +++ b/src/js/states/puzzle_menu.js @@ -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 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, diff --git a/translations/base-en.yaml b/translations/base-en.yaml index 11db8aea..69d002eb 100644 --- a/translations/base-en.yaml +++ b/translations/base-en.yaml @@ -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, all buildings will be removed 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: