From f5c1e2625624c9667fd369778e305289014056a8 Mon Sep 17 00:00:00 2001 From: tobspr Date: Sat, 1 May 2021 18:04:33 +0200 Subject: [PATCH] Puzzle mode, almost done --- res/ui/icons/puzzle_action_liked_no.png | Bin 0 -> 2341 bytes res/ui/icons/puzzle_action_liked_yes.png | Bin 0 -> 2785 bytes res/ui/icons/puzzle_plays.png | Bin 0 -> 1595 bytes res/ui/icons/puzzle_upvotes.png | Bin 3035 -> 2547 bytes .../puzzle_complete_notification.scss | 192 ++++++++++++++++++ src/css/main.scss | 2 + src/css/states/puzzle_menu.scss | 81 +++++--- src/js/application.js | 4 + src/js/game/hud/hud.js | 2 - .../hud/parts/puzzle_complete_notification.js | 136 +++++++++++++ src/js/game/hud/parts/puzzle_editor_review.js | 37 +++- src/js/game/modes/puzzle_play.js | 45 +++- src/js/game/modes/regular.js | 4 +- src/js/game/root.js | 3 + src/js/game/systems/constant_signal.js | 18 +- src/js/game/systems/goal_acceptor.js | 2 +- src/js/platform/api.js | 163 +++++++++++++++ src/js/savegame/savegame_typedefs.js | 14 +- src/js/states/login.js | 102 ++++++++++ src/js/states/main_menu.js | 49 +---- src/js/states/preload.js | 2 - src/js/states/puzzle_menu.js | 172 +++++++++------- translations/base-en.yaml | 43 +++- 23 files changed, 897 insertions(+), 174 deletions(-) create mode 100644 res/ui/icons/puzzle_action_liked_no.png create mode 100644 res/ui/icons/puzzle_action_liked_yes.png create mode 100644 res/ui/icons/puzzle_plays.png create mode 100644 src/css/ingame_hud/puzzle_complete_notification.scss create mode 100644 src/js/game/hud/parts/puzzle_complete_notification.js create mode 100644 src/js/platform/api.js create mode 100644 src/js/states/login.js 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 0000000000000000000000000000000000000000..7b30f81eec0c9c61b38bc6ad0f8ba07509081492 GIT binary patch literal 2341 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 BJqQ2* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..07b8bbcfa2d4fc4b73d27eed857a1aed9441d53d GIT binary patch literal 2785 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 X=Kl-6%H1P!1%R!Uqh*bSPs)D*>bg)W literal 0 HcmV?d00001 diff --git a/res/ui/icons/puzzle_plays.png b/res/ui/icons/puzzle_plays.png new file mode 100644 index 0000000000000000000000000000000000000000..358b5362762839b072d7901b611fe5fd8cf9c7fc GIT binary patch literal 1595 zcmeHH`7;}M9Q{Td5pmx-QIy%;S(Dlt%o0QiqHRQ)Z9;8P;;JFelvZT7ajmXWM;CRE z6}J}a4%-4P0u^?MWZy$(KQ?Z`0xcG!z@*VXsH>2r9|8g@r|H+g;4>W zPDcdC8F#}in%(pw~opgMwxzYQQ_P;m)K zDVQ`|1|cgaub`-;tg5b|sdWi?SyxZr;H$5{`PLA1)##eBiRt%fOKXgcE!F|&bQ4b? zlJ1aQ-R`>IyYEf)3wRI|96}2X3y+MVM?a2bK4B#$r94edf1Z(<^;=F}enDYzN!jo0 zipr{*+WOZGjej(^wRd!Ob-(H9d;5N9c!WDTHo=>mn)x`(pI=;B`Lw#W{`rewV{>a; zxU;u^aCmfla(Y&;fbAn=Cv`7h4%Es+I+j-YSaq<;phVR?%U2d7PeLu)q(Xc;1Fq;VsT=lP&|1e|% z+Zx+j2$_1r__FfCq$I9QPI9Nf+A1OL?}q7e)7rgH34xMc!N;6!3S$NCI+SI^-afohAdUf(GZs zn0`w>fEH?~$=n!Ypg__O%QOgP5q%A;3QJpo&Z{_>I%upjfHD&%eE`vKpat;PIe)r? z;kH}^&>qrBv(TVD3ng&(G!@Zp-*rWtutDzum8YpXn3_373HFY~YY@+H z(DIzz=!Y5}A(4Q~aFFS)o*NPGpV{z-f=JtUpfNiG;&>+mg2|Na);0w;rbP{D2n6c{jn2WCRFzRt)+L_5ypJV4xqk;3((#ccDajU{(LQFC1h*D5dJ1m|QGT_b0 zHE_-(df+#1m2wKq>Wb7ZOgUg`n{B3v{5(K;a}`gC+vKhcay8QwBX zQ3qr$S{uv^8MjKd_YmPbZL`eHCvhldjZjE+4&^x+y_CYsYLTX9%*_2m;qsj~SU}sZS zN#n9Grb1UO9$Q{p|Ic4}yOk5K6cc=^vp)*(CM@qZ!G2V$u69QJTLJQLdqqgLTD?(s n(&TZrU>v)f*L4z17#9`x9KBHUNtvecMQP!%c)Qx`o` literal 0 HcmV?d00001 diff --git a/res/ui/icons/puzzle_upvotes.png b/res/ui/icons/puzzle_upvotes.png index cf6ba21237e27ebb758234c429a9ef502f386349..685d4bd709296c15b7764f78b71b619a44d650d9 100644 GIT binary patch literal 2547 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 DWGIWY literal 3035 zcmZ{mc|6o#7stO8B5Np37(@1DY{g7tHQi9(@gdj5O#JjL}T${|$b2 z=J;#BmdM0$oB_%J0G<&}9=NbE*Ai}KXcPcM$^byzedZ}aMz(`^PP z2km-ZdS3K0e3&hhk)yU~sK$Rf%u)YqNqPDc|C-#y1*KEcNa^sx-wEOy+#|dv*2Rmq z_X*p-5FLwz#^L=o{RVYRIA&LM7h*I+z#fA@1`MUaKYt)uJ(ms*?(ug`?Y-}1QxjsT zEYK0S2Xl!1#A^~L(SpH1k`WC{YnYMli#?*ckEC`^&7R|~D5JVGe`GgO6Sh^Gr+R!t zC^2xcKXDu!$Q3tP>h0s~u0osi);o91`ncCcbwFuOsm&@X+J@{S0bzYsWiI%)bu)vr zlq4RnhG7x&QVA zwEQ{FtNz7Pu}bSozL|U2{@&t;=?$(u4=)l9B;(@pwU!eLwWGH^i%=#CtOnlve zACBu?Bfq+f>8y6fbl_M0C}j>{Fv0e-HLMXwcLb2BslNDbz^W&y)KtyPZ21j|RPmX5 zJv=RqQMA0+u{byUg33}gT|MpXWMs5|EeP^^($0<^OyLuqS!H{ZxghdzsWm)M=uCi~ z{Gc5J*GcRJ$4t)Mjodothm$Rv^>}9wbuwcgs;C{#p~F@5asK|>h?^hzwrFn#ZglC+ ziJifTT-~a7a>L^-pt}P+5+gcHZ?njW3Jr~-xF1GRCNAyaZqx!F`YK$vO0!BcWT?SqUB1WF)la4E1g@-=Vn#I0xV`EE>f#e`0+&P=rb|f% zrM&EOazmkPKXIbOB=Cc3rdzT}u&chNq=+wC^D>o`4U#NYl##x3Os;DM}{(o8l(zbS-@ zKR=z+DS9LH6EDwl|F`U@$hE^AJPal*ys}}k$Z|iq!c_!YpRL%Q5#c~s?Z_aI{+X(N z(E7Cy=*;WPqm$YBE*AFP4(=o9mGHwqT*+^fOE*_K(RsqdQ)@zFKm;+fm9(5i+Z-p7 zV_9gBNCEt8CaTQ$(=h`HKptaU|v35Q3m ze^cvhKP!)6Fsz>2lH8i0@>Ek#H;XDg^3X6_YVYl3*sxKVJ8{xpTmPCB@sy2;cUbyO zmOoPgJhk2TOC&pW&A%9FB_;LTIfmwtX;iUV{U%4Qy+Hn&x|oMWOHLBR=Q{}#>Fc*h z$1)vfGbT^Qt}jBzs;a?!`id;Pd+Qn`dWPef@a}MjR*&Sp;LC1n^e3q(&GjKhfKyXdZd2CW#u~zdhq7{AZjAVKg{)LgIjbRltI3H`Vk-Wp9$p0(5!ZDF6xCi$(qBWA zitPjGUnQRG|F~ zpLjYiR7VL1B8f;NU@nFhiZ>=#*d^3+xvGOwdV56^F*5>!-QXb5rti@y>F1tscBzf| z)&z)V!o{|OpyiYUI*0nT#pHLZ!NAuuDM3ZJw5&Y&adkhjXS=DtUz^Mzk-{%OpW0Sv zo@=4DPs!Y2hs7iGbiLnemp#cqU3=d6OjyzDDnYzOzQsyEI?c^kfhHxDEE?kW-Sx+> zmUa|bk5x$kd}T-WW)Vg~IQ9-25qL-qwWt-0pd3Ns8)TtaaxI1-nU6ZQi~GU121e+x zX+UixWD_QV)4N}BuadDe3 zb0v9a=ZVJ@vcuyM7>emlo|{Lj8stoldu5O3_h+q+=2HE{luN_q4IbANx9Aop_-xbq zlW#8!WwHO8k&Uf+3|G3+nlBg;7}EpSmv-dVUO_d)y%vy7~G`aqH6*ciIs z*8Ol9aS+KS zroh)LRkgG%`jd(3hRUBNq67qL#R4Z5LVvMK{7dm>{$g!Wi}f19Xfs#IgcbEVFU=op zX$kESXmWFur9jWVx%u2>-|`mpMtk<4yLz6bOa>E .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: