diff --git a/res/ui/building_tutorials/constant_producer.png b/res/ui/building_tutorials/constant_producer.png index b0d15387..8af4da33 100644 Binary files a/res/ui/building_tutorials/constant_producer.png and b/res/ui/building_tutorials/constant_producer.png differ diff --git a/res/ui/building_tutorials/goal_acceptor.png b/res/ui/building_tutorials/goal_acceptor.png index b0d15387..054783b6 100644 Binary files a/res/ui/building_tutorials/goal_acceptor.png and b/res/ui/building_tutorials/goal_acceptor.png differ diff --git a/res/ui/icons/puzzle_complete_indicator_inverse.png b/res/ui/icons/puzzle_complete_indicator_inverse.png new file mode 100644 index 00000000..f3946efc Binary files /dev/null and b/res/ui/icons/puzzle_complete_indicator_inverse.png differ diff --git a/res/ui/puzzle_dlc_logo_inverse.png b/res/ui/puzzle_dlc_logo_inverse.png new file mode 100644 index 00000000..4709f5c4 Binary files /dev/null and b/res/ui/puzzle_dlc_logo_inverse.png differ diff --git a/res_raw/sprites/blueprints/goal_acceptor.png b/res_raw/sprites/blueprints/goal_acceptor.png index bb67385b..58097279 100644 Binary files a/res_raw/sprites/blueprints/goal_acceptor.png and b/res_raw/sprites/blueprints/goal_acceptor.png differ diff --git a/src/css/ingame_hud/puzzle_back_to_menu.scss b/src/css/ingame_hud/puzzle_back_to_menu.scss index 07c15418..564b592e 100644 --- a/src/css/ingame_hud/puzzle_back_to_menu.scss +++ b/src/css/ingame_hud/puzzle_back_to_menu.scss @@ -22,6 +22,8 @@ @include S(width, 30px); @include S(height, 30px); + @include DarkThemeInvert; + opacity: 1; &:hover { opacity: 0.9 !important; diff --git a/src/css/ingame_hud/puzzle_complete_notification.scss b/src/css/ingame_hud/puzzle_complete_notification.scss index 59a2be21..43cdc776 100644 --- a/src/css/ingame_hud/puzzle_complete_notification.scss +++ b/src/css/ingame_hud/puzzle_complete_notification.scss @@ -60,7 +60,7 @@ } } - .contents { + > .contents { @include S(width, 400px); @include S(height, 170px); @include InlineAnimation(0.5s ease-in-out) { @@ -94,7 +94,6 @@ > 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); @@ -136,16 +135,31 @@ display: flex; align-items: center; - > canvas { - @include S(margin, 0, 5px); - @include S(width, 30px); - @include S(height, 30px); + > .rating { @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; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + @include S(margin, 0, 5px); + @include S(width, 65px); + @include S(height, 50px); + + > canvas { + @include S(width, 30px); + @include S(height, 30px); + transition: opacity 0.12s ease-in-out, background-color 0.12s ease-in-out, + box-shadow 0.12s ease-in-out; + } + + > .description { + @include SuperSmallText; + white-space: nowrap; + } + &.active { background-color: #151118 !important; box-shadow: 0 0 0 D(2px) #151118; @@ -154,34 +168,33 @@ &: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; - } } } } } + > .actions { + position: absolute; + @include S(bottom, 40px); + + display: grid; + @include S(grid-gap, 15px); + grid-auto-flow: column; + + button { + @include SuperSmallText; + } + .report { + background-color: $accentColorDark; + } + } + button.close { border: 0; position: relative; @include S(margin-top, 30px); + background: $colorGreenBright; + @include S(padding, 10px, 40px); &:not(.visible) { opacity: 0; diff --git a/src/css/ingame_hud/puzzle_dlc_logo.scss b/src/css/ingame_hud/puzzle_dlc_logo.scss index 72d87e51..684cead4 100644 --- a/src/css/ingame_hud/puzzle_dlc_logo.scss +++ b/src/css/ingame_hud/puzzle_dlc_logo.scss @@ -9,4 +9,11 @@ /* @load-async */ background: uiResource("puzzle_dlc_logo.png") center center / contain no-repeat; } + + @include DarkThemeOverride { + & { + /* @load-async */ + background: uiResource("puzzle_dlc_logo_inverse.png") center center / contain no-repeat; + } + } } diff --git a/src/css/ingame_hud/puzzle_editor_controls.scss b/src/css/ingame_hud/puzzle_editor_controls.scss index 85acba6a..ada80390 100644 --- a/src/css/ingame_hud/puzzle_editor_controls.scss +++ b/src/css/ingame_hud/puzzle_editor_controls.scss @@ -16,6 +16,8 @@ font-weight: bold; } } + + @include DarkThemeInvert; } #ingame_HUD_PuzzleEditorTitle { @@ -27,4 +29,6 @@ text-transform: uppercase; @include Heading; text-align: center; + + @include DarkThemeInvert; } diff --git a/src/css/ingame_hud/puzzle_editor_review.scss b/src/css/ingame_hud/puzzle_editor_review.scss index 73f87d7d..523d8025 100644 --- a/src/css/ingame_hud/puzzle_editor_review.scss +++ b/src/css/ingame_hud/puzzle_editor_review.scss @@ -23,6 +23,8 @@ @include S(padding-right, 25px); opacity: 1; + @include DarkThemeInvert; + &:hover { opacity: 0.9 !important; } diff --git a/src/css/ingame_hud/puzzle_editor_settings.scss b/src/css/ingame_hud/puzzle_editor_settings.scss index 6bea0a23..70d16123 100644 --- a/src/css/ingame_hud/puzzle_editor_settings.scss +++ b/src/css/ingame_hud/puzzle_editor_settings.scss @@ -44,9 +44,19 @@ } } - > .buttons > button.trim { - @include S(margin-top, 10px); - @include SuperSmallText; + > .buttons { + > .buttonBar { + display: flex; + align-items: center; + @include S(margin-top, 10px); + > button { + @include S(margin-right, 4px); + @include SuperSmallText; + &:last-child { + margin-right: 0; + } + } + } } } } diff --git a/src/css/ingame_hud/puzzle_play_metadata.scss b/src/css/ingame_hud/puzzle_play_metadata.scss index 152b8871..50403347 100644 --- a/src/css/ingame_hud/puzzle_play_metadata.scss +++ b/src/css/ingame_hud/puzzle_play_metadata.scss @@ -6,14 +6,94 @@ display: flex; flex-direction: column; - @include SuperDuperSmallText; @include S(width, 200px); - > span { - @include S(margin-bottom, 10px); + > .info { + display: flex; + flex-direction: column; + @include SuperSmallText; + @include S(margin-bottom, 5px); - strong { + > label { + text-transform: uppercase; + @include SuperSmallText; + } + > span { + display: flex; + @include SuperSmallText; + } + } + + > .plays { + display: flex; + align-items: center; + justify-self: end; + align-self: end; + flex-direction: row; + + @include DarkThemeInvert; + opacity: 0.4; + + > .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; + } + } + } + + > .key { + button { + @include S(margin-top, 2px); + } + } + + button { + @include SuperSmallText; + align-self: start; + @include S(min-width, 50px); + + &.report { + background-color: $accentColorDark; + @include SuperDuperSmallText; + } + } + + > .buttons { + display: flex; + flex-direction: column; + + > button { + @include S(margin-bottom, 4px); } } } diff --git a/src/css/ingame_hud/puzzle_play_settings.scss b/src/css/ingame_hud/puzzle_play_settings.scss new file mode 100644 index 00000000..13e25c61 --- /dev/null +++ b/src/css/ingame_hud/puzzle_play_settings.scss @@ -0,0 +1,23 @@ +#ingame_HUD_PuzzlePlaySettings { + position: absolute; + background: $ingameHudBg; + @include S(padding, 10px); + @include S(bottom, 60px); + @include S(left, 10px); + + @include SuperSmallText; + color: #eee; + display: flex; + flex-direction: column; + @include S(border-radius, $globalBorderRadius); + + > .section { + display: grid; + @include S(grid-gap, 10px); + grid-auto-flow: row; + + > button { + @include SuperSmallText; + } + } +} diff --git a/src/css/main.scss b/src/css/main.scss index 02679948..1bd82828 100644 --- a/src/css/main.scss +++ b/src/css/main.scss @@ -61,6 +61,7 @@ @import "ingame_hud/puzzle_dlc_logo"; @import "ingame_hud/puzzle_editor_controls"; @import "ingame_hud/puzzle_editor_settings"; +@import "ingame_hud/puzzle_play_settings"; @import "ingame_hud/puzzle_play_metadata"; @import "ingame_hud/puzzle_complete_notification"; @@ -85,6 +86,7 @@ ingame_HUD_PuzzleEditorReview, ingame_HUD_PuzzleEditorControls, ingame_HUD_PuzzleEditorTitle, ingame_HUD_PuzzleEditorSettings, +ingame_HUD_PuzzlePlaySettings, ingame_HUD_PuzzlePlayMetadata, ingame_HUD_PuzzlePlayTitle, ingame_HUD_Notifications, diff --git a/src/css/states/main_menu.scss b/src/css/states/main_menu.scss index 5dbcf0e9..f46230a2 100644 --- a/src/css/states/main_menu.scss +++ b/src/css/states/main_menu.scss @@ -183,7 +183,7 @@ .updateLabel { position: absolute; transform: translateX(50%) rotate(-5deg); - color: #3291e9; + color: #ff590b; @include Heading; font-weight: bold; @include S(right, 40px); diff --git a/src/css/states/puzzle_menu.scss b/src/css/states/puzzle_menu.scss index e6be2dda..18fb561a 100644 --- a/src/css/states/puzzle_menu.scss +++ b/src/css/states/puzzle_menu.scss @@ -7,6 +7,10 @@ > h1 { justify-self: start; } + + .createPuzzle { + background-color: $colorGreenBright; + } } > .container { @@ -42,12 +46,22 @@ color: #fff; cursor: default; } + + @include DarkThemeOverride { + background: $accentColorDark; + color: #bbbbc4; + + &.active { + background: $colorBlueBright; + color: #fff; + } + } } } > .puzzles { display: grid; - grid-template-columns: repeat(auto-fit, minmax(D(150px), 1fr)); + grid-template-columns: repeat(auto-fit, D(150px)); @include S(grid-auto-rows, 120px); @include S(grid-gap, 3px); @include S(margin-top, 10px); @@ -55,6 +69,7 @@ @include S(height, 360px); overflow-y: scroll; pointer-events: all; + position: relative; > .puzzle { width: 100%; @@ -72,6 +87,10 @@ cursor: pointer; position: relative; + @include DarkThemeOverride { + background: rgba(0, 0, 10, 0.2); + } + @include InlineAnimation(0.12s ease-in-out) { 0% { opacity: 0; @@ -86,9 +105,12 @@ } > .title { - grid-column: 1 / 2; - grid-row: 1/ 2; + grid-column: 1 / 3; + grid-row: 1 / 2; @include PlainText; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; } > .icon { @@ -122,12 +144,14 @@ display: flex; align-items: center; justify-self: end; + justify-content: center; align-self: end; + @include DarkThemeInvert; + > .downloads { @include SuperSmallText; color: #000; - align-self: start; justify-self: start; font-weight: bold; @include S(margin-right, 10px); @@ -149,7 +173,6 @@ align-items: center; justify-content: center; color: #000; - align-self: start; justify-self: start; font-weight: bold; @include S(padding-left, 14px); @@ -162,6 +185,14 @@ )} #{D(8px)} no-repeat; } } + + > .difficulty { + @include S(margin-top, 1px); + @include S(margin-right, 7px); + display: inline-flex; + align-items: center; + justify-content: center; + } } &.completed { @@ -189,17 +220,27 @@ contain no-repeat; } } + @include DarkThemeOverride { + &::after { + /* @load-async */ + background: uiResource("icons/puzzle_complete_indicator_inverse.png") center + center / contain no-repeat; + } + } } } > .loader, > .empty { - grid-column: 1 / -1; - grid-row: 1 / 3; display: flex; align-items: center; color: $accentColorDark; justify-content: center; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; } } } diff --git a/src/js/changelog.js b/src/js/changelog.js index cfdcbe5b..5b987c87 100644 --- a/src/js/changelog.js +++ b/src/js/changelog.js @@ -2,7 +2,7 @@ export const CHANGELOG = [ { version: "1.4.0", date: "UNRELEASED", - entries: ["Added puzzle mode"], + entries: ["Added puzzle mode", "Belts in blueprints should now always paste correctly"], }, { version: "1.3.1", diff --git a/src/js/core/config.js b/src/js/core/config.js index 0f074949..cb70528b 100644 --- a/src/js/core/config.js +++ b/src/js/core/config.js @@ -76,6 +76,7 @@ export const globalConfig = { puzzleModeSpeed: 3, puzzleMinBoundsSize: 2, puzzleMaxBoundsSize: 20, + puzzleValidationDurationSeconds: 30, buildingSpeeds: { cutter: 1 / 4, @@ -99,7 +100,7 @@ export const globalConfig = { gameSpeed: 1, warmupTimeSecondsFast: 0.5, - warmupTimeSecondsRegular: 3, + warmupTimeSecondsRegular: 1.5, smoothing: { smoothMainCanvas: smoothCanvas && true, diff --git a/src/js/game/belt_path.js b/src/js/game/belt_path.js index f3316c37..c3b43dae 100644 --- a/src/js/game/belt_path.js +++ b/src/js/game/belt_path.js @@ -108,6 +108,14 @@ export class BeltPath extends BasicSerializableObject { } } + /** + * Clears all items + */ + clearAllItems() { + this.items = []; + this.spacingToFirstItem = this.totalLength; + } + /** * Returns whether this path can accept a new item * @returns {boolean} diff --git a/src/js/game/blueprint.js b/src/js/game/blueprint.js index 3aaef831..3e7cdaa6 100644 --- a/src/js/game/blueprint.js +++ b/src/js/game/blueprint.js @@ -149,29 +149,31 @@ export class Blueprint { */ tryPlace(root, tile) { return root.logic.performBulkOperation(() => { - let count = 0; - for (let i = 0; i < this.entities.length; ++i) { - const entity = this.entities[i]; - if (!root.logic.checkCanPlaceEntity(entity, tile)) { - continue; + return root.logic.performImmutableOperation(() => { + let count = 0; + for (let i = 0; i < this.entities.length; ++i) { + const entity = this.entities[i]; + if (!root.logic.checkCanPlaceEntity(entity, tile)) { + continue; + } + + const clone = entity.clone(); + clone.components.StaticMapEntity.origin.addInplace(tile); + root.logic.freeEntityAreaBeforeBuild(clone); + root.map.placeStaticEntity(clone); + root.entityMgr.registerEntity(clone); + count++; } - const clone = entity.clone(); - clone.components.StaticMapEntity.origin.addInplace(tile); - root.logic.freeEntityAreaBeforeBuild(clone); - root.map.placeStaticEntity(clone); - root.entityMgr.registerEntity(clone); - count++; - } + root.signals.bulkAchievementCheck.dispatch( + ACHIEVEMENTS.placeBlueprint, + count, + ACHIEVEMENTS.placeBp1000, + count + ); - root.signals.bulkAchievementCheck.dispatch( - ACHIEVEMENTS.placeBlueprint, - count, - ACHIEVEMENTS.placeBp1000, - count - ); - - return count !== 0; + return count !== 0; + }); }); } } diff --git a/src/js/game/buildings/balancer.js b/src/js/game/buildings/balancer.js index 7b31e837..7d6f3d0b 100644 --- a/src/js/game/buildings/balancer.js +++ b/src/js/game/buildings/balancer.js @@ -102,18 +102,6 @@ export class MetaBalancerBuilding extends MetaBuilding { available.push(enumBalancerVariants.splitter, enumBalancerVariants.splitterInverse); } - if (root.gameMode.getIsDeterministic()) { - // mergers are not deterministic - available = available.filter( - v => - ![ - enumBalancerVariants.merger, - enumBalancerVariants.mergerInverse, - defaultBuildingVariant, - ].includes(v) - ); - } - return available; } diff --git a/src/js/game/buildings/painter.js b/src/js/game/buildings/painter.js index a32bec95..e7a0b72d 100644 --- a/src/js/game/buildings/painter.js +++ b/src/js/game/buildings/painter.js @@ -74,7 +74,10 @@ export class MetaPainterBuilding extends MetaBuilding { if (root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_painter_double)) { variants.push(enumPainterVariants.double); } - if (root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_wires_painter_and_levers)) { + if ( + root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_wires_painter_and_levers) && + root.gameMode.getSupportsWires() + ) { variants.push(enumPainterVariants.quad); } return variants; diff --git a/src/js/game/component.js b/src/js/game/component.js index 46b1b545..cff14d62 100644 --- a/src/js/game/component.js +++ b/src/js/game/component.js @@ -23,6 +23,11 @@ export class Component extends BasicSerializableObject { */ copyAdditionalStateTo(otherComponent) {} + /** + * Clears all items and state + */ + clear() {} + /* dev:start */ /** diff --git a/src/js/game/components/belt_reader.js b/src/js/game/components/belt_reader.js index c7f05511..5a33db29 100644 --- a/src/js/game/components/belt_reader.js +++ b/src/js/game/components/belt_reader.js @@ -30,6 +30,10 @@ export class BeltReaderComponent extends Component { this.type = type; + this.clear(); + } + + clear() { /** * Which items went through the reader, we only store the time * @type {Array} diff --git a/src/js/game/components/filter.js b/src/js/game/components/filter.js index cffee969..8a22a076 100644 --- a/src/js/game/components/filter.js +++ b/src/js/game/components/filter.js @@ -40,6 +40,10 @@ export class FilterComponent extends Component { constructor() { super(); + this.clear(); + } + + clear() { /** * Items in queue to leave through * @type {Array} diff --git a/src/js/game/components/goal_acceptor.js b/src/js/game/components/goal_acceptor.js index e0e53914..87c55501 100644 --- a/src/js/game/components/goal_acceptor.js +++ b/src/js/game/components/goal_acceptor.js @@ -26,6 +26,10 @@ export class GoalAcceptorComponent extends Component { /** @type {BaseItem | undefined} */ this.item = item; + this.clear(); + } + + clear() { // the last items we delivered /** @type {{ item: BaseItem; time: number; }[]} */ this.deliveryHistory = []; diff --git a/src/js/game/components/item_acceptor.js b/src/js/game/components/item_acceptor.js index b6a7e719..354f9024 100644 --- a/src/js/game/components/item_acceptor.js +++ b/src/js/game/components/item_acceptor.js @@ -36,6 +36,11 @@ export class ItemAcceptorComponent extends Component { constructor({ slots = [] }) { super(); + this.setSlots(slots); + this.clear(); + } + + clear() { /** * Fixes belt animations * @type {Array<{ @@ -46,8 +51,6 @@ export class ItemAcceptorComponent extends Component { * }>} */ this.itemConsumptionAnimations = []; - - this.setSlots(slots); } /** diff --git a/src/js/game/components/item_ejector.js b/src/js/game/components/item_ejector.js index 47253b4b..719925af 100644 --- a/src/js/game/components/item_ejector.js +++ b/src/js/game/components/item_ejector.js @@ -48,6 +48,13 @@ export class ItemEjectorComponent extends Component { this.renderFloatingItems = renderFloatingItems; } + clear() { + for (const slot of this.slots) { + slot.item = null; + slot.progress = 0; + } + } + /** * @param {Array<{pos: Vector, direction: enumDirection }>} slots The slots to eject on */ diff --git a/src/js/game/components/item_processor.js b/src/js/game/components/item_processor.js index 166dd49c..4c0e1835 100644 --- a/src/js/game/components/item_processor.js +++ b/src/js/game/components/item_processor.js @@ -64,10 +64,8 @@ export class ItemProcessorComponent extends Component { }) { super(); - // Which slot to emit next, this is only a preference and if it can't emit - // it will take the other one. Some machines ignore this (e.g. the balancer) to make - // sure the outputs always match - this.nextOutputSlot = 0; + // How many inputs we need for one charge + this.inputsPerCharge = inputsPerCharge; // Type of the processor this.type = processorType; @@ -75,8 +73,14 @@ export class ItemProcessorComponent extends Component { // Type of processing requirement this.processingRequirement = processingRequirement; - // How many inputs we need for one charge - this.inputsPerCharge = inputsPerCharge; + this.clear(); + } + + clear() { + // Which slot to emit next, this is only a preference and if it can't emit + // it will take the other one. Some machines ignore this (e.g. the balancer) to make + // sure the outputs always match + this.nextOutputSlot = 0; /** * Our current inputs diff --git a/src/js/game/components/miner.js b/src/js/game/components/miner.js index ab87760f..5321ae11 100644 --- a/src/js/game/components/miner.js +++ b/src/js/game/components/miner.js @@ -24,13 +24,6 @@ export class MinerComponent extends Component { this.lastMiningTime = 0; this.chainable = chainable; - /** - * Stores items from other miners which were chained to this - * miner. - * @type {Array} - */ - this.itemChainBuffer = []; - /** * @type {BaseItem} */ @@ -42,6 +35,17 @@ export class MinerComponent extends Component { * @type {Entity|null|false} */ this.cachedChainedMiner = null; + + this.clear(); + } + + clear() { + /** + * Stores items from other miners which were chained to this + * miner. + * @type {Array} + */ + this.itemChainBuffer = []; } /** diff --git a/src/js/game/components/static_map_entity.js b/src/js/game/components/static_map_entity.js index 7e2f5314..c76a298e 100644 --- a/src/js/game/components/static_map_entity.js +++ b/src/js/game/components/static_map_entity.js @@ -71,6 +71,14 @@ export class StaticMapEntityComponent extends Component { return getBuildingDataFromCode(this.code).variant; } + /** + * Returns the buildings rotation variant + * @returns {number} + */ + getRotationVariant() { + return getBuildingDataFromCode(this.code).rotationVariant; + } + /** * Copy the current state to another component * @param {Component} otherComponent diff --git a/src/js/game/components/underground_belt.js b/src/js/game/components/underground_belt.js index a3e883ec..2b744edd 100644 --- a/src/js/game/components/underground_belt.js +++ b/src/js/game/components/underground_belt.js @@ -41,6 +41,17 @@ export class UndergroundBeltComponent extends Component { this.mode = mode; this.tier = tier; + /** + * The linked entity, used to speed up performance. This contains either + * the entrance or exit depending on the tunnel type + * @type {LinkedUndergroundBelt} + */ + this.cachedLinkedEntity = null; + + this.clear(); + } + + clear() { /** @type {Array<{ item: BaseItem, progress: number }>} */ this.consumptionAnimations = []; @@ -51,13 +62,6 @@ export class UndergroundBeltComponent extends Component { * @type {Array<[BaseItem, number]>} Format is [Item, ingame time to eject the item] */ this.pendingItems = []; - - /** - * The linked entity, used to speed up performance. This contains either - * the entrance or exit depending on the tunnel type - * @type {LinkedUndergroundBelt} - */ - this.cachedLinkedEntity = null; } /** diff --git a/src/js/game/game_mode.js b/src/js/game/game_mode.js index b4e53ba9..d515ca72 100644 --- a/src/js/game/game_mode.js +++ b/src/js/game/game_mode.js @@ -170,11 +170,6 @@ export class GameMode extends BasicSerializableObject { return true; } - /** @returns {boolean} */ - getIsDeterministic() { - return false; - } - /** @returns {boolean} */ getIsEditor() { return false; diff --git a/src/js/game/hud/parts/puzzle_complete_notification.js b/src/js/game/hud/parts/puzzle_complete_notification.js index bfc89dc1..b9b523de 100644 --- a/src/js/game/hud/parts/puzzle_complete_notification.js +++ b/src/js/game/hud/parts/puzzle_complete_notification.js @@ -1,13 +1,26 @@ +/* typehints:start */ +import { PuzzlePlayGameMode } from "../../modes/puzzle_play"; +/* typehints:end */ + 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"; +import { ShapeItem } from "../../items/shape_item"; +import { ShapeDefinition } from "../../shape_definition"; + +export const PUZZLE_RATINGS = [ + new ColorItem(enumColors.red), + new ShapeItem(ShapeDefinition.fromShortKey("CuCuCuCu")), + new ShapeItem(ShapeDefinition.fromShortKey("WwWwWwWw")), + new ShapeItem(ShapeDefinition.fromShortKey(finalGameShape)), + new ShapeItem(ShapeDefinition.fromShortKey(rocketShape)), +]; export class HUDPuzzleCompleteNotification extends BaseHUDPart { initialize() { @@ -33,15 +46,28 @@ export class HUDPuzzleCompleteNotification extends BaseHUDPart { this.elemTitle = makeDiv(dialog, null, ["title"], T.ingame.puzzleCompletion.title); this.elemContents = makeDiv(dialog, null, ["contents"]); + this.elemActions = makeDiv(dialog, null, ["actions"]); + + const reportBtn = document.createElement("button"); + reportBtn.classList.add("styledButton", "report"); + reportBtn.innerHTML = T.ingame.puzzleEditorSettings.report; + this.elemActions.appendChild(reportBtn); + this.trackClicks(reportBtn, this.report); + + const shareBtn = document.createElement("button"); + shareBtn.classList.add("styledButton", "share"); + shareBtn.innerHTML = T.ingame.puzzleEditorSettings.share; + this.elemActions.appendChild(shareBtn); + this.trackClicks(shareBtn, this.share); const stepLike = makeDiv(this.elemContents, null, ["step", "stepLike"]); makeDiv(stepLike, null, ["title"], T.ingame.puzzleCompletion.titleLike); - const buttons = makeDiv(stepLike, null, ["buttons"]); + const likeButtons = makeDiv(stepLike, null, ["buttons"]); this.buttonLikeYes = document.createElement("button"); this.buttonLikeYes.classList.add("liked-yes"); - buttons.appendChild(this.buttonLikeYes); + likeButtons.appendChild(this.buttonLikeYes); this.trackClicks(this.buttonLikeYes, () => { this.selectionLiked = true; this.updateState(); @@ -49,7 +75,7 @@ export class HUDPuzzleCompleteNotification extends BaseHUDPart { this.buttonLikeNo = document.createElement("button"); this.buttonLikeNo.classList.add("liked-no"); - buttons.appendChild(this.buttonLikeNo); + likeButtons.appendChild(this.buttonLikeNo); this.trackClicks(this.buttonLikeNo, () => { this.selectionLiked = false; this.updateState(); @@ -59,30 +85,33 @@ export class HUDPuzzleCompleteNotification extends BaseHUDPart { 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 = []; + this.difficultyElements = []; let index = 0; - for (const shape of items) { + for (const shape of PUZZLE_RATINGS) { const localIndex = index; + + const elem = document.createElement("div"); + elem.classList.add("rating"); + shapeContainer.appendChild(elem); + 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, () => { + elem.appendChild(canvas); + + this.trackClicks(elem, () => { this.selectionDifficulty = localIndex; this.updateState(); }); - this.difficultyCanvases.push(canvas); + this.difficultyElements.push(elem); + + const desc = document.createElement("div"); + desc.classList.add("description"); + desc.innerText = T.ingame.puzzleCompletion.difficulties[localIndex]; + elem.appendChild(desc); ++index; } @@ -94,10 +123,20 @@ export class HUDPuzzleCompleteNotification extends BaseHUDPart { this.trackClicks(this.btnClose, this.close); } + share() { + const mode = /** @type {PuzzlePlayGameMode} */ (this.root.gameMode); + mode.sharePuzzle(); + } + + report() { + const mode = /** @type {PuzzlePlayGameMode} */ (this.root.gameMode); + mode.reportPuzzle(); + } + updateState() { this.buttonLikeYes.classList.toggle("active", this.selectionLiked === true); this.buttonLikeNo.classList.toggle("active", this.selectionLiked === false); - this.difficultyCanvases.forEach((canvas, index) => + this.difficultyElements.forEach((canvas, index) => canvas.classList.toggle("active", index === this.selectionDifficulty) ); diff --git a/src/js/game/hud/parts/puzzle_editor_review.js b/src/js/game/hud/parts/puzzle_editor_review.js index e3f74920..f36b4ae4 100644 --- a/src/js/game/hud/parts/puzzle_editor_review.js +++ b/src/js/game/hud/parts/puzzle_editor_review.js @@ -19,9 +19,6 @@ const logger = createLogger("puzzle-review"); export class HUDPuzzleEditorReview extends BaseHUDPart { constructor(root) { super(root); - - this.validationEndsIn = null; - this.callOnceValidationEnded = null; } createElements(parent) { @@ -45,9 +42,37 @@ export class HUDPuzzleEditorReview extends BaseHUDPart { return; } - const closeLoading = this.root.hud.parts.dialogs.showLoadingDialog(T.puzzleMenu.validtingPuzzle); - this.validationEndsIn = this.root.time.now() + globalConfig.goalAcceptorMinimumDurationSeconds; - this.callOnceValidationEnded = () => { + const closeLoading = this.root.hud.parts.dialogs.showLoadingDialog(T.puzzleMenu.validatingPuzzle); + + // Wait a bit, so the user sees the puzzle actually got validated + setTimeout(() => { + // Manually simulate ticks + this.root.logic.clearAllBeltsAndItems(); + + const ticks = + this.root.gameMode.getFixedTickrate() * globalConfig.puzzleValidationDurationSeconds; + const deltaMs = this.root.dynamicTickrate.deltaMs; + logger.log("Simulating", ticks, "ticks, start=", this.root.time.now().toFixed(1)); + const now = performance.now(); + for (let i = 0; i < ticks; ++i) { + if (i % Math.round((ticks - 1) / 10) === 0) { + console.log("Ticking", Math.round((i / ticks) * 100) + "%"); + } + + // Perform logic ticks + this.root.time.performTicks(deltaMs, this.root.gameState.core.boundInternalTick); + } + const duration = performance.now() - now; + logger.log( + "Simulated", + ticks, + "ticks, end=", + this.root.time.now().toFixed(1), + "duration=", + duration.toFixed(2), + "ms" + ); + closeLoading(); const validationError = this.validatePuzzle(); if (validationError) { @@ -55,7 +80,7 @@ export class HUDPuzzleEditorReview extends BaseHUDPart { return; } this.startSubmit(); - }; + }, 750); } startSubmit(title = "", shortKey = "") { @@ -102,7 +127,7 @@ export class HUDPuzzleEditorReview extends BaseHUDPart { title: T.dialogs.submitPuzzle.title, desc: "", formElements: [nameInput, itemInput, shapeKeyInput], - buttons: ["cancel:bad:escape", "ok:good:enter"], + buttons: ["ok:good:enter"], }); itemInput.valueChosen.add(value => { @@ -154,18 +179,6 @@ export class HUDPuzzleEditorReview extends BaseHUDPart { ); } - update() { - if ( - this.validationEndsIn && - this.validationEndsIn < this.root.time.now() && - this.callOnceValidationEnded - ) { - const callMethod = this.callOnceValidationEnded; - this.callOnceValidationEnded = null; - callMethod(); - } - } - validatePuzzle() { // Check there is at least one constant producer and goal acceptor const producers = this.root.entityMgr.getAllWithComponent(ConstantSignalComponent); diff --git a/src/js/game/hud/parts/puzzle_editor_settings.js b/src/js/game/hud/parts/puzzle_editor_settings.js index 0fb41dde..cf283a9b 100644 --- a/src/js/game/hud/parts/puzzle_editor_settings.js +++ b/src/js/game/hud/parts/puzzle_editor_settings.js @@ -41,7 +41,10 @@ export class HUDPuzzleEditorSettings extends BaseHUDPart { - +
+ + +
` ); @@ -50,20 +53,82 @@ export class HUDPuzzleEditorSettings extends BaseHUDPart { bind(".zoneHeight .minus", () => this.modifyZone(0, -1)); bind(".zoneHeight .plus", () => this.modifyZone(0, 1)); bind("button.trim", this.trim); + bind("button.clear", this.clear); } } - trim() { - const mode = /** @type {PuzzleGameMode} */ (this.root.gameMode); + clear() { + this.root.logic.clearAllBeltsAndItems(); + } - let w = mode.zoneWidth; - let h = mode.zoneHeight; - if (this.anyBuildingOutsideZone(w, h)) { - logger.error("Trim: Zone is already too small"); + trim() { + // Now, find the center + const buildings = this.root.entityMgr.entities.slice(); + + if (buildings.length === 0) { + // nothing to do return; } - logger.log("Zone trim: Starts at", w, h); + let minRect = null; + + for (const building of buildings) { + const staticComp = building.components.StaticMapEntity; + const bounds = staticComp.getTileSpaceBounds(); + + if (!minRect) { + minRect = bounds; + } else { + minRect = minRect.getUnion(bounds); + } + } + + const mode = /** @type {PuzzleGameMode} */ (this.root.gameMode); + const moveByInverse = minRect.getCenter().round(); + + // move buildings + if (moveByInverse.length() > 0) { + // increase area size + mode.zoneWidth = globalConfig.puzzleMaxBoundsSize; + mode.zoneHeight = globalConfig.puzzleMaxBoundsSize; + + // First, remove any items etc + this.root.logic.clearAllBeltsAndItems(); + + this.root.logic.performImmutableOperation(() => { + // 1. remove all buildings + for (const building of buildings) { + if (!this.root.logic.tryDeleteBuilding(building)) { + assertAlways(false, "Failed to remove building in trim"); + } + } + + // 2. place them again, but centered + for (const building of buildings) { + const staticComp = building.components.StaticMapEntity; + const result = this.root.logic.tryPlaceBuilding({ + origin: staticComp.origin.sub(moveByInverse), + building: staticComp.getMetaBuilding(), + originalRotation: staticComp.originalRotation, + rotation: staticComp.rotation, + rotationVariant: staticComp.getRotationVariant(), + variant: staticComp.getVariant(), + }); + if (!result) { + this.root.bulkOperationRunning = false; + assertAlways(false, "Failed to re-place building in trim"); + } + + if (building.components.ConstantSignal) { + result.components.ConstantSignal.signal = building.components.ConstantSignal.signal; + } + } + }); + } + + // 3. Actually trim + let w = mode.zoneWidth; + let h = mode.zoneHeight; while (!this.anyBuildingOutsideZone(w - 1, h)) { --w; @@ -73,12 +138,6 @@ export class HUDPuzzleEditorSettings extends BaseHUDPart { --h; } - logger.log("Zone trim: After height pass at", w, h); - if (this.anyBuildingOutsideZone(w, h)) { - logger.error("Trim: Zone is too small *after* trim"); - return; - } - mode.zoneWidth = w; mode.zoneHeight = h; this.updateZoneValues(); diff --git a/src/js/game/hud/parts/puzzle_play_metadata.js b/src/js/game/hud/parts/puzzle_play_metadata.js index 15c4c20a..1d0f6c5a 100644 --- a/src/js/game/hud/parts/puzzle_play_metadata.js +++ b/src/js/game/hud/parts/puzzle_play_metadata.js @@ -1,22 +1,76 @@ -import { makeDiv } from "../../../core/utils"; +/* typehints:start */ +import { PuzzlePlayGameMode } from "../../modes/puzzle_play"; +/* typehints:end */ + +import { formatBigNumberFull, formatSeconds, makeDiv } from "../../../core/utils"; +import { T } from "../../../translations"; import { BaseHUDPart } from "../base_hud_part"; +const copy = require("clipboard-copy"); + export class HUDPuzzlePlayMetadata extends BaseHUDPart { createElements(parent) { this.titleElement = makeDiv(parent, "ingame_HUD_PuzzlePlayTitle"); this.titleElement.innerText = "PUZZLE"; + const mode = /** @type {PuzzlePlayGameMode} */ (this.root.gameMode); + const puzzle = mode.puzzle; + this.puzzleNameElement = makeDiv(this.titleElement, null, ["name"]); - this.puzzleNameElement.innerText = "tobspr's first puzzle"; + this.puzzleNameElement.innerText = puzzle.meta.title; this.element = makeDiv(parent, "ingame_HUD_PuzzlePlayMetadata"); this.element.innerHTML = ` -
Author: tobspr
-
Plays: 12.000
- +
+ ${formatBigNumberFull(puzzle.meta.downloads)} + +
+ + +
+
+ ${puzzle.meta.shortKey} +
+
+ + ${ + puzzle.meta.difficulty + ? puzzle.meta.difficulty.toFixed(1) + : T.puzzleMenu.difficultyNotDetermined + } +
+
+ + ${ + puzzle.meta.averageTime + ? formatSeconds(puzzle.meta.averageTime) + : T.puzzleMenu.difficultyNotDetermined + } +
+ +
+ + +
`; + + this.trackClicks(this.element.querySelector("button.share"), this.share); + this.trackClicks(this.element.querySelector("button.report"), this.report); + + /** @type {HTMLElement} */ (this.element.querySelector(".author span")).innerText = + puzzle.meta.author; } initialize() {} + + share() { + const mode = /** @type {PuzzlePlayGameMode} */ (this.root.gameMode); + mode.sharePuzzle(); + } + + report() { + const mode = /** @type {PuzzlePlayGameMode} */ (this.root.gameMode); + mode.reportPuzzle(); + } } diff --git a/src/js/game/hud/parts/puzzle_play_settings.js b/src/js/game/hud/parts/puzzle_play_settings.js new file mode 100644 index 00000000..168c3de2 --- /dev/null +++ b/src/js/game/hud/parts/puzzle_play_settings.js @@ -0,0 +1,36 @@ +import { createLogger } from "../../../core/logging"; +import { makeDiv } from "../../../core/utils"; +import { T } from "../../../translations"; +import { BaseHUDPart } from "../base_hud_part"; + +const logger = createLogger("puzzle-play"); + +export class HUDPuzzlePlaySettings extends BaseHUDPart { + createElements(parent) { + this.element = makeDiv(parent, "ingame_HUD_PuzzlePlaySettings"); + + if (this.root.gameMode.getBuildableZones()) { + const bind = (selector, handler) => + this.trackClicks(this.element.querySelector(selector), handler); + makeDiv( + this.element, + null, + ["section"], + ` + + + ` + ); + + bind("button.clear", this.clear); + } + } + + clear() { + this.root.logic.clearAllBeltsAndItems(); + } + + initialize() { + this.visible = true; + } +} diff --git a/src/js/game/logic.js b/src/js/game/logic.js index bdd98eca..c3adb0a3 100644 --- a/src/js/game/logic.js +++ b/src/js/game/logic.js @@ -4,6 +4,7 @@ import { STOP_PROPAGATION } from "../core/signal"; import { round2Digits } from "../core/utils"; import { enumDirection, enumDirectionToVector, enumInvertedDirections, Vector } from "../core/vector"; import { getBuildingDataFromCode } from "./building_codes"; +import { Component } from "./component"; import { enumWireVariant } from "./components/wire"; import { Entity } from "./entity"; import { CHUNK_OVERLAY_RES } from "./map_chunk_view"; @@ -161,6 +162,27 @@ export class GameLogic { return returnValue; } + /** + * Performs a immutable operation, causing no recalculations + * @param {function} operation + */ + performImmutableOperation(operation) { + logger.warn("Running immutable operation ..."); + assert(!this.root.immutableOperationRunning, "Can not run two immutalbe operations twice"); + this.root.immutableOperationRunning = true; + const now = performance.now(); + const returnValue = operation(); + const duration = performance.now() - now; + logger.log("Done in", round2Digits(duration), "ms"); + assert( + this.root.immutableOperationRunning, + "Immutable operation = false while immutable operation was running" + ); + this.root.immutableOperationRunning = false; + this.root.signals.immutableOperationFinished.dispatch(); + return returnValue; + } + /** * Returns whether the given building can get removed * @param {Entity} building @@ -342,8 +364,6 @@ export class GameLogic { return !!overlayMatrix[localPosition.x + localPosition.y * 3]; } - g(tile, edge) {} - /** * Returns the acceptors and ejectors which affect the current tile * @param {Vector} tile @@ -425,4 +445,22 @@ export class GameLogic { } return { ejectors, acceptors }; } + + /** + * Clears all belts and items + */ + clearAllBeltsAndItems() { + // Belts + const beltPaths = this.root.systemMgr.systems.belt.beltPaths; + for (const path of beltPaths) { + path.clearAllItems(); + } + + // Acceptors + for (const entity of this.root.entityMgr.entities) { + for (const component of Object.values(entity.components)) { + /** @type {Component} */ (component).clear(); + } + } + } } diff --git a/src/js/game/modes/puzzle.js b/src/js/game/modes/puzzle.js index 348ace91..5a2fd9c8 100644 --- a/src/js/game/modes/puzzle.js +++ b/src/js/game/modes/puzzle.js @@ -84,10 +84,6 @@ export class PuzzleGameMode extends GameMode { return false; } - getIsDeterministic() { - return true; - } - getFixedTickrate() { return 300; } diff --git a/src/js/game/modes/puzzle_play.js b/src/js/game/modes/puzzle_play.js index 8d386192..da1f9dd4 100644 --- a/src/js/game/modes/puzzle_play.js +++ b/src/js/game/modes/puzzle_play.js @@ -27,8 +27,10 @@ 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"; +import { HUDPuzzlePlaySettings } from "../hud/parts/puzzle_play_settings"; const logger = createLogger("puzzle-play"); +const copy = require("clipboard-copy"); export class PuzzlePlayGameMode extends PuzzleGameMode { static getId() { @@ -66,6 +68,7 @@ export class PuzzlePlayGameMode extends PuzzleGameMode { ]; this.additionalHudParts.puzzlePlayMetadata = HUDPuzzlePlayMetadata; + this.additionalHudParts.puzzlePlaySettings = HUDPuzzlePlaySettings; this.additionalHudParts.puzzleCompleteNotification = HUDPuzzleCompleteNotification; root.signals.postLoadHook.add(this.loadPuzzle, this); @@ -122,4 +125,47 @@ export class PuzzlePlayGameMode extends PuzzleGameMode { closeLoading(); }); } + + sharePuzzle() { + copy(this.puzzle.meta.shortKey); + + this.root.hud.parts.dialogs.showInfo( + T.dialogs.puzzleShare.title, + T.dialogs.puzzleShare.desc.replace("", this.puzzle.meta.shortKey) + ); + } + + reportPuzzle() { + const { optionSelected } = this.root.hud.parts.dialogs.showOptionChooser( + T.dialogs.puzzleReport.title, + { + options: [ + { value: "profane", text: T.dialogs.puzzleReport.options.profane }, + { value: "unsolvable", text: T.dialogs.puzzleReport.options.unsolvable }, + { value: "trolling", text: T.dialogs.puzzleReport.options.trolling }, + ], + } + ); + + optionSelected.add(option => { + const closeLoading = this.root.hud.parts.dialogs.showLoadingDialog(); + + this.root.app.clientApi.apiReportPuzzle(this.puzzle.meta.id, option).then( + () => { + closeLoading(); + this.root.hud.parts.dialogs.showInfo( + T.dialogs.puzzleReportComplete.title, + T.dialogs.puzzleReportComplete.desc + ); + }, + err => { + closeLoading(); + this.root.hud.parts.dialogs.showInfo( + T.dialogs.puzzleReportError.title, + T.dialogs.puzzleReportError.desc + " " + err + ); + } + ); + }); + } } diff --git a/src/js/game/root.js b/src/js/game/root.js index cc6cc444..64004e9d 100644 --- a/src/js/game/root.js +++ b/src/js/game/root.js @@ -79,6 +79,11 @@ export class GameRoot { */ this.bulkOperationRunning = false; + /** + * Whether a immutable operation is running + */ + this.immutableOperationRunning = false; + //////// Other properties /////// /** @type {Camera} */ @@ -169,6 +174,7 @@ export class GameRoot { itemProduced: /** @type {TypedSignal<[BaseItem]>} */ (new Signal()), bulkOperationFinished: /** @type {TypedSignal<[]>} */ (new Signal()), + immutableOperationFinished: /** @type {TypedSignal<[]>} */ (new Signal()), editModeChanged: /** @type {TypedSignal<[Layer]>} */ (new Signal()), diff --git a/src/js/game/systems/belt.js b/src/js/game/systems/belt.js index 10543e6c..62e98b33 100644 --- a/src/js/game/systems/belt.js +++ b/src/js/game/systems/belt.js @@ -123,6 +123,10 @@ export class BeltSystem extends GameSystemWithFilter { return; } + if (this.root.immutableOperationRunning) { + return; + } + const metaBelt = gMetaBuildingRegistry.findByClass(MetaBeltBuilding); // Compute affected area const originalRect = staticComp.getTileSpaceBounds(); diff --git a/src/js/game/themes/dark.json b/src/js/game/themes/dark.json index 010f9251..cb111430 100644 --- a/src/js/game/themes/dark.json +++ b/src/js/game/themes/dark.json @@ -50,10 +50,8 @@ }, "zone": { - "background": "#fff", - "border": "rgba(23, 192, 255, 0.1)", - "borderSolid": "rgba(23, 192, 255, 0.7)", - "outerColor": "rgba(240, 240, 255, 0.5)" + "borderSolid": "rgba(23, 192, 255, 1)", + "outerColor": "rgba(20 , 20, 25, 0.5)" } }, diff --git a/src/js/platform/api.js b/src/js/platform/api.js index 9616ad5d..897629cc 100644 --- a/src/js/platform/api.js +++ b/src/js/platform/api.js @@ -2,6 +2,8 @@ import { Application } from "../application"; /* typehints:end */ import { createLogger } from "../core/logging"; +import { compressX64 } from "../core/lzstring"; +import { T } from "../translations"; const logger = createLogger("puzzle-api"); @@ -70,7 +72,8 @@ export class ClientAPI { ]) .then(data => { if (data.error) { - throw data.error; + logger.warn("Got error from api:", data); + throw T.backendErrors[data.error] || data.error; } return data; }) @@ -126,6 +129,31 @@ export class ClientAPI { return this._request("/v1/puzzles/download/" + puzzleId, {}); } + /** + * @param {number} shortKey + * @returns {Promise} + */ + apiDownloadPuzzleByKey(shortKey) { + if (!this.isLoggedIn()) { + return Promise.reject("not-logged-in"); + } + return this._request("/v1/puzzles/download/" + shortKey, {}); + } + + /** + * @param {number} puzzleId + * @returns {Promise} + */ + apiReportPuzzle(puzzleId, reason) { + if (!this.isLoggedIn()) { + return Promise.reject("not-logged-in"); + } + return this._request("/v1/puzzles/report/" + puzzleId, { + method: "POST", + body: { reason }, + }); + } + /** * @param {number} puzzleId * @param {object} payload @@ -157,7 +185,10 @@ export class ClientAPI { } return this._request("/v1/puzzles/submit", { method: "POST", - body: payload, + body: { + ...payload, + data: compressX64(JSON.stringify(payload.data)), + }, }); } } diff --git a/src/js/savegame/savegame_typedefs.js b/src/js/savegame/savegame_typedefs.js index f8efcf34..3e8e2e1d 100644 --- a/src/js/savegame/savegame_typedefs.js +++ b/src/js/savegame/savegame_typedefs.js @@ -48,6 +48,8 @@ * shortKey: string; * likes: number; * downloads: number; + * difficulty: number | null; + * averageTime: number | null; * title: string; * author: string; * completed: boolean; diff --git a/src/js/states/main_menu.js b/src/js/states/main_menu.js index da96d748..a4e19453 100644 --- a/src/js/states/main_menu.js +++ b/src/js/states/main_menu.js @@ -67,7 +67,7 @@ export class MainMenuState extends GameState { shapez.io Logo - v${G_BUILD_VERSION} - Achievements! + v${G_BUILD_VERSION} - Puzzle DLC!
@@ -208,7 +208,7 @@ export class MainMenuState extends GameState { const qs = this.htmlElement.querySelector.bind(this.htmlElement); if (G_IS_DEV && globalConfig.debug.testPuzzleMode) { - this.onPuzzleModeButtonClicked(); + this.onPuzzleModeButtonClicked(true); return; } @@ -320,10 +320,22 @@ export class MainMenuState extends GameState { const puzzleModeButton = makeButton(bottomButtonContainer, ["styledButton"], T.mainMenu.puzzleMode); bottomButtonContainer.appendChild(puzzleModeButton); - this.trackClicks(puzzleModeButton, this.onPuzzleModeButtonClicked); + this.trackClicks(puzzleModeButton, () => this.onPuzzleModeButtonClicked()); } - onPuzzleModeButtonClicked() { + onPuzzleModeButtonClicked(force = false) { + const hasUnlockedBlueprints = this.app.savegameMgr.getSavegamesMetaData().some(s => s.level >= 12); + console.log(hasUnlockedBlueprints); + if (!force && !hasUnlockedBlueprints) { + const { ok } = this.dialogs.showWarning( + T.dialogs.puzzlePlayRegularRecommendation.title, + T.dialogs.puzzlePlayRegularRecommendation.desc, + ["cancel:good", "ok:bad:timeout"] + ); + ok.add(() => this.onPuzzleModeButtonClicked(true)); + return; + } + this.moveToState("LoginState", { nextStateId: "PuzzleMenuState", }); diff --git a/src/js/states/puzzle_menu.js b/src/js/states/puzzle_menu.js index bed8cf26..a7960ea0 100644 --- a/src/js/states/puzzle_menu.js +++ b/src/js/states/puzzle_menu.js @@ -1,12 +1,16 @@ import { globalConfig } from "../core/config"; import { createLogger } from "../core/logging"; +import { DialogWithForm } from "../core/modal_dialog_elements"; +import { FormElementInput } from "../core/modal_dialog_forms"; import { TextualGameState } from "../core/textual_game_state"; -import { formatBigNumberFull } from "../core/utils"; +import { clamp, formatBigNumberFull } from "../core/utils"; import { enumGameModeIds } from "../game/game_mode"; +import { PUZZLE_RATINGS } from "../game/hud/parts/puzzle_complete_notification"; import { ShapeDefinition } from "../game/shape_definition"; +import { Savegame } from "../savegame/savegame"; import { T } from "../translations"; -const categories = ["levels", "new", "top-rated", "mine"]; +const categories = ["top-rated", "short", "hard", "new", "mine"]; /** * @type {import("../savegame/savegame_typedefs").PuzzleMetadata} @@ -16,6 +20,8 @@ const SAMPLE_PUZZLE = { shortKey: "CuCuCuCu", downloads: 0, likes: 0, + averageTime: 1, + difficulty: null, title: "Level 1", author: "verylongsteamnamewhichbreaks", completed: false, @@ -63,6 +69,7 @@ export class PuzzleMenuState extends TextualGameState {

${this.getStateHeaderTitle()}

+
`; @@ -89,12 +96,7 @@ export class PuzzleMenuState extends TextualGameState { .join("")} -
-
-
-
-
-
+
`; return html; @@ -104,9 +106,11 @@ export class PuzzleMenuState extends TextualGameState { if (category === this.activeCategory) { return; } + if (this.loading) { return; } + this.loading = true; this.activeCategory = category; @@ -175,6 +179,22 @@ export class PuzzleMenuState extends TextualGameState { stats.classList.add("stats"); elem.appendChild(stats); + if (puzzle.difficulty !== null) { + const difficulty = document.createElement("div"); + difficulty.classList.add("difficulty"); + + const canvas = document.createElement("canvas"); + canvas.width = 32; + canvas.height = 32; + const context = canvas.getContext("2d"); + PUZZLE_RATINGS[ + clamp(Math.round(puzzle.difficulty), 0, PUZZLE_RATINGS.length - 1) + ].drawFullSizeOnCanvas(context, 32); + difficulty.appendChild(canvas); + + stats.appendChild(difficulty); + } + const downloads = document.createElement("div"); downloads.classList.add("downloads"); downloads.innerText = String(puzzle.downloads); @@ -233,16 +253,8 @@ export class PuzzleMenuState extends TextualGameState { 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, - }); + this.startLoadedPuzzle(puzzleData); }, err => { closeLoading(); @@ -255,8 +267,23 @@ export class PuzzleMenuState extends TextualGameState { ); } + /** + * + * @param {import("../savegame/savegame_typedefs").PuzzleFullData} puzzle + */ + startLoadedPuzzle(puzzle) { + const savegame = this.createEmptySavegame(); + this.moveToState("InGameState", { + gameModeId: enumGameModeIds.puzzlePlay, + gameModeParameters: { + puzzle, + }, + savegame, + }); + } + onEnter(payload) { - this.selectCategory("levels"); + this.selectCategory(categories[0]); if (payload && payload.error) { this.dialogs.showWarning(payload.error.title, payload.error.desc); @@ -268,6 +295,7 @@ export class PuzzleMenuState extends TextualGameState { } this.trackClicks(this.htmlElement.querySelector("button.createPuzzle"), () => this.createNewPuzzle()); + this.trackClicks(this.htmlElement.querySelector("button.loadPuzzle"), () => this.loadPuzzle()); if (G_IS_DEV && globalConfig.debug.testPuzzleMode) { // this.createNewPuzzle(); @@ -275,6 +303,56 @@ export class PuzzleMenuState extends TextualGameState { } } + createEmptySavegame() { + return new Savegame(this.app, { + internalId: "puzzle", + metaDataRef: { + internalId: "puzzle", + lastUpdate: 0, + version: 0, + level: 0, + name: "puzzle", + }, + }); + } + + loadPuzzle() { + const shortKeyInput = new FormElementInput({ + id: "shortKey", + label: null, + placeholder: "", + defaultValue: "", + validator: val => ShapeDefinition.isValidShortKey(val), + }); + + const dialog = new DialogWithForm({ + app: this.app, + title: T.dialogs.puzzleLoadShortKey.title, + desc: T.dialogs.puzzleLoadShortKey.desc, + formElements: [shortKeyInput], + buttons: ["ok:good:enter"], + }); + this.dialogs.internalShowDialog(dialog); + + dialog.buttonSignals.ok.add(() => { + const closeLoading = this.dialogs.showLoadingDialog(); + + this.app.clientApi.apiDownloadPuzzleByKey(shortKeyInput.getValue()).then( + puzzle => { + closeLoading(); + this.startLoadedPuzzle(puzzle); + }, + err => { + closeLoading(); + this.dialogs.showWarning( + T.dialogs.puzzleDownloadError.title, + T.dialogs.puzzleDownloadError.desc + " " + err + ); + } + ); + }); + } + createNewPuzzle(force = false) { if (!force && !this.app.clientApi.isLoggedIn()) { const signals = this.dialogs.showWarning( @@ -286,7 +364,7 @@ export class PuzzleMenuState extends TextualGameState { return; } - const savegame = this.app.savegameMgr.createNewSavegame(); + const savegame = this.createEmptySavegame(); this.moveToState("InGameState", { gameModeId: enumGameModeIds.puzzleEdit, savegame, diff --git a/translations/base-en.yaml b/translations/base-en.yaml index 16c257ef..f09b2e19 100644 --- a/translations/base-en.yaml +++ b/translations/base-en.yaml @@ -125,16 +125,20 @@ puzzleMenu: edit: Edit title: Puzzle Mode createPuzzle: Create Puzzle + loadPuzzle: Load reviewPuzzle: Review & Publish - validtingPuzzle: Validating Puzzle + validatingPuzzle: Validating Puzzle submittingPuzzle: Submitting Puzzle noPuzzles: There are currently no puzzles in this section. + difficultyNotDetermined: Not yet determined categories: levels: Levels new: New top-rated: Top Rated mine: My Puzzles + short: Short + hard: Hard validation: title: Invalid Puzzle @@ -337,6 +341,38 @@ dialogs: desc: >- Since you are offline, you will not be able to save and/or publish your puzzle. Would you still like to continue? + puzzlePlayRegularRecommendation: + title: Recommendation + desc: >- + I strongly recommend playing the normal game to level 12 before attempting the puzzle DLC, otherwise you will have comprehension problems. Do you still want to continue? + + puzzleShare: + title: Short Key Copied + desc: >- + The short key of the puzzle () has been copied to your clipboard! It can be entered in the puzzle menu to access the puzzle. + + puzzleReport: + title: Report Puzzle + options: + profane: Profane + unsolvable: Not solvable + trolling: Trolling + + puzzleReportComplete: + title: Thank you for your feedback! + desc: >- + The puzzle has been flagged. + + puzzleReportError: + title: Failed to report + desc: >- + Your report could not get processed: + + puzzleLoadShortKey: + title: Enter short key + desc: >- + Enter the short key of the puzzle to load it. + ingame: # This is shown in the top left corner and displays useful keybindings in # every situation @@ -567,6 +603,9 @@ ingame: zoneWidth: Width zoneHeight: Height trimZone: Trim + clearItems: Clear Items + share: Share + report: Report puzzleEditorControls: title: Puzzle Creator @@ -584,7 +623,20 @@ ingame: Please rate the puzzle: titleRating: How difficult did you find the puzzle? - buttonSubmit: Submit + buttonSubmit: Continue + + difficulties: + - No challenge + - Easy + - Medium + - Hard + - Impossible + + puzzleMetadata: + author: Author + shortKey: Short Key + rating: Difficulty + averageDuration: Avg. Duration # All shop upgrades shopUpgrades: @@ -1303,6 +1355,26 @@ demo: settingNotAvailable: Not available in the demo. +backendErrors: + ratelimit: You are performing your actions too frequent. Please wait a bit. + invalid-api-key: Failed to communicate with the backend, please try to update/restart the game (Invalid Api Key). + unauthorized: Failed to communicate with the backend, please try to update/restart the game (Unauthorized). + bad-token: Failed to communicate with the backend, please try to update/restart the game (Bad Token). + bad-id: Invalid puzzle identifier. + not-found: The given puzzle could not be found. + bad-category: The given category could not be found. + bad-short-key: The given short key is invalid. + profane-title: Your puzzle title contains profane words. + bad-title-too-many-spaces: Your puzzle title is too short. + bad-shape-key-in-emitter: A constant producer has an invalid item. + bad-shape-key-in-goal: A goal acceptor has an invalid item. + no-emitters: Your puzzle does not contain any constant producers. + no-goals: Your puzzle does not contain any goal acceptors. + short-key-already-taken: This short key is already taken, please use another one. + can-not-report-your-own-puzzle: You can not report your own puzzle. + bad-payload: The request contains invalid data. + bad-building-placement: Your puzzle contains invalid placed buildings. + tips: - The hub will accept any input, not just the current shape! - Make sure your factories are modular - it will pay out!