}
@@ -87,19 +110,31 @@ export class HUDBaseToolbar extends BaseHUDPart {
);
itemContainer.setAttribute("data-icon", "building_icons/" + metaBuilding.getId() + ".png");
itemContainer.setAttribute("data-id", metaBuilding.getId());
-
binding.add(() => this.selectBuildingForPlacement(metaBuilding));
- this.trackClicks(itemContainer, () => this.selectBuildingForPlacement(metaBuilding), {
+ const icon = makeDiv(itemContainer, null, ["icon"]);
+
+ this.trackClicks(icon, () => this.selectBuildingForPlacement(metaBuilding), {
clickSound: null,
});
+ //lock icon for puzzle editor
+ if (this.root.gameMode.getIsEditor() && !this.inRequiredBuildings(metaBuilding)) {
+ const puzzleLock = makeDiv(itemContainer, null, ["puzzle-lock"]);
+
+ itemContainer.classList.toggle("editor", true);
+ this.trackClicks(puzzleLock, () => this.toggleBuildingLock(metaBuilding), {
+ clickSound: null,
+ });
+ }
+
this.buildingHandles[metaBuilding.id] = {
- metaBuilding,
+ metaBuilding: metaBuilding,
element: itemContainer,
unlocked: false,
selected: false,
index: i,
+ puzzleLocked: false,
};
}
@@ -127,7 +162,7 @@ export class HUDBaseToolbar extends BaseHUDPart {
let recomputeSecondaryToolbarVisibility = false;
for (const buildingId in this.buildingHandles) {
const handle = this.buildingHandles[buildingId];
- const newStatus = handle.metaBuilding.getIsUnlocked(this.root);
+ const newStatus = !handle.puzzleLocked && handle.metaBuilding.getIsUnlocked(this.root);
if (handle.unlocked !== newStatus) {
handle.unlocked = newStatus;
handle.element.classList.toggle("unlocked", newStatus);
@@ -216,6 +251,14 @@ export class HUDBaseToolbar extends BaseHUDPart {
return STOP_PROPAGATION;
}
+ const handle = this.buildingHandles[metaBuilding.getId()];
+ if (handle.puzzleLocked) {
+ handle.puzzleLocked = false;
+ handle.element.classList.toggle("unlocked", false);
+ this.root.soundProxy.playUiClick();
+ return;
+ }
+
// Allow clicking an item again to deselect it
for (const buildingId in this.buildingHandles) {
const handle = this.buildingHandles[buildingId];
@@ -229,4 +272,51 @@ export class HUDBaseToolbar extends BaseHUDPart {
this.root.hud.signals.buildingSelectedForPlacement.dispatch(metaBuilding);
this.onSelectedPlacementBuildingChanged(metaBuilding);
}
+
+ /**
+ * @param {MetaBuilding} metaBuilding
+ */
+ toggleBuildingLock(metaBuilding) {
+ if (!this.visibilityCondition()) {
+ // Not active
+ return;
+ }
+
+ if (this.inRequiredBuildings(metaBuilding) || !metaBuilding.getIsUnlocked(this.root)) {
+ this.root.soundProxy.playUiError();
+ return STOP_PROPAGATION;
+ }
+
+ const handle = this.buildingHandles[metaBuilding.getId()];
+ handle.puzzleLocked = !handle.puzzleLocked;
+ handle.element.classList.toggle("unlocked", !handle.puzzleLocked);
+ this.root.soundProxy.playUiClick();
+
+ const entityManager = this.root.entityMgr;
+ for (const entity of entityManager.getAllWithComponent(StaticMapEntityComponent)) {
+ const staticComp = entity.components.StaticMapEntity;
+ if (staticComp.getMetaBuilding().id === metaBuilding.id) {
+ this.root.map.removeStaticEntity(entity);
+ entityManager.destroyEntity(entity);
+ }
+ }
+ entityManager.processDestroyList();
+
+ const currentMetaBuilding = this.root.hud.parts.buildingPlacer.currentMetaBuilding;
+ if (currentMetaBuilding.get() == metaBuilding) {
+ currentMetaBuilding.set(null);
+ }
+ }
+
+ /**
+ * @param {MetaBuilding} metaBuilding
+ */
+ inRequiredBuildings(metaBuilding) {
+ const requiredBuildings = [
+ gMetaBuildingRegistry.findByClass(MetaConstantProducerBuilding),
+ gMetaBuildingRegistry.findByClass(MetaGoalAcceptorBuilding),
+ gMetaBuildingRegistry.findByClass(MetaBlockBuilding),
+ ];
+ return requiredBuildings.includes(metaBuilding);
+ }
}
diff --git a/src/js/game/hud/parts/building_placer.js b/src/js/game/hud/parts/building_placer.js
index 7d618b6b..33e6ebc2 100644
--- a/src/js/game/hud/parts/building_placer.js
+++ b/src/js/game/hud/parts/building_placer.js
@@ -234,7 +234,7 @@ export class HUDBuildingPlacer extends HUDBuildingPlacerLogic {
* @param {DrawParameters} parameters
*/
draw(parameters) {
- if (this.root.camera.zoomLevel < globalConfig.mapChunkOverviewMinZoom) {
+ if (this.root.camera.getIsMapOverlayActive()) {
// Dont allow placing in overview mode
this.domAttach.update(false);
this.variantsAttach.update(false);
@@ -275,11 +275,13 @@ export class HUDBuildingPlacer extends HUDBuildingPlacerLogic {
const worldPosition = this.root.camera.screenToWorld(mousePosition);
// Draw peeker
- this.root.hud.parts.layerPreview.renderPreview(
- parameters,
- worldPosition,
- 1 / this.root.camera.zoomLevel
- );
+ if (this.root.hud.parts.layerPreview) {
+ this.root.hud.parts.layerPreview.renderPreview(
+ parameters,
+ worldPosition,
+ 1 / this.root.camera.zoomLevel
+ );
+ }
}
/**
diff --git a/src/js/game/hud/parts/building_placer_logic.js b/src/js/game/hud/parts/building_placer_logic.js
index 1e88abc7..9e91f372 100644
--- a/src/js/game/hud/parts/building_placer_logic.js
+++ b/src/js/game/hud/parts/building_placer_logic.js
@@ -366,7 +366,8 @@ export class HUDBuildingPlacerLogic extends BaseHUDPart {
if (
tileBelow &&
this.root.app.settings.getAllSettings().pickMinerOnPatch &&
- this.root.currentLayer === "regular"
+ this.root.currentLayer === "regular" &&
+ this.root.gameMode.hasResources()
) {
this.currentMetaBuilding.set(gMetaBuildingRegistry.findByClass(MetaMinerBuilding));
@@ -390,6 +391,12 @@ export class HUDBuildingPlacerLogic extends BaseHUDPart {
return;
}
+ // Disallow picking excluded buildings
+ if (this.root.gameMode.isBuildingExcluded(extracted.metaClass)) {
+ this.currentMetaBuilding.set(null);
+ return;
+ }
+
// If the building we are picking is the same as the one we have, clear the cursor.
if (
this.currentMetaBuilding.get() &&
@@ -430,7 +437,7 @@ export class HUDBuildingPlacerLogic extends BaseHUDPart {
* @param {Vector} tile
*/
tryPlaceCurrentBuildingAt(tile) {
- if (this.root.camera.zoomLevel < globalConfig.mapChunkOverviewMinZoom) {
+ if (this.root.camera.getIsMapOverlayActive()) {
// Dont allow placing in overview mode
return;
}
diff --git a/src/js/game/hud/parts/buildings_toolbar.js b/src/js/game/hud/parts/buildings_toolbar.js
index 05ffc795..994a70ed 100644
--- a/src/js/game/hud/parts/buildings_toolbar.js
+++ b/src/js/game/hud/parts/buildings_toolbar.js
@@ -15,23 +15,28 @@ import { MetaUndergroundBeltBuilding } from "../../buildings/underground_belt";
import { HUDBaseToolbar } from "./base_toolbar";
import { MetaStorageBuilding } from "../../buildings/storage";
import { MetaItemProducerBuilding } from "../../buildings/item_producer";
-import { queryParamOptions } from "../../../core/query_parameters";
+import { MetaConstantProducerBuilding } from "../../buildings/constant_producer";
+import { MetaGoalAcceptorBuilding } from "../../buildings/goal_acceptor";
+import { MetaBlockBuilding } from "../../buildings/block";
export class HUDBuildingsToolbar extends HUDBaseToolbar {
constructor(root) {
super(root, {
primaryBuildings: [
+ MetaConstantProducerBuilding,
+ MetaGoalAcceptorBuilding,
MetaBeltBuilding,
MetaBalancerBuilding,
MetaUndergroundBeltBuilding,
MetaMinerBuilding,
+ MetaBlockBuilding,
MetaCutterBuilding,
MetaRotaterBuilding,
MetaStackerBuilding,
MetaMixerBuilding,
MetaPainterBuilding,
MetaTrashBuilding,
- ...(queryParamOptions.sandboxMode || G_IS_DEV ? [MetaItemProducerBuilding] : []),
+ MetaItemProducerBuilding,
],
secondaryBuildings: [
MetaStorageBuilding,
diff --git a/src/js/game/hud/parts/keybinding_overlay.js b/src/js/game/hud/parts/keybinding_overlay.js
index 65919d3c..2384ab84 100644
--- a/src/js/game/hud/parts/keybinding_overlay.js
+++ b/src/js/game/hud/parts/keybinding_overlay.js
@@ -254,6 +254,13 @@ export class HUDKeybindingOverlay extends BaseHUDPart {
condition: () => this.anythingSelectedOnMap,
},
+ {
+ // [SELECTION] Clear
+ label: T.ingame.keybindingsOverlay.clearBelts,
+ keys: [k.massSelect.massSelectClear],
+ condition: () => this.anythingSelectedOnMap,
+ },
+
{
// Switch layers
label: T.ingame.keybindingsOverlay.switchLayers,
diff --git a/src/js/game/hud/parts/mass_selector.js b/src/js/game/hud/parts/mass_selector.js
index d73e3be3..ab933da3 100644
--- a/src/js/game/hud/parts/mass_selector.js
+++ b/src/js/game/hud/parts/mass_selector.js
@@ -1,20 +1,19 @@
-import { BaseHUDPart } from "../base_hud_part";
-import { Vector } from "../../../core/vector";
-import { STOP_PROPAGATION } from "../../../core/signal";
-import { DrawParameters } from "../../../core/draw_parameters";
-import { Entity } from "../../entity";
-import { Loader } from "../../../core/loader";
import { globalConfig } from "../../../core/config";
-import { makeDiv, formatBigNumber, formatBigNumberFull } from "../../../core/utils";
-import { DynamicDomAttach } from "../dynamic_dom_attach";
+import { DrawParameters } from "../../../core/draw_parameters";
import { createLogger } from "../../../core/logging";
+import { STOP_PROPAGATION } from "../../../core/signal";
+import { formatBigNumberFull } from "../../../core/utils";
+import { Vector } from "../../../core/vector";
import { ACHIEVEMENTS } from "../../../platform/achievement_provider";
-import { enumMouseButton } from "../../camera";
import { T } from "../../../translations";
+import { Blueprint } from "../../blueprint";
+import { enumMouseButton } from "../../camera";
+import { Component } from "../../component";
+import { Entity } from "../../entity";
import { KEYMAPPINGS } from "../../key_action_mapper";
import { THEME } from "../../theme";
import { enumHubGoalRewards } from "../../tutorial_goals";
-import { Blueprint } from "../../blueprint";
+import { BaseHUDPart } from "../base_hud_part";
const logger = createLogger("hud/mass_selector");
@@ -33,12 +32,13 @@ export class HUDMassSelector extends BaseHUDPart {
this.root.camera.movePreHandler.add(this.onMouseMove, this);
this.root.camera.upPostHandler.add(this.onMouseUp, this);
- this.root.keyMapper.getBinding(KEYMAPPINGS.general.back).add(this.onBack, this);
+ this.root.keyMapper.getBinding(KEYMAPPINGS.general.back).addToTop(this.onBack, this);
this.root.keyMapper
.getBinding(KEYMAPPINGS.massSelect.confirmMassDelete)
.add(this.confirmDelete, this);
this.root.keyMapper.getBinding(KEYMAPPINGS.massSelect.massSelectCut).add(this.confirmCut, this);
this.root.keyMapper.getBinding(KEYMAPPINGS.massSelect.massSelectCopy).add(this.startCopy, this);
+ this.root.keyMapper.getBinding(KEYMAPPINGS.massSelect.massSelectClear).add(this.clearBelts, this);
this.root.hud.signals.selectedPlacementBuildingChanged.add(this.clearSelection, this);
this.root.signals.editModeChanged.add(this.clearSelection, this);
@@ -142,6 +142,16 @@ export class HUDMassSelector extends BaseHUDPart {
}
}
+ clearBelts() {
+ for (const uid of this.selectedUids) {
+ const entity = this.root.entityMgr.findByUid(uid);
+ for (const component of Object.values(entity.components)) {
+ /** @type {Component} */ (component).clear();
+ }
+ }
+ this.selectedUids = new Set();
+ }
+
confirmCut() {
if (!this.root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_blueprints)) {
this.root.hud.parts.dialogs.showInfo(
diff --git a/src/js/game/hud/parts/modal_dialogs.js b/src/js/game/hud/parts/modal_dialogs.js
index 263b23dd..a43260e3 100644
--- a/src/js/game/hud/parts/modal_dialogs.js
+++ b/src/js/game/hud/parts/modal_dialogs.js
@@ -29,11 +29,14 @@ export class HUDModalDialogs extends BaseHUDPart {
}
shouldPauseRendering() {
- return this.dialogStack.length > 0;
+ // return this.dialogStack.length > 0;
+ // @todo: Check if change this affects anything
+ return false;
}
shouldPauseGame() {
- return this.shouldPauseRendering();
+ // @todo: Check if this change affects anything
+ return false;
}
createElements(parent) {
@@ -139,8 +142,8 @@ export class HUDModalDialogs extends BaseHUDPart {
}
// Returns method to be called when laoding finishd
- showLoadingDialog() {
- const dialog = new DialogLoading(this.app);
+ showLoadingDialog(text = "") {
+ const dialog = new DialogLoading(this.app, text);
this.internalShowDialog(dialog);
return this.closeDialog.bind(this, dialog);
}
diff --git a/src/js/game/hud/parts/pinned_shapes.js b/src/js/game/hud/parts/pinned_shapes.js
index 4a9fce0d..a53ecbe5 100644
--- a/src/js/game/hud/parts/pinned_shapes.js
+++ b/src/js/game/hud/parts/pinned_shapes.js
@@ -55,7 +55,7 @@ export class HUDPinnedShapes extends BaseHUDPart {
*/
deserialize(data) {
if (!data || !data.shapes || !Array.isArray(data.shapes)) {
- return "Invalid pinned shapes data";
+ return "Invalid pinned shapes data: " + JSON.stringify(data);
}
this.pinnedShapes = data.shapes;
}
diff --git a/src/js/game/hud/parts/puzzle_back_to_menu.js b/src/js/game/hud/parts/puzzle_back_to_menu.js
new file mode 100644
index 00000000..bde5b66d
--- /dev/null
+++ b/src/js/game/hud/parts/puzzle_back_to_menu.js
@@ -0,0 +1,21 @@
+import { makeDiv } from "../../../core/utils";
+import { BaseHUDPart } from "../base_hud_part";
+
+export class HUDPuzzleBackToMenu extends BaseHUDPart {
+ createElements(parent) {
+ const key = this.root.gameMode.getId();
+
+ this.element = makeDiv(parent, "ingame_HUD_PuzzleBackToMenu");
+ this.button = document.createElement("button");
+ this.button.classList.add("button");
+ this.element.appendChild(this.button);
+
+ this.trackClicks(this.button, this.back);
+ }
+
+ initialize() {}
+
+ back() {
+ this.root.gameState.goBackToMenu();
+ }
+}
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..f223c1d6
--- /dev/null
+++ b/src/js/game/hud/parts/puzzle_complete_notification.js
@@ -0,0 +1,112 @@
+/* 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 { 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 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.userDidLikePuzzle = false;
+ 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"]);
+ this.elemActions = makeDiv(dialog, null, ["actions"]);
+
+ const stepLike = makeDiv(this.elemContents, null, ["step", "stepLike"]);
+ makeDiv(stepLike, null, ["title"], T.ingame.puzzleCompletion.titleLike);
+
+ const likeButtons = makeDiv(stepLike, null, ["buttons"]);
+
+ this.buttonLikeYes = document.createElement("button");
+ this.buttonLikeYes.classList.add("liked-yes");
+ likeButtons.appendChild(this.buttonLikeYes);
+ this.trackClicks(this.buttonLikeYes, () => {
+ this.userDidLikePuzzle = !this.userDidLikePuzzle;
+ this.updateState();
+ });
+
+ const buttonBar = document.createElement("div");
+ buttonBar.classList.add("buttonBar");
+ this.elemContents.appendChild(buttonBar);
+
+ this.continueBtn = document.createElement("button");
+ this.continueBtn.classList.add("continue", "styledButton");
+ this.continueBtn.innerText = T.ingame.puzzleCompletion.continueBtn;
+ buttonBar.appendChild(this.continueBtn);
+ this.trackClicks(this.continueBtn, () => {
+ this.close(false);
+ });
+
+ this.menuBtn = document.createElement("button");
+ this.menuBtn.classList.add("menu", "styledButton");
+ this.menuBtn.innerText = T.ingame.puzzleCompletion.menuBtn;
+ buttonBar.appendChild(this.menuBtn);
+
+ this.trackClicks(this.menuBtn, () => {
+ this.close(true);
+ });
+ }
+
+ updateState() {
+ this.buttonLikeYes.classList.toggle("active", this.userDidLikePuzzle === true);
+ }
+
+ 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(toMenu) {
+ /** @type {PuzzlePlayGameMode} */ (this.root.gameMode)
+ .trackCompleted(this.userDidLikePuzzle, Math.round(this.timeOfCompletion))
+ .then(() => {
+ if (toMenu) {
+ this.root.gameState.moveToState("PuzzleMenuState");
+ } else {
+ this.visible = false;
+ this.cleanup();
+ }
+ });
+ }
+
+ update() {
+ this.domAttach.update(this.visible);
+ }
+}
diff --git a/src/js/game/hud/parts/puzzle_dlc_logo.js b/src/js/game/hud/parts/puzzle_dlc_logo.js
new file mode 100644
index 00000000..ec50808a
--- /dev/null
+++ b/src/js/game/hud/parts/puzzle_dlc_logo.js
@@ -0,0 +1,13 @@
+import { makeDiv } from "../../../core/utils";
+import { BaseHUDPart } from "../base_hud_part";
+
+export class HUDPuzzleDLCLogo extends BaseHUDPart {
+ createElements(parent) {
+ this.element = makeDiv(parent, "ingame_HUD_PuzzleDLCLogo");
+ parent.appendChild(this.element);
+ }
+
+ initialize() {}
+
+ next() {}
+}
diff --git a/src/js/game/hud/parts/puzzle_editor_controls.js b/src/js/game/hud/parts/puzzle_editor_controls.js
new file mode 100644
index 00000000..d8055f11
--- /dev/null
+++ b/src/js/game/hud/parts/puzzle_editor_controls.js
@@ -0,0 +1,18 @@
+import { makeDiv } from "../../../core/utils";
+import { T } from "../../../translations";
+import { BaseHUDPart } from "../base_hud_part";
+
+export class HUDPuzzleEditorControls extends BaseHUDPart {
+ createElements(parent) {
+ this.element = makeDiv(parent, "ingame_HUD_PuzzleEditorControls");
+
+ this.element.innerHTML = T.ingame.puzzleEditorControls.instructions
+ .map(text => `${text}`)
+ .join("");
+
+ this.titleElement = makeDiv(parent, "ingame_HUD_PuzzleEditorTitle");
+ this.titleElement.innerText = T.ingame.puzzleEditorControls.title;
+ }
+
+ initialize() {}
+}
diff --git a/src/js/game/hud/parts/puzzle_editor_review.js b/src/js/game/hud/parts/puzzle_editor_review.js
new file mode 100644
index 00000000..68f5360c
--- /dev/null
+++ b/src/js/game/hud/parts/puzzle_editor_review.js
@@ -0,0 +1,233 @@
+import { globalConfig, THIRDPARTY_URLS } from "../../../core/config";
+import { createLogger } from "../../../core/logging";
+import { DialogWithForm } from "../../../core/modal_dialog_elements";
+import { FormElementInput, FormElementItemChooser } from "../../../core/modal_dialog_forms";
+import { STOP_PROPAGATION } from "../../../core/signal";
+import { fillInLinkIntoTranslation, makeDiv } from "../../../core/utils";
+import { PuzzleSerializer } from "../../../savegame/puzzle_serializer";
+import { T } from "../../../translations";
+import { ConstantSignalComponent } from "../../components/constant_signal";
+import { GoalAcceptorComponent } from "../../components/goal_acceptor";
+import { StaticMapEntityComponent } from "../../components/static_map_entity";
+import { ShapeItem } from "../../items/shape_item";
+import { ShapeDefinition } from "../../shape_definition";
+import { BaseHUDPart } from "../base_hud_part";
+
+const trim = require("trim");
+const logger = createLogger("puzzle-review");
+
+export class HUDPuzzleEditorReview extends BaseHUDPart {
+ constructor(root) {
+ super(root);
+ }
+
+ createElements(parent) {
+ const key = this.root.gameMode.getId();
+
+ this.element = makeDiv(parent, "ingame_HUD_PuzzleEditorReview");
+ this.button = document.createElement("button");
+ this.button.classList.add("button");
+ this.button.textContent = T.puzzleMenu.reviewPuzzle;
+ this.element.appendChild(this.button);
+
+ this.trackClicks(this.button, this.startReview);
+ }
+
+ initialize() {}
+
+ startReview() {
+ const validationError = this.validatePuzzle();
+ if (validationError) {
+ this.root.hud.parts.dialogs.showWarning(T.puzzleMenu.validation.title, validationError);
+ return;
+ }
+
+ 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 maxTicks =
+ this.root.gameMode.getFixedTickrate() * globalConfig.puzzleValidationDurationSeconds;
+ const deltaMs = this.root.dynamicTickrate.deltaMs;
+ logger.log("Simulating up to", maxTicks, "ticks, start=", this.root.time.now().toFixed(1));
+ const now = performance.now();
+
+ let simulatedTicks = 0;
+ for (let i = 0; i < maxTicks; ++i) {
+ // Perform logic tick
+ this.root.time.performTicks(deltaMs, this.root.gameState.core.boundInternalTick);
+ simulatedTicks++;
+
+ if (simulatedTicks % 100 == 0 && !this.validatePuzzle()) {
+ break;
+ }
+ }
+
+ const duration = performance.now() - now;
+ logger.log(
+ "Simulated",
+ simulatedTicks,
+ "ticks, end=",
+ this.root.time.now().toFixed(1),
+ "duration=",
+ duration.toFixed(2),
+ "ms"
+ );
+
+ console.log("duration: " + duration);
+ closeLoading();
+
+ //if it took so little ticks that it must have autocompeted
+ if (simulatedTicks <= 300) {
+ this.root.hud.parts.dialogs.showWarning(
+ T.puzzleMenu.validation.title,
+ T.puzzleMenu.validation.autoComplete
+ );
+ return;
+ }
+
+ //if we reached maximum ticks and the puzzle still isn't completed
+ const validationError = this.validatePuzzle();
+ if (simulatedTicks == maxTicks && validationError) {
+ this.root.hud.parts.dialogs.showWarning(T.puzzleMenu.validation.title, validationError);
+ return;
+ }
+ this.startSubmit();
+ }, 750);
+ }
+
+ 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: title,
+ validator: val => trim(val).match(regex) && trim(val).length > 0,
+ });
+
+ let items = new Set();
+ const acceptors = this.root.entityMgr.getAllWithComponent(GoalAcceptorComponent);
+ for (const acceptor of acceptors) {
+ const item = acceptor.components.GoalAcceptor.item;
+ if (item.getItemType() === "shape") {
+ items.add(item);
+ }
+ }
+
+ while (items.size < 8) {
+ // add some randoms
+ const item = this.root.hubGoals.computeFreeplayShape(Math.round(10 + Math.random() * 10000));
+ items.add(new ShapeItem(item));
+ }
+
+ const itemInput = new FormElementItemChooser({
+ id: "signalItem",
+ label: fillInLinkIntoTranslation(T.dialogs.submitPuzzle.descIcon, THIRDPARTY_URLS.shapeViewer),
+ items: Array.from(items),
+ });
+
+ const shapeKeyInput = new FormElementInput({
+ id: "shapeKeyInput",
+ label: null,
+ placeholder: "CuCuCuCu",
+ defaultValue: shortKey,
+ validator: val => ShapeDefinition.isValidShortKey(trim(val)),
+ });
+
+ const dialog = new DialogWithForm({
+ app: this.root.app,
+ title: T.dialogs.submitPuzzle.title,
+ desc: "",
+ formElements: [nameInput, itemInput, shapeKeyInput],
+ buttons: ["ok:good:enter"],
+ });
+
+ itemInput.valueChosen.add(value => {
+ shapeKeyInput.setValue(value.definition.getHash());
+ });
+
+ this.root.hud.parts.dialogs.internalShowDialog(dialog);
+
+ dialog.buttonSignals.ok.add(() => {
+ const title = trim(nameInput.getValue());
+ const shortKey = trim(shapeKeyInput.getValue());
+ this.doSubmitPuzzle(title, shortKey);
+ });
+ }
+
+ doSubmitPuzzle(title, shortKey) {
+ const serialized = new PuzzleSerializer().generateDumpFromGameRoot(this.root);
+
+ logger.log("Submitting puzzle, title=", title, "shortKey=", shortKey);
+ if (G_IS_DEV) {
+ logger.log("Serialized data:", serialized);
+ }
+
+ const closeLoading = this.root.hud.parts.dialogs.showLoadingDialog(T.puzzleMenu.submittingPuzzle);
+
+ 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));
+ }
+ );
+ }
+
+ validatePuzzle() {
+ // Check there is at least one constant producer and goal acceptor
+ const producers = this.root.entityMgr.getAllWithComponent(ConstantSignalComponent);
+ const acceptors = this.root.entityMgr.getAllWithComponent(GoalAcceptorComponent);
+
+ if (producers.length === 0) {
+ return T.puzzleMenu.validation.noProducers;
+ }
+
+ if (acceptors.length === 0) {
+ return T.puzzleMenu.validation.noGoalAcceptors;
+ }
+
+ // Check if all acceptors satisfy the constraints
+ for (const acceptor of acceptors) {
+ const goalComp = acceptor.components.GoalAcceptor;
+ if (!goalComp.item) {
+ return T.puzzleMenu.validation.goalAcceptorNoItem;
+ }
+ const required = goalComp.getRequiredDeliveryHistorySize();
+ if (goalComp.deliveryHistory.length < required) {
+ return T.puzzleMenu.validation.goalAcceptorRateNotMet;
+ }
+ }
+
+ // Check if all buildings are within the area
+ const entities = this.root.entityMgr.getAllWithComponent(StaticMapEntityComponent);
+ for (const entity of entities) {
+ if (this.root.systemMgr.systems.zone.prePlacementCheck(entity) === STOP_PROPAGATION) {
+ return T.puzzleMenu.validation.buildingOutOfBounds;
+ }
+ }
+ }
+}
diff --git a/src/js/game/hud/parts/puzzle_editor_settings.js b/src/js/game/hud/parts/puzzle_editor_settings.js
new file mode 100644
index 00000000..cf283a9b
--- /dev/null
+++ b/src/js/game/hud/parts/puzzle_editor_settings.js
@@ -0,0 +1,200 @@
+/* typehints:start */
+import { PuzzleGameMode } from "../../modes/puzzle";
+/* typehints:end */
+
+import { globalConfig } from "../../../core/config";
+import { createLogger } from "../../../core/logging";
+import { Rectangle } from "../../../core/rectangle";
+import { makeDiv } from "../../../core/utils";
+import { T } from "../../../translations";
+import { StaticMapEntityComponent } from "../../components/static_map_entity";
+import { BaseHUDPart } from "../base_hud_part";
+
+const logger = createLogger("puzzle-editor");
+
+export class HUDPuzzleEditorSettings extends BaseHUDPart {
+ createElements(parent) {
+ this.element = makeDiv(parent, "ingame_HUD_PuzzleEditorSettings");
+
+ if (this.root.gameMode.getBuildableZones()) {
+ const bind = (selector, handler) =>
+ this.trackClicks(this.element.querySelector(selector), handler);
+ this.zone = makeDiv(
+ this.element,
+ null,
+ ["section", "zone"],
+ `
+
+
+ `
+ );
+
+ bind(".zoneWidth .minus", () => this.modifyZone(-1, 0));
+ bind(".zoneWidth .plus", () => this.modifyZone(1, 0));
+ bind(".zoneHeight .minus", () => this.modifyZone(0, -1));
+ bind(".zoneHeight .plus", () => this.modifyZone(0, 1));
+ bind("button.trim", this.trim);
+ bind("button.clear", this.clear);
+ }
+ }
+
+ clear() {
+ this.root.logic.clearAllBeltsAndItems();
+ }
+
+ trim() {
+ // Now, find the center
+ const buildings = this.root.entityMgr.entities.slice();
+
+ if (buildings.length === 0) {
+ // nothing to do
+ return;
+ }
+
+ 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;
+ }
+
+ while (!this.anyBuildingOutsideZone(w, h - 1)) {
+ --h;
+ }
+
+ mode.zoneWidth = w;
+ mode.zoneHeight = h;
+ this.updateZoneValues();
+ }
+
+ initialize() {
+ this.visible = true;
+ this.updateZoneValues();
+ }
+
+ anyBuildingOutsideZone(width, height) {
+ if (Math.min(width, height) < globalConfig.puzzleMinBoundsSize) {
+ return true;
+ }
+ const newZone = Rectangle.centered(width, height);
+ const entities = this.root.entityMgr.getAllWithComponent(StaticMapEntityComponent);
+
+ for (const entity of entities) {
+ const staticComp = entity.components.StaticMapEntity;
+ const bounds = staticComp.getTileSpaceBounds();
+ if (!newZone.intersectsFully(bounds)) {
+ return true;
+ }
+ }
+ }
+
+ modifyZone(deltaW, deltaH) {
+ const mode = /** @type {PuzzleGameMode} */ (this.root.gameMode);
+
+ const newWidth = mode.zoneWidth + deltaW;
+ const newHeight = mode.zoneHeight + deltaH;
+
+ if (Math.min(newWidth, newHeight) < globalConfig.puzzleMinBoundsSize) {
+ return;
+ }
+
+ if (Math.max(newWidth, newHeight) > globalConfig.puzzleMaxBoundsSize) {
+ return;
+ }
+
+ if (this.anyBuildingOutsideZone(newWidth, newHeight)) {
+ this.root.hud.parts.dialogs.showWarning(
+ T.dialogs.puzzleResizeBadBuildings.title,
+ T.dialogs.puzzleResizeBadBuildings.desc
+ );
+ return;
+ }
+
+ mode.zoneWidth = newWidth;
+ mode.zoneHeight = newHeight;
+ this.updateZoneValues();
+ }
+
+ updateZoneValues() {
+ const mode = /** @type {PuzzleGameMode} */ (this.root.gameMode);
+
+ this.element.querySelector(".zoneWidth > .value").textContent = String(mode.zoneWidth);
+ this.element.querySelector(".zoneHeight > .value").textContent = String(mode.zoneHeight);
+ }
+}
diff --git a/src/js/game/hud/parts/puzzle_play_metadata.js b/src/js/game/hud/parts/puzzle_play_metadata.js
new file mode 100644
index 00000000..3550a1e6
--- /dev/null
+++ b/src/js/game/hud/parts/puzzle_play_metadata.js
@@ -0,0 +1,72 @@
+/* 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 = puzzle.meta.title;
+
+ this.element = makeDiv(parent, "ingame_HUD_PuzzlePlayMetadata");
+ this.element.innerHTML = `
+
+
+ ${formatBigNumberFull(puzzle.meta.downloads)}
+ ${formatBigNumberFull(puzzle.meta.likes)}
+
+
+
+
+
+ ${puzzle.meta.shortKey}
+
+
+
+ ${puzzle.meta.averageTime ? formatSeconds(puzzle.meta.averageTime) : "-"}
+
+
+
+ ${
+ puzzle.meta.downloads > 0
+ ? ((puzzle.meta.completions / puzzle.meta.downloads) * 100.0).toFixed(1) + "%"
+ : "-"
+ }
+
+
+
+
+
+
+ `;
+
+ 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/hud/parts/sandbox_controller.js b/src/js/game/hud/parts/sandbox_controller.js
index 592487ee..3689fa36 100644
--- a/src/js/game/hud/parts/sandbox_controller.js
+++ b/src/js/game/hud/parts/sandbox_controller.js
@@ -1,3 +1,4 @@
+import { queryParamOptions } from "../../../core/query_parameters";
import { makeDiv } from "../../../core/utils";
import { BaseHUDPart } from "../base_hud_part";
import { DynamicDomAttach } from "../dynamic_dom_attach";
@@ -19,25 +20,25 @@ export class HUDSandboxController extends BaseHUDPart {
-
+
-
+
-
+
-
+
@@ -117,7 +118,9 @@ export class HUDSandboxController extends BaseHUDPart {
// Clear all shapes of this level
hubGoals.storedShapes[hubGoals.currentGoal.definition.getHash()] = 0;
- this.root.hud.parts.pinnedShapes.rerenderFull();
+ if (this.root.hud.parts.pinnedShapes) {
+ this.root.hud.parts.pinnedShapes.rerenderFull();
+ }
// Compute gained rewards
hubGoals.gainedRewards = {};
@@ -144,7 +147,7 @@ export class HUDSandboxController extends BaseHUDPart {
}
});
- this.visible = !G_IS_DEV;
+ this.visible = false;
this.domAttach = new DynamicDomAttach(this.root, this.element);
}
diff --git a/src/js/game/hud/parts/settings_menu.js b/src/js/game/hud/parts/settings_menu.js
index eb902934..16da0440 100644
--- a/src/js/game/hud/parts/settings_menu.js
+++ b/src/js/game/hud/parts/settings_menu.js
@@ -13,17 +13,19 @@ export class HUDSettingsMenu extends BaseHUDPart {
this.menuElement = makeDiv(this.background, null, ["menuElement"]);
- this.statsElement = makeDiv(
- this.background,
- null,
- ["statsElement"],
- `
+ if (this.root.gameMode.hasHub()) {
+ this.statsElement = makeDiv(
+ this.background,
+ null,
+ ["statsElement"],
+ `
${T.ingame.settingsMenu.beltsPlaced}
${T.ingame.settingsMenu.buildingsPlaced}
${T.ingame.settingsMenu.playtime}
`
- );
+ );
+ }
this.buttonContainer = makeDiv(this.menuElement, null, ["buttons"]);
@@ -94,23 +96,25 @@ export class HUDSettingsMenu extends BaseHUDPart {
const totalMinutesPlayed = Math.ceil(this.root.time.now() / 60);
- /** @type {HTMLElement} */
- const playtimeElement = this.statsElement.querySelector(".playtime");
- /** @type {HTMLElement} */
- const buildingsPlacedElement = this.statsElement.querySelector(".buildingsPlaced");
- /** @type {HTMLElement} */
- const beltsPlacedElement = this.statsElement.querySelector(".beltsPlaced");
+ if (this.root.gameMode.hasHub()) {
+ /** @type {HTMLElement} */
+ const playtimeElement = this.statsElement.querySelector(".playtime");
+ /** @type {HTMLElement} */
+ const buildingsPlacedElement = this.statsElement.querySelector(".buildingsPlaced");
+ /** @type {HTMLElement} */
+ const beltsPlacedElement = this.statsElement.querySelector(".beltsPlaced");
- playtimeElement.innerText = T.global.time.xMinutes.replace("
", `${totalMinutesPlayed}`);
+ playtimeElement.innerText = T.global.time.xMinutes.replace("", `${totalMinutesPlayed}`);
- buildingsPlacedElement.innerText = formatBigNumberFull(
- this.root.entityMgr.getAllWithComponent(StaticMapEntityComponent).length -
- this.root.entityMgr.getAllWithComponent(BeltComponent).length
- );
+ buildingsPlacedElement.innerText = formatBigNumberFull(
+ this.root.entityMgr.getAllWithComponent(StaticMapEntityComponent).length -
+ this.root.entityMgr.getAllWithComponent(BeltComponent).length
+ );
- beltsPlacedElement.innerText = formatBigNumberFull(
- this.root.entityMgr.getAllWithComponent(BeltComponent).length
- );
+ beltsPlacedElement.innerText = formatBigNumberFull(
+ this.root.entityMgr.getAllWithComponent(BeltComponent).length
+ );
+ }
}
close() {
diff --git a/src/js/game/hud/parts/waypoints.js b/src/js/game/hud/parts/waypoints.js
index 1a0e3739..2e0bc159 100644
--- a/src/js/game/hud/parts/waypoints.js
+++ b/src/js/game/hud/parts/waypoints.js
@@ -100,16 +100,14 @@ export class HUDWaypoints extends BaseHUDPart {
this.directionIndicatorSprite = Loader.getSprite("sprites/misc/hub_direction_indicator.png");
- /** @type {Array}
- */
- this.waypoints = [
- {
- label: null,
- center: { x: 0, y: 0 },
- zoomLevel: 3,
- layer: gMetaBuildingRegistry.findByClass(MetaHubBuilding).getLayer(),
- },
- ];
+ /** @type {Array} */
+ this.waypoints = [];
+ this.waypoints.push({
+ label: null,
+ center: { x: 0, y: 0 },
+ zoomLevel: 3,
+ layer: gMetaBuildingRegistry.findByClass(MetaHubBuilding).getLayer(),
+ });
// Create a buffer we can use to measure text
this.dummyBuffer = makeOffscreenBuffer(1, 1, {
diff --git a/src/js/game/hud/parts/wires_overlay.js b/src/js/game/hud/parts/wires_overlay.js
index 2fd3092c..328d6689 100644
--- a/src/js/game/hud/parts/wires_overlay.js
+++ b/src/js/game/hud/parts/wires_overlay.js
@@ -28,6 +28,9 @@ export class HUDWiresOverlay extends BaseHUDPart {
* Switches between layers
*/
switchLayers() {
+ if (!this.root.gameMode.getSupportsWires()) {
+ return;
+ }
if (this.root.currentLayer === "regular") {
if (
this.root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_wires_painter_and_levers) ||
diff --git a/src/js/game/key_action_mapper.js b/src/js/game/key_action_mapper.js
index 13f33d66..41208d13 100644
--- a/src/js/game/key_action_mapper.js
+++ b/src/js/game/key_action_mapper.js
@@ -49,6 +49,11 @@ export const KEYMAPPINGS = {
},
buildings: {
+ // Puzzle buildings
+ constant_producer: { keyCode: key("H") },
+ goal_acceptor: { keyCode: key("N") },
+ block: { keyCode: key("4") },
+
// Primary Toolbar
belt: { keyCode: key("1") },
balancer: { keyCode: key("2") },
@@ -102,6 +107,7 @@ export const KEYMAPPINGS = {
massSelectSelectMultiple: { keyCode: 16 }, // SHIFT
massSelectCopy: { keyCode: key("C") },
massSelectCut: { keyCode: key("X") },
+ massSelectClear: { keyCode: key("B") },
confirmMassDelete: { keyCode: 46 }, // DEL
pasteLastBlueprint: { keyCode: key("V") },
},
@@ -262,6 +268,8 @@ export function getStringForKeyCode(code) {
return ".";
case 191:
return "/";
+ case 192:
+ return "`";
case 219:
return "[";
case 220:
@@ -322,6 +330,15 @@ export class Keybinding {
this.signal.add(receiver, scope);
}
+ /**
+ * Adds an event listener
+ * @param {function() : void} receiver
+ * @param {object=} scope
+ */
+ addToTop(receiver, scope = null) {
+ this.signal.addToTop(receiver, scope);
+ }
+
/**
* @param {Element} elem
* @returns {HTMLElement} the created element, or null if the keybindings are not shown
diff --git a/src/js/game/logic.js b/src/js/game/logic.js
index 7ec7b8ab..20caca31 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,13 +162,34 @@ 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
*/
canDeleteBuilding(building) {
const staticComp = building.components.StaticMapEntity;
- return staticComp.getMetaBuilding().getIsRemovable();
+ return staticComp.getMetaBuilding().getIsRemovable(this.root);
}
/**
@@ -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,15 @@ export class GameLogic {
}
return { ejectors, acceptors };
}
+
+ /**
+ * Clears all belts and items
+ */
+ clearAllBeltsAndItems() {
+ 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/map_chunk_view.js b/src/js/game/map_chunk_view.js
index 848afbab..131ce37b 100644
--- a/src/js/game/map_chunk_view.js
+++ b/src/js/game/map_chunk_view.js
@@ -41,7 +41,14 @@ export class MapChunkView extends MapChunk {
*/
drawBackgroundLayer(parameters) {
const systems = this.root.systemMgr.systems;
- systems.mapResources.drawChunk(parameters, this);
+ if (systems.zone) {
+ systems.zone.drawChunk(parameters, this);
+ }
+
+ if (this.root.gameMode.hasResources()) {
+ systems.mapResources.drawChunk(parameters, this);
+ }
+
systems.beltUnderlays.drawChunk(parameters, this);
systems.belt.drawChunk(parameters, this);
}
@@ -69,6 +76,8 @@ export class MapChunkView extends MapChunk {
systems.lever.drawChunk(parameters, this);
systems.display.drawChunk(parameters, this);
systems.storage.drawChunk(parameters, this);
+ systems.constantProducer.drawChunk(parameters, this);
+ systems.goalAcceptor.drawChunk(parameters, this);
systems.itemProcessorOverlays.drawChunk(parameters, this);
}
diff --git a/src/js/game/meta_building.js b/src/js/game/meta_building.js
index 9deee272..f3df0b62 100644
--- a/src/js/game/meta_building.js
+++ b/src/js/game/meta_building.js
@@ -108,9 +108,10 @@ export class MetaBuilding {
/**
* Returns whether this building is removable
+ * @param {GameRoot} root
* @returns {boolean}
*/
- getIsRemovable() {
+ getIsRemovable(root) {
return true;
}
diff --git a/src/js/game/meta_building_registry.js b/src/js/game/meta_building_registry.js
index 0613103e..0c93153d 100644
--- a/src/js/game/meta_building_registry.js
+++ b/src/js/game/meta_building_registry.js
@@ -4,11 +4,14 @@ import { T } from "../translations";
import { MetaAnalyzerBuilding } from "./buildings/analyzer";
import { enumBalancerVariants, MetaBalancerBuilding } from "./buildings/balancer";
import { MetaBeltBuilding } from "./buildings/belt";
+import { MetaBlockBuilding } from "./buildings/block";
import { MetaComparatorBuilding } from "./buildings/comparator";
+import { MetaConstantProducerBuilding } from "./buildings/constant_producer";
import { MetaConstantSignalBuilding } from "./buildings/constant_signal";
import { enumCutterVariants, MetaCutterBuilding } from "./buildings/cutter";
import { MetaDisplayBuilding } from "./buildings/display";
import { MetaFilterBuilding } from "./buildings/filter";
+import { MetaGoalAcceptorBuilding } from "./buildings/goal_acceptor";
import { MetaHubBuilding } from "./buildings/hub";
import { MetaItemProducerBuilding } from "./buildings/item_producer";
import { MetaLeverBuilding } from "./buildings/lever";
@@ -45,6 +48,7 @@ export function initMetaBuildingRegistry() {
gMetaBuildingRegistry.register(MetaStorageBuilding);
gMetaBuildingRegistry.register(MetaBeltBuilding);
gMetaBuildingRegistry.register(MetaUndergroundBeltBuilding);
+ gMetaBuildingRegistry.register(MetaGoalAcceptorBuilding);
gMetaBuildingRegistry.register(MetaHubBuilding);
gMetaBuildingRegistry.register(MetaWireBuilding);
gMetaBuildingRegistry.register(MetaConstantSignalBuilding);
@@ -59,6 +63,8 @@ export function initMetaBuildingRegistry() {
gMetaBuildingRegistry.register(MetaAnalyzerBuilding);
gMetaBuildingRegistry.register(MetaComparatorBuilding);
gMetaBuildingRegistry.register(MetaItemProducerBuilding);
+ gMetaBuildingRegistry.register(MetaConstantProducerBuilding);
+ gMetaBuildingRegistry.register(MetaBlockBuilding);
// Belt
registerBuildingVariant(1, MetaBeltBuilding, defaultBuildingVariant, 0);
@@ -165,6 +171,15 @@ export function initMetaBuildingRegistry() {
// Item producer
registerBuildingVariant(61, MetaItemProducerBuilding);
+ // Constant producer
+ registerBuildingVariant(62, MetaConstantProducerBuilding);
+
+ // Goal acceptor
+ registerBuildingVariant(63, MetaGoalAcceptorBuilding);
+
+ // Block
+ registerBuildingVariant(64, MetaBlockBuilding);
+
// Propagate instances
for (const key in gBuildingVariants) {
gBuildingVariants[key].metaInstance = gMetaBuildingRegistry.findByClass(
diff --git a/src/js/game/modes/puzzle.js b/src/js/game/modes/puzzle.js
new file mode 100644
index 00000000..4bf3b1e6
--- /dev/null
+++ b/src/js/game/modes/puzzle.js
@@ -0,0 +1,106 @@
+/* typehints:start */
+import { GameRoot } from "../root";
+/* typehints:end */
+
+import { Rectangle } from "../../core/rectangle";
+import { types } from "../../savegame/serialization";
+import { enumGameModeTypes, GameMode } from "../game_mode";
+import { HUDPuzzleBackToMenu } from "../hud/parts/puzzle_back_to_menu";
+import { HUDPuzzleDLCLogo } from "../hud/parts/puzzle_dlc_logo";
+
+export class PuzzleGameMode extends GameMode {
+ static getType() {
+ return enumGameModeTypes.puzzle;
+ }
+
+ /** @returns {object} */
+ static getSchema() {
+ return {
+ zoneHeight: types.uint,
+ zoneWidth: types.uint,
+ };
+ }
+
+ /** @param {GameRoot} root */
+ constructor(root) {
+ super(root);
+
+ const data = this.getSaveData();
+
+ this.additionalHudParts = {
+ puzzleBackToMenu: HUDPuzzleBackToMenu,
+ puzzleDlcLogo: HUDPuzzleDLCLogo,
+ };
+
+ this.zoneWidth = data.zoneWidth || 8;
+ this.zoneHeight = data.zoneHeight || 6;
+ }
+
+ /**
+ * @param {typeof import("../meta_building").MetaBuilding} building
+ */
+ isBuildingExcluded(building) {
+ return this.hiddenBuildings.indexOf(building) >= 0;
+ }
+
+ getSaveData() {
+ const save = this.root.savegame.getCurrentDump();
+ if (!save) {
+ return {};
+ }
+ return save.gameMode.data;
+ }
+
+ getCameraBounds() {
+ return Rectangle.centered(this.zoneWidth + 20, this.zoneHeight + 20);
+ }
+
+ getBuildableZones() {
+ return [Rectangle.centered(this.zoneWidth, this.zoneHeight)];
+ }
+
+ hasHub() {
+ return false;
+ }
+
+ hasResources() {
+ return false;
+ }
+
+ getMinimumZoom() {
+ return 1;
+ }
+
+ getMaximumZoom() {
+ return 4;
+ }
+
+ getIsSaveable() {
+ return false;
+ }
+
+ getSupportsCopyPaste() {
+ return false;
+ }
+
+ throughputDoesNotMatter() {
+ return true;
+ }
+
+ getSupportsWires() {
+ return false;
+ }
+
+ getFixedTickrate() {
+ return 300;
+ }
+
+ getIsDeterministic() {
+ return true;
+ }
+
+ /** @returns {boolean} */
+ getIsFreeplayAvailable() {
+ return true;
+ }
+}
diff --git a/src/js/game/modes/puzzle_edit.js b/src/js/game/modes/puzzle_edit.js
new file mode 100644
index 00000000..e3d2e40d
--- /dev/null
+++ b/src/js/game/modes/puzzle_edit.js
@@ -0,0 +1,66 @@
+/* typehints:start */
+import { GameRoot } from "../root";
+/* typehints:end */
+
+import { enumGameModeIds } from "../game_mode";
+import { PuzzleGameMode } from "./puzzle";
+import { MetaStorageBuilding } from "../buildings/storage";
+import { MetaReaderBuilding } from "../buildings/reader";
+import { MetaFilterBuilding } from "../buildings/filter";
+import { MetaDisplayBuilding } from "../buildings/display";
+import { MetaLeverBuilding } from "../buildings/lever";
+import { MetaItemProducerBuilding } from "../buildings/item_producer";
+import { MetaMinerBuilding } from "../buildings/miner";
+import { MetaWireBuilding } from "../buildings/wire";
+import { MetaWireTunnelBuilding } from "../buildings/wire_tunnel";
+import { MetaConstantSignalBuilding } from "../buildings/constant_signal";
+import { MetaLogicGateBuilding } from "../buildings/logic_gate";
+import { MetaVirtualProcessorBuilding } from "../buildings/virtual_processor";
+import { MetaAnalyzerBuilding } from "../buildings/analyzer";
+import { MetaComparatorBuilding } from "../buildings/comparator";
+import { MetaTransistorBuilding } from "../buildings/transistor";
+import { HUDPuzzleEditorControls } from "../hud/parts/puzzle_editor_controls";
+import { HUDPuzzleEditorReview } from "../hud/parts/puzzle_editor_review";
+import { HUDPuzzleEditorSettings } from "../hud/parts/puzzle_editor_settings";
+
+export class PuzzleEditGameMode extends PuzzleGameMode {
+ static getId() {
+ return enumGameModeIds.puzzleEdit;
+ }
+
+ static getSchema() {
+ return {};
+ }
+
+ /** @param {GameRoot} root */
+ constructor(root) {
+ super(root);
+
+ this.hiddenBuildings = [
+ MetaStorageBuilding,
+ MetaReaderBuilding,
+ MetaFilterBuilding,
+ MetaDisplayBuilding,
+ MetaLeverBuilding,
+ MetaItemProducerBuilding,
+ MetaMinerBuilding,
+
+ MetaWireBuilding,
+ MetaWireTunnelBuilding,
+ MetaConstantSignalBuilding,
+ MetaLogicGateBuilding,
+ MetaVirtualProcessorBuilding,
+ MetaAnalyzerBuilding,
+ MetaComparatorBuilding,
+ MetaTransistorBuilding,
+ ];
+
+ this.additionalHudParts.puzzleEditorControls = HUDPuzzleEditorControls;
+ this.additionalHudParts.puzzleEditorReview = HUDPuzzleEditorReview;
+ this.additionalHudParts.puzzleEditorSettings = HUDPuzzleEditorSettings;
+ }
+
+ getIsEditor() {
+ return true;
+ }
+}
diff --git a/src/js/game/modes/puzzle_play.js b/src/js/game/modes/puzzle_play.js
new file mode 100644
index 00000000..46480c51
--- /dev/null
+++ b/src/js/game/modes/puzzle_play.js
@@ -0,0 +1,193 @@
+/* typehints:start */
+import { GameRoot } from "../root";
+/* typehints:end */
+
+import { enumGameModeIds } from "../game_mode";
+import { PuzzleGameMode } from "./puzzle";
+import { MetaStorageBuilding } from "../buildings/storage";
+import { MetaReaderBuilding } from "../buildings/reader";
+import { MetaFilterBuilding } from "../buildings/filter";
+import { MetaDisplayBuilding } from "../buildings/display";
+import { MetaLeverBuilding } from "../buildings/lever";
+import { MetaItemProducerBuilding } from "../buildings/item_producer";
+import { MetaMinerBuilding } from "../buildings/miner";
+import { MetaWireBuilding } from "../buildings/wire";
+import { MetaWireTunnelBuilding } from "../buildings/wire_tunnel";
+import { MetaConstantSignalBuilding } from "../buildings/constant_signal";
+import { MetaLogicGateBuilding } from "../buildings/logic_gate";
+import { MetaVirtualProcessorBuilding } from "../buildings/virtual_processor";
+import { MetaAnalyzerBuilding } from "../buildings/analyzer";
+import { MetaComparatorBuilding } from "../buildings/comparator";
+import { MetaTransistorBuilding } from "../buildings/transistor";
+import { MetaConstantProducerBuilding } from "../buildings/constant_producer";
+import { MetaGoalAcceptorBuilding } from "../buildings/goal_acceptor";
+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";
+import { HUDPuzzlePlaySettings } from "../hud/parts/puzzle_play_settings";
+import { MetaBlockBuilding } from "../buildings/block";
+import { MetaBuilding } from "../meta_building";
+import { gMetaBuildingRegistry } from "../../core/global_registries";
+
+const logger = createLogger("puzzle-play");
+const copy = require("clipboard-copy");
+
+export class PuzzlePlayGameMode extends PuzzleGameMode {
+ static getId() {
+ return enumGameModeIds.puzzlePlay;
+ }
+
+ /**
+ * @param {GameRoot} root
+ * @param {object} payload
+ * @param {import("../../savegame/savegame_typedefs").PuzzleFullData} payload.puzzle
+ */
+ constructor(root, { puzzle }) {
+ super(root);
+
+ /** @type {Array} */
+ let excludedBuildings = [
+ MetaConstantProducerBuilding,
+ MetaGoalAcceptorBuilding,
+ MetaBlockBuilding,
+
+ MetaStorageBuilding,
+ MetaReaderBuilding,
+ MetaFilterBuilding,
+ MetaDisplayBuilding,
+ MetaLeverBuilding,
+ MetaItemProducerBuilding,
+ MetaMinerBuilding,
+
+ MetaWireBuilding,
+ MetaWireTunnelBuilding,
+ MetaConstantSignalBuilding,
+ MetaLogicGateBuilding,
+ MetaVirtualProcessorBuilding,
+ MetaAnalyzerBuilding,
+ MetaComparatorBuilding,
+ MetaTransistorBuilding,
+ ];
+
+ if (puzzle.game.excludedBuildings) {
+ /**
+ * @type {any}
+ */
+ const puzzleHidden = puzzle.game.excludedBuildings
+ .map(id => {
+ if (!gMetaBuildingRegistry.hasId(id)) {
+ return;
+ }
+ return gMetaBuildingRegistry.findById(id).constructor;
+ })
+ .filter(x => !!x);
+ excludedBuildings = excludedBuildings.concat(puzzleHidden);
+ }
+
+ this.hiddenBuildings = excludedBuildings;
+
+ this.additionalHudParts.puzzlePlayMetadata = HUDPuzzlePlayMetadata;
+ this.additionalHudParts.puzzlePlaySettings = HUDPuzzlePlaySettings;
+ this.additionalHudParts.puzzleCompleteNotification = HUDPuzzleCompleteNotification;
+
+ root.signals.postLoadHook.add(this.loadPuzzle, this);
+
+ this.puzzle = puzzle;
+ }
+
+ loadPuzzle() {
+ let errorText;
+ logger.log("Loading puzzle", this.puzzle);
+
+ try {
+ this.zoneWidth = this.puzzle.game.bounds.w;
+ this.zoneHeight = this.puzzle.game.bounds.h;
+ errorText = new PuzzleSerializer().deserializePuzzle(this.root, this.puzzle.game);
+ } catch (ex) {
+ errorText = ex.message || ex;
+ }
+
+ if (errorText) {
+ 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} time
+ */
+ trackCompleted(liked, time) {
+ const closeLoading = this.root.hud.parts.dialogs.showLoadingDialog();
+
+ return this.root.app.clientApi
+ .apiCompletePuzzle(this.puzzle.meta.id, {
+ time,
+ liked,
+ })
+ .catch(err => {
+ logger.warn("Failed to complete puzzle:", err);
+ })
+ .then(() => {
+ 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 },
+ ],
+ }
+ );
+
+ return new Promise(resolve => {
+ optionSelected.add(option => {
+ const closeLoading = this.root.hud.parts.dialogs.showLoadingDialog();
+
+ this.root.app.clientApi.apiReportPuzzle(this.puzzle.meta.id, option).then(
+ () => {
+ closeLoading();
+ const { ok } = this.root.hud.parts.dialogs.showInfo(
+ T.dialogs.puzzleReportComplete.title,
+ T.dialogs.puzzleReportComplete.desc
+ );
+ ok.add(resolve);
+ },
+ err => {
+ closeLoading();
+ const { ok } = this.root.hud.parts.dialogs.showInfo(
+ T.dialogs.puzzleReportError.title,
+ T.dialogs.puzzleReportError.desc + " " + err
+ );
+ }
+ );
+ });
+ });
+ }
+}
diff --git a/src/js/game/modes/regular.js b/src/js/game/modes/regular.js
index e99f4a7c..0b71ff39 100644
--- a/src/js/game/modes/regular.js
+++ b/src/js/game/modes/regular.js
@@ -1,19 +1,74 @@
+/* typehints:start */
+import { GameRoot } from "../root";
+import { MetaBuilding } from "../meta_building";
+/* typehints:end */
+
import { findNiceIntegerValue } from "../../core/utils";
-import { GameMode } from "../game_mode";
+import { MetaConstantProducerBuilding } from "../buildings/constant_producer";
+import { MetaGoalAcceptorBuilding } from "../buildings/goal_acceptor";
+import { enumGameModeIds, enumGameModeTypes, GameMode } from "../game_mode";
import { ShapeDefinition } from "../shape_definition";
import { enumHubGoalRewards } from "../tutorial_goals";
-
-const rocketShape = "CbCuCbCu:Sr------:--CrSrCr:CwCwCwCw";
-const finalGameShape = "RuCw--Cw:----Ru--";
+import { HUDWiresToolbar } from "../hud/parts/wires_toolbar";
+import { HUDBlueprintPlacer } from "../hud/parts/blueprint_placer";
+import { HUDUnlockNotification } from "../hud/parts/unlock_notification";
+import { HUDMassSelector } from "../hud/parts/mass_selector";
+import { HUDShop } from "../hud/parts/shop";
+import { HUDWaypoints } from "../hud/parts/waypoints";
+import { HUDStatistics } from "../hud/parts/statistics";
+import { HUDWireInfo } from "../hud/parts/wire_info";
+import { HUDLeverToggle } from "../hud/parts/lever_toggle";
+import { HUDPinnedShapes } from "../hud/parts/pinned_shapes";
+import { HUDNotifications } from "../hud/parts/notifications";
+import { HUDScreenshotExporter } from "../hud/parts/screenshot_exporter";
+import { HUDWiresOverlay } from "../hud/parts/wires_overlay";
+import { HUDShapeViewer } from "../hud/parts/shape_viewer";
+import { HUDLayerPreview } from "../hud/parts/layer_preview";
+import { HUDTutorialVideoOffer } from "../hud/parts/tutorial_video_offer";
+import { HUDMinerHighlight } from "../hud/parts/miner_highlight";
+import { HUDGameMenu } from "../hud/parts/game_menu";
+import { HUDConstantSignalEdit } from "../hud/parts/constant_signal_edit";
+import { IS_MOBILE } from "../../core/config";
+import { HUDKeybindingOverlay } from "../hud/parts/keybinding_overlay";
+import { HUDWatermark } from "../hud/parts/watermark";
+import { HUDStandaloneAdvantages } from "../hud/parts/standalone_advantages";
+import { HUDCatMemes } from "../hud/parts/cat_memes";
+import { HUDPartTutorialHints } from "../hud/parts/tutorial_hints";
+import { HUDInteractiveTutorial } from "../hud/parts/interactive_tutorial";
+import { HUDSandboxController } from "../hud/parts/sandbox_controller";
+import { queryParamOptions } from "../../core/query_parameters";
+import { MetaBlockBuilding } from "../buildings/block";
+
+/** @typedef {{
+ * shape: string,
+ * amount: number
+ * }} UpgradeRequirement */
+
+/** @typedef {{
+ * required: Array
+ * improvement?: number,
+ * excludePrevious?: boolean
+ * }} TierRequirement */
+
+/** @typedef {Array} UpgradeTiers */
+
+/** @typedef {{
+ * shape: string,
+ * required: number,
+ * reward: enumHubGoalRewards,
+ * throughputOnly?: boolean
+ * }} LevelDefinition */
+
+export const rocketShape = "CbCuCbCu:Sr------:--CrSrCr:CwCwCwCw";
+export const finalGameShape = "RuCw--Cw:----Ru--";
const preparementShape = "CpRpCp--:SwSwSwSw";
-const blueprintShape = "CbCbCbRb:CwCwCwCw";
// Tiers need % of the previous tier as requirement too
const tierGrowth = 2.5;
/**
* Generates all upgrades
- * @returns {Object} */
+ * @returns {Object} */
function generateUpgrades(limitedVersion = false) {
const fixedImprovements = [0.5, 0.5, 1, 1, 2, 1, 1];
const numEndgameUpgrades = limitedVersion ? 0 : 1000 - fixedImprovements.length - 1;
@@ -87,7 +142,14 @@ function generateUpgrades(limitedVersion = false) {
required: [{ shape: "CwCwCwCw:WbWbWbWb", amount: 23000 }],
},
{
- required: [{ shape: "CbRbRbCb:CwCwCwCw:WbWbWbWb", amount: 50000 }],
+ required: [
+ {
+ shape: G_CHINA_VERSION
+ ? "CyCyCyCy:CyCyCyCy:RyRyRyRy:RuRuRuRu"
+ : "CbRbRbCb:CwCwCwCw:WbWbWbWb",
+ amount: 50000,
+ },
+ ],
},
{
required: [{ shape: preparementShape, amount: 25000 }],
@@ -141,7 +203,12 @@ function generateUpgrades(limitedVersion = false) {
required: [{ shape: "WrWrWrWr", amount: 3800 }],
},
{
- required: [{ shape: "RpRpRpRp:CwCwCwCw", amount: 6500 }],
+ required: [
+ {
+ shape: G_CHINA_VERSION ? "CuCuCuCu:CwCwCwCw:Sb--Sr--" : "RpRpRpRp:CwCwCwCw",
+ amount: 6500,
+ },
+ ],
},
{
required: [{ shape: "WpWpWpWp:CwCwCwCw:WpWpWpWp", amount: 25000 }],
@@ -315,7 +382,7 @@ export function generateLevelDefinitions(limitedVersion = false) {
// 13
// Tunnel Tier 2
{
- shape: "RpRpRpRp:CwCwCwCw", // painting t3
+ shape: G_CHINA_VERSION ? "CuCuCuCu:CwCwCwCw:Sb--Sr--" : "RpRpRpRp:CwCwCwCw", // painting t3
required: 3800,
reward: enumHubGoalRewards.reward_underground_belt_tier_2,
},
@@ -324,7 +391,7 @@ export function generateLevelDefinitions(limitedVersion = false) {
...(limitedVersion
? [
{
- shape: "RpRpRpRp:CwCwCwCw",
+ shape: G_CHINA_VERSION ? "CuCuCuCu:CwCwCwCw:Sb--Sr--" : "RpRpRpRp:CwCwCwCw",
required: 0,
reward: enumHubGoalRewards.reward_demo_end,
},
@@ -358,7 +425,9 @@ export function generateLevelDefinitions(limitedVersion = false) {
// 17
// Double painter
{
- shape: "CbRbRbCb:CwCwCwCw:WbWbWbWb", // miner t4 (two variants)
+ shape: G_CHINA_VERSION
+ ? "CyCyCyCy:CyCyCyCy:RyRyRyRy:RuRuRuRu"
+ : "CbRbRbCb:CwCwCwCw:WbWbWbWb", // miner t4 (two variants)
required: 20000,
reward: enumHubGoalRewards.reward_painter_double,
},
@@ -398,7 +467,9 @@ export function generateLevelDefinitions(limitedVersion = false) {
// 22
// Constant signal
{
- shape: "Cg----Cr:Cw----Cw:Sy------:Cy----Cy",
+ shape: G_CHINA_VERSION
+ ? "RrSySrSy:RyCrCwCr:CyCyRyCy"
+ : "Cg----Cr:Cw----Cw:Sy------:Cy----Cy",
required: 25000,
reward: enumHubGoalRewards.reward_constant_signal,
},
@@ -406,14 +477,18 @@ export function generateLevelDefinitions(limitedVersion = false) {
// 23
// Display
{
- shape: "CcSyCcSy:SyCcSyCc:CcSyCcSy",
+ shape: G_CHINA_VERSION
+ ? "CrCrCrCr:CwCwCwCw:WwWwWwWw:CrCrCrCr"
+ : "CcSyCcSy:SyCcSyCc:CcSyCcSy",
required: 25000,
reward: enumHubGoalRewards.reward_display,
},
// 24 Logic gates
{
- shape: "CcRcCcRc:RwCwRwCw:Sr--Sw--:CyCyCyCy",
+ shape: G_CHINA_VERSION
+ ? "Su----Su:RwRwRwRw:Cu----Cu:CwCwCwCw"
+ : "CcRcCcRc:RwCwRwCw:Sr--Sw--:CyCyCyCy",
required: 25000,
reward: enumHubGoalRewards.reward_logic_gates,
},
@@ -454,27 +529,90 @@ const fullVersionLevels = generateLevelDefinitions(false);
const demoVersionLevels = generateLevelDefinitions(true);
export class RegularGameMode extends GameMode {
+ static getId() {
+ return enumGameModeIds.regular;
+ }
+
+ static getType() {
+ return enumGameModeTypes.default;
+ }
+
+ /** @param {GameRoot} root */
constructor(root) {
super(root);
+
+ this.additionalHudParts = {
+ wiresToolbar: HUDWiresToolbar,
+ blueprintPlacer: HUDBlueprintPlacer,
+ unlockNotification: HUDUnlockNotification,
+ massSelector: HUDMassSelector,
+ shop: HUDShop,
+ statistics: HUDStatistics,
+ waypoints: HUDWaypoints,
+ wireInfo: HUDWireInfo,
+ leverToggle: HUDLeverToggle,
+ pinnedShapes: HUDPinnedShapes,
+ notifications: HUDNotifications,
+ screenshotExporter: HUDScreenshotExporter,
+ wiresOverlay: HUDWiresOverlay,
+ shapeViewer: HUDShapeViewer,
+ layerPreview: HUDLayerPreview,
+ minerHighlight: HUDMinerHighlight,
+ tutorialVideoOffer: HUDTutorialVideoOffer,
+ gameMenu: HUDGameMenu,
+ constantSignalEdit: HUDConstantSignalEdit,
+ };
+
+ if (!IS_MOBILE) {
+ this.additionalHudParts.keybindingOverlay = HUDKeybindingOverlay;
+ }
+
+ if (this.root.app.restrictionMgr.getIsStandaloneMarketingActive()) {
+ this.additionalHudParts.watermark = HUDWatermark;
+ this.additionalHudParts.standaloneAdvantages = HUDStandaloneAdvantages;
+ this.additionalHudParts.catMemes = HUDCatMemes;
+ }
+
+ if (this.root.app.settings.getAllSettings().offerHints) {
+ this.additionalHudParts.tutorialHints = HUDPartTutorialHints;
+ this.additionalHudParts.interactiveTutorial = HUDInteractiveTutorial;
+ }
+
+ // @ts-ignore
+ if (queryParamOptions.sandboxMode || window.sandboxMode || G_IS_DEV) {
+ this.additionalHudParts.sandboxController = HUDSandboxController;
+ }
+
+ /** @type {(typeof MetaBuilding)[]} */
+ this.hiddenBuildings = [MetaConstantProducerBuilding, MetaGoalAcceptorBuilding, MetaBlockBuilding];
}
+ /**
+ * Should return all available upgrades
+ * @returns {Object}
+ */
getUpgrades() {
return this.root.app.restrictionMgr.getHasExtendedUpgrades()
? fullVersionUpgrades
: demoVersionUpgrades;
}
- getIsFreeplayAvailable() {
- return this.root.app.restrictionMgr.getHasExtendedLevelsAndFreeplay();
- }
-
- getBlueprintShapeKey() {
- return blueprintShape;
- }
-
+ /**
+ * Returns the goals for all levels including their reward
+ * @returns {Array}
+ */
getLevelDefinitions() {
return this.root.app.restrictionMgr.getHasExtendedLevelsAndFreeplay()
? fullVersionLevels
: demoVersionLevels;
}
+
+ /**
+ * Should return whether free play is available or if the game stops
+ * after the predefined levels
+ * @returns {boolean}
+ */
+ getIsFreeplayAvailable() {
+ return this.root.app.restrictionMgr.getHasExtendedLevelsAndFreeplay();
+ }
}
diff --git a/src/js/game/root.js b/src/js/game/root.js
index 82d1e49f..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()),
@@ -183,6 +189,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/belt.js b/src/js/game/systems/belt.js
index 10543e6c..00491eff 100644
--- a/src/js/game/systems/belt.js
+++ b/src/js/game/systems/belt.js
@@ -164,7 +164,10 @@ export class BeltSystem extends GameSystemWithFilter {
// Compute delta to see if anything changed
const newDirection = arrayBeltVariantToRotation[rotationVariant];
- if (targetStaticComp.rotation !== rotation || newDirection !== targetBeltComp.direction) {
+ if (
+ !this.root.immutableOperationRunning &&
+ (targetStaticComp.rotation !== rotation || newDirection !== targetBeltComp.direction)
+ ) {
const originalPath = targetBeltComp.assignedPath;
// Ok, first remove it from its current path
diff --git a/src/js/game/systems/belt_reader.js b/src/js/game/systems/belt_reader.js
index fbd00b6c..f6080aa9 100644
--- a/src/js/game/systems/belt_reader.js
+++ b/src/js/game/systems/belt_reader.js
@@ -14,7 +14,6 @@ export class BeltReaderSystem extends GameSystemWithFilter {
const minimumTimeForThroughput = now - 1;
for (let i = 0; i < this.allEntities.length; ++i) {
const entity = this.allEntities[i];
-
const readerComp = entity.components.BeltReader;
const pinsComp = entity.components.WiredPins;
@@ -23,12 +22,14 @@ export class BeltReaderSystem extends GameSystemWithFilter {
readerComp.lastItemTimes.shift();
}
- pinsComp.slots[1].value = readerComp.lastItem;
- pinsComp.slots[0].value =
- (readerComp.lastItemTimes[readerComp.lastItemTimes.length - 1] || 0) >
- minimumTimeForThroughput
- ? BOOL_TRUE_SINGLETON
- : BOOL_FALSE_SINGLETON;
+ if (!entity.components.BeltReader.isWireless()) {
+ pinsComp.slots[1].value = readerComp.lastItem;
+ pinsComp.slots[0].value =
+ (readerComp.lastItemTimes[readerComp.lastItemTimes.length - 1] || 0) >
+ minimumTimeForThroughput
+ ? BOOL_TRUE_SINGLETON
+ : BOOL_FALSE_SINGLETON;
+ }
if (now - readerComp.lastThroughputComputation > 0.5) {
// Compute throughput
diff --git a/src/js/game/systems/constant_producer.js b/src/js/game/systems/constant_producer.js
new file mode 100644
index 00000000..fa9f9e52
--- /dev/null
+++ b/src/js/game/systems/constant_producer.js
@@ -0,0 +1,66 @@
+import { globalConfig } from "../../core/config";
+import { DrawParameters } from "../../core/draw_parameters";
+import { Vector } from "../../core/vector";
+import { ConstantSignalComponent } from "../components/constant_signal";
+import { ItemProducerComponent } from "../components/item_producer";
+import { GameSystemWithFilter } from "../game_system_with_filter";
+import { MapChunk } from "../map_chunk";
+import { GameRoot } from "../root";
+
+export class ConstantProducerSystem extends GameSystemWithFilter {
+ /** @param {GameRoot} root */
+ constructor(root) {
+ super(root, [ConstantSignalComponent, ItemProducerComponent]);
+ }
+
+ update() {
+ for (let i = 0; i < this.allEntities.length; ++i) {
+ const entity = this.allEntities[i];
+ const producerComp = entity.components.ItemProducer;
+ const signalComp = entity.components.ConstantSignal;
+
+ if (!producerComp.isWireless() || !signalComp.isWireless()) {
+ continue;
+ }
+
+ const ejectorComp = entity.components.ItemEjector;
+
+ ejectorComp.tryEject(0, signalComp.signal);
+ }
+ }
+
+ /**
+ *
+ * @param {DrawParameters} parameters
+ * @param {MapChunk} chunk
+ * @returns
+ */
+ drawChunk(parameters, chunk) {
+ const contents = chunk.containedEntitiesByLayer.regular;
+ for (let i = 0; i < contents.length; ++i) {
+ const producerComp = contents[i].components.ItemProducer;
+ const signalComp = contents[i].components.ConstantSignal;
+
+ if (!producerComp || !producerComp.isWireless() || !signalComp || !signalComp.isWireless()) {
+ continue;
+ }
+
+ const staticComp = contents[i].components.StaticMapEntity;
+ const item = signalComp.signal;
+
+ if (!item) {
+ continue;
+ }
+
+ const center = staticComp.getTileSpaceBounds().getCenter().toWorldSpace();
+
+ const localOffset = new Vector(0, 1).rotateFastMultipleOf90(staticComp.rotation);
+ item.drawItemCenteredClipped(
+ center.x + localOffset.x,
+ center.y + localOffset.y,
+ parameters,
+ globalConfig.tileSize * 0.65
+ );
+ }
+ }
+}
diff --git a/src/js/game/systems/constant_signal.js b/src/js/game/systems/constant_signal.js
index d698c1d5..bcaa0583 100644
--- a/src/js/game/systems/constant_signal.js
+++ b/src/js/game/systems/constant_signal.js
@@ -6,7 +6,7 @@ import { fillInLinkIntoTranslation } from "../../core/utils";
import { T } from "../../translations";
import { BaseItem } from "../base_item";
import { enumColors } from "../colors";
-import { ConstantSignalComponent } from "../components/constant_signal";
+import { ConstantSignalComponent, enumConstantSignalType } from "../components/constant_signal";
import { Entity } from "../entity";
import { GameSystemWithFilter } from "../game_system_with_filter";
import { BOOL_FALSE_SINGLETON, BOOL_TRUE_SINGLETON } from "../items/boolean_item";
@@ -26,8 +26,13 @@ export class ConstantSignalSystem extends GameSystemWithFilter {
// Set signals
for (let i = 0; i < this.allEntities.length; ++i) {
const entity = this.allEntities[i];
- const pinsComp = entity.components.WiredPins;
const signalComp = entity.components.ConstantSignal;
+
+ if (signalComp.isWireless()) {
+ continue;
+ }
+
+ const pinsComp = entity.components.WiredPins;
pinsComp.slots[0].value = signalComp.signal;
}
}
@@ -51,31 +56,50 @@ export class ConstantSignalSystem extends GameSystemWithFilter {
label: fillInLinkIntoTranslation(T.dialogs.editSignal.descShortKey, THIRDPARTY_URLS.shapeViewer),
placeholder: "",
defaultValue: "",
- validator: val => this.parseSignalCode(val),
+ validator: val => this.parseSignalCode(entity.components.ConstantSignal.type, val),
});
- const itemInput = new FormElementItemChooser({
- id: "signalItem",
- label: null,
- items: [
- BOOL_FALSE_SINGLETON,
- BOOL_TRUE_SINGLETON,
- ...Object.values(COLOR_ITEM_SINGLETONS),
- this.root.shapeDefinitionMgr.getShapeItemFromDefinition(
- this.root.hubGoals.currentGoal.definition
- ),
+ 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()) {
+ items.push(
+ this.root.shapeDefinitionMgr.getShapeItemFromDefinition(
+ this.root.hubGoals.currentGoal.definition
+ )
+ );
+ }
+
+ if (this.root.hud.parts.pinnedShapes) {
+ items.push(
...this.root.hud.parts.pinnedShapes.pinnedShapes.map(key =>
this.root.shapeDefinitionMgr.getShapeItemFromShortKey(key)
- ),
- ],
+ )
+ );
+ }
+
+ const itemInput = new FormElementItemChooser({
+ id: "signalItem",
+ label: null,
+ items,
});
const dialog = new DialogWithForm({
app: this.root.app,
- title: T.dialogs.editSignal.title,
+ title: T.dialogs.editConstantProducer.title,
desc: T.dialogs.editSignal.descItems,
formElements: [itemInput, signalValueInput],
buttons: ["cancel:bad:escape", "ok:good:enter"],
@@ -103,15 +127,22 @@ export class ConstantSignalSystem extends GameSystemWithFilter {
}
if (itemInput.chosenItem) {
- console.log(itemInput.chosenItem);
constantComp.signal = itemInput.chosenItem;
} else {
- constantComp.signal = this.parseSignalCode(signalValueInput.getValue());
+ constantComp.signal = this.parseSignalCode(
+ entity.components.ConstantSignal.type,
+ signalValueInput.getValue()
+ );
}
};
- dialog.buttonSignals.ok.add(closeHandler);
- dialog.valueChosen.add(closeHandler);
+ dialog.buttonSignals.ok.add(() => {
+ closeHandler();
+ });
+ dialog.valueChosen.add(() => {
+ dialog.closeRequested.dispatch();
+ closeHandler();
+ });
// When cancelled, destroy the entity again
if (deleteOnCancel) {
@@ -140,10 +171,11 @@ export class ConstantSignalSystem extends GameSystemWithFilter {
/**
* Tries to parse a signal code
+ * @param {string} type
* @param {string} code
* @returns {BaseItem}
*/
- parseSignalCode(code) {
+ parseSignalCode(type, code) {
if (!this.root || !this.root.shapeDefinitionMgr) {
// Stale reference
return null;
@@ -155,12 +187,15 @@ export class ConstantSignalSystem extends GameSystemWithFilter {
if (enumColors[codeLower]) {
return COLOR_ITEM_SINGLETONS[codeLower];
}
- if (code === "1" || codeLower === "true") {
- return BOOL_TRUE_SINGLETON;
- }
- if (code === "0" || codeLower === "false") {
- return BOOL_FALSE_SINGLETON;
+ if (type === enumConstantSignalType.wired) {
+ if (code === "1" || codeLower === "true") {
+ return BOOL_TRUE_SINGLETON;
+ }
+
+ if (code === "0" || codeLower === "false") {
+ return BOOL_FALSE_SINGLETON;
+ }
}
if (ShapeDefinition.isValidShortKey(code)) {
diff --git a/src/js/game/systems/goal_acceptor.js b/src/js/game/systems/goal_acceptor.js
new file mode 100644
index 00000000..75b286d3
--- /dev/null
+++ b/src/js/game/systems/goal_acceptor.js
@@ -0,0 +1,132 @@
+import { globalConfig } from "../../core/config";
+import { DrawParameters } from "../../core/draw_parameters";
+import { clamp, lerp } from "../../core/utils";
+import { Vector } from "../../core/vector";
+import { GoalAcceptorComponent } from "../components/goal_acceptor";
+import { GameSystemWithFilter } from "../game_system_with_filter";
+import { MapChunk } from "../map_chunk";
+import { GameRoot } from "../root";
+
+export class GoalAcceptorSystem extends GameSystemWithFilter {
+ /** @param {GameRoot} root */
+ constructor(root) {
+ super(root, [GoalAcceptorComponent]);
+
+ this.puzzleCompleted = false;
+ }
+
+ update() {
+ const now = this.root.time.now();
+
+ let allAccepted = true;
+
+ for (let i = 0; i < this.allEntities.length; ++i) {
+ const entity = this.allEntities[i];
+ const goalComp = entity.components.GoalAcceptor;
+
+ // filter the ones which are no longer active, or which are not the same
+ goalComp.deliveryHistory = goalComp.deliveryHistory.filter(
+ d =>
+ now - d.time < globalConfig.goalAcceptorMinimumDurationSeconds && d.item === goalComp.item
+ );
+
+ if (goalComp.deliveryHistory.length < goalComp.getRequiredDeliveryHistorySize()) {
+ allAccepted = false;
+ }
+ }
+
+ if (
+ !this.puzzleCompleted &&
+ this.root.gameInitialized &&
+ allAccepted &&
+ !this.root.gameMode.getIsEditor()
+ ) {
+ this.root.signals.puzzleComplete.dispatch();
+ this.puzzleCompleted = true;
+ }
+ }
+
+ /**
+ *
+ * @param {DrawParameters} parameters
+ * @param {MapChunk} chunk
+ * @returns
+ */
+ drawChunk(parameters, chunk) {
+ const contents = chunk.containedEntitiesByLayer.regular;
+ for (let i = 0; i < contents.length; ++i) {
+ const goalComp = contents[i].components.GoalAcceptor;
+
+ if (!goalComp) {
+ continue;
+ }
+
+ const staticComp = contents[i].components.StaticMapEntity;
+ const item = goalComp.item;
+
+ const requiredItemsForSuccess = goalComp.getRequiredDeliveryHistorySize();
+ const percentage = clamp(goalComp.deliveryHistory.length / requiredItemsForSuccess, 0, 1);
+
+ const center = staticComp.getTileSpaceBounds().getCenter().toWorldSpace();
+ if (item) {
+ const localOffset = new Vector(0, -1.8).rotateFastMultipleOf90(staticComp.rotation);
+ item.drawItemCenteredClipped(
+ center.x + localOffset.x,
+ center.y + localOffset.y,
+ parameters,
+ globalConfig.tileSize * 0.65
+ );
+ }
+
+ const isValid = item && goalComp.deliveryHistory.length >= requiredItemsForSuccess;
+
+ parameters.context.translate(center.x, center.y);
+ parameters.context.rotate((staticComp.rotation / 180) * Math.PI);
+
+ parameters.context.lineWidth = 1;
+ parameters.context.fillStyle = "#8de255";
+ parameters.context.strokeStyle = "#64666e";
+ parameters.context.lineCap = "round";
+
+ // progress arc
+
+ goalComp.displayPercentage = lerp(goalComp.displayPercentage, percentage, 0.3);
+
+ const startAngle = Math.PI * 0.595;
+ const maxAngle = Math.PI * 1.82;
+ parameters.context.beginPath();
+ parameters.context.arc(
+ 0.25,
+ -1.5,
+ 11.6,
+ startAngle,
+ startAngle + goalComp.displayPercentage * maxAngle,
+ false
+ );
+ parameters.context.arc(
+ 0.25,
+ -1.5,
+ 15.5,
+ startAngle + goalComp.displayPercentage * maxAngle,
+ startAngle,
+ true
+ );
+ parameters.context.closePath();
+ parameters.context.fill();
+ parameters.context.stroke();
+ parameters.context.lineCap = "butt";
+
+ // LED indicator
+
+ parameters.context.lineWidth = 1;
+ parameters.context.strokeStyle = "#64666e";
+ parameters.context.fillStyle = isValid ? "#8de255" : "#ff666a";
+ parameters.context.beginCircle(10, 11.8, 3);
+ parameters.context.fill();
+ parameters.context.stroke();
+
+ parameters.context.rotate((-staticComp.rotation / 180) * Math.PI);
+ parameters.context.translate(-center.x, -center.y);
+ }
+ }
+}
diff --git a/src/js/game/systems/item_ejector.js b/src/js/game/systems/item_ejector.js
index 1daaad6b..db37455a 100644
--- a/src/js/game/systems/item_ejector.js
+++ b/src/js/game/systems/item_ejector.js
@@ -239,6 +239,14 @@ export class ItemEjectorSystem extends GameSystemWithFilter {
return false;
}
+ ////////////////////////////////////////////////////////////////////////////////
+ ////////////////////////////////////////////////////////////////////////////////
+ //
+ // NOTICE ! THIS CODE IS DUPLICATED IN THE BELT PATH FOR PERFORMANCE REASONS
+ //
+ ////////////////////////////////////////////////////////////////////////////////
+ ////////////////////////////////////////////////////////////////////////////////
+
const itemProcessorComp = receiver.components.ItemProcessor;
if (itemProcessorComp) {
// Check for potential filters
diff --git a/src/js/game/systems/item_processor.js b/src/js/game/systems/item_processor.js
index 9775afde..e06d4a21 100644
--- a/src/js/game/systems/item_processor.js
+++ b/src/js/game/systems/item_processor.js
@@ -59,6 +59,7 @@ export class ItemProcessorSystem extends GameSystemWithFilter {
[enumItemProcessorTypes.painterQuad]: this.process_PAINTER_QUAD,
[enumItemProcessorTypes.hub]: this.process_HUB,
[enumItemProcessorTypes.reader]: this.process_READER,
+ [enumItemProcessorTypes.goal]: this.process_GOAL,
};
// Bind all handlers
@@ -562,4 +563,32 @@ export class ItemProcessorSystem extends GameSystemWithFilter {
this.root.hubGoals.handleDefinitionDelivered(item.definition);
}
}
+
+ /**
+ * @param {ProcessorImplementationPayload} payload
+ */
+ process_GOAL(payload) {
+ const goalComp = payload.entity.components.GoalAcceptor;
+ const item = payload.items[0].item;
+ const now = this.root.time.now();
+
+ if (this.root.gameMode.getIsEditor()) {
+ // while playing in editor, assign the item
+ goalComp.item = payload.items[0].item;
+ goalComp.deliveryHistory.push({
+ item,
+ time: now,
+ });
+ } else {
+ // otherwise, make sure it is the same, otherwise reset
+ if (item.equals(goalComp.item)) {
+ goalComp.deliveryHistory.push({
+ item,
+ time: now,
+ });
+ } else {
+ goalComp.deliveryHistory = [];
+ }
+ }
+ }
}
diff --git a/src/js/game/systems/item_producer.js b/src/js/game/systems/item_producer.js
index 52edf5d1..be78e4e8 100644
--- a/src/js/game/systems/item_producer.js
+++ b/src/js/game/systems/item_producer.js
@@ -1,14 +1,27 @@
+/* typehints:start */
+import { GameRoot } from "../root";
+/* typehints:end */
+
import { ItemProducerComponent } from "../components/item_producer";
import { GameSystemWithFilter } from "../game_system_with_filter";
export class ItemProducerSystem extends GameSystemWithFilter {
+ /** @param {GameRoot} root */
constructor(root) {
super(root, [ItemProducerComponent]);
+ this.item = null;
}
update() {
for (let i = 0; i < this.allEntities.length; ++i) {
const entity = this.allEntities[i];
+ const producerComp = entity.components.ItemProducer;
+ const ejectorComp = entity.components.ItemEjector;
+
+ if (producerComp.isWireless()) {
+ continue;
+ }
+
const pinsComp = entity.components.WiredPins;
const pin = pinsComp.slots[0];
const network = pin.linkedNetwork;
@@ -17,8 +30,8 @@ export class ItemProducerSystem extends GameSystemWithFilter {
continue;
}
- const ejectorComp = entity.components.ItemEjector;
- ejectorComp.tryEject(0, network.currentValue);
+ this.item = network.currentValue;
+ ejectorComp.tryEject(0, this.item);
}
}
}
diff --git a/src/js/game/systems/underground_belt.js b/src/js/game/systems/underground_belt.js
index 7a7609f8..9b31eec1 100644
--- a/src/js/game/systems/underground_belt.js
+++ b/src/js/game/systems/underground_belt.js
@@ -224,13 +224,16 @@ export class UndergroundBeltSystem extends GameSystemWithFilter {
update() {
this.staleAreaWatcher.update();
+ const sender = enumUndergroundBeltMode.sender;
+ const now = this.root.time.now();
+
for (let i = 0; i < this.allEntities.length; ++i) {
const entity = this.allEntities[i];
const undergroundComp = entity.components.UndergroundBelt;
- if (undergroundComp.mode === enumUndergroundBeltMode.sender) {
+ if (undergroundComp.mode === sender) {
this.handleSender(entity);
} else {
- this.handleReceiver(entity);
+ this.handleReceiver(entity, now);
}
}
}
@@ -327,14 +330,15 @@ export class UndergroundBeltSystem extends GameSystemWithFilter {
/**
*
* @param {Entity} entity
+ * @param {number} now
*/
- handleReceiver(entity) {
+ handleReceiver(entity, now) {
const undergroundComp = entity.components.UndergroundBelt;
// Try to eject items, we only check the first one because it is sorted by remaining time
const nextItemAndDuration = undergroundComp.pendingItems[0];
if (nextItemAndDuration) {
- if (this.root.time.now() > nextItemAndDuration[1]) {
+ if (now > nextItemAndDuration[1]) {
const ejectorComp = entity.components.ItemEjector;
const nextSlotIndex = ejectorComp.getFirstFreeSlot();
diff --git a/src/js/game/systems/zone.js b/src/js/game/systems/zone.js
new file mode 100644
index 00000000..109f5166
--- /dev/null
+++ b/src/js/game/systems/zone.js
@@ -0,0 +1,105 @@
+/* typehints:start */
+import { DrawParameters } from "../../core/draw_parameters";
+import { MapChunkView } from "../map_chunk_view";
+import { GameRoot } from "../root";
+/* typehints:end */
+
+import { globalConfig } from "../../core/config";
+import { STOP_PROPAGATION } from "../../core/signal";
+import { GameSystem } from "../game_system";
+import { THEME } from "../theme";
+import { Entity } from "../entity";
+import { Vector } from "../../core/vector";
+
+export class ZoneSystem extends GameSystem {
+ /** @param {GameRoot} root */
+ constructor(root) {
+ super(root);
+ this.drawn = false;
+ this.root.signals.prePlacementCheck.add(this.prePlacementCheck, this);
+
+ this.root.signals.gameFrameStarted.add(() => {
+ this.drawn = false;
+ });
+ }
+
+ /**
+ *
+ * @param {Entity} entity
+ * @param {Vector | undefined} tile
+ * @returns
+ */
+ prePlacementCheck(entity, tile = null) {
+ const staticComp = entity.components.StaticMapEntity;
+
+ if (!staticComp) {
+ return;
+ }
+
+ const mode = this.root.gameMode;
+
+ const zones = mode.getBuildableZones();
+ if (!zones) {
+ return;
+ }
+
+ const transformed = staticComp.getTileSpaceBounds();
+ if (tile) {
+ transformed.x += tile.x;
+ transformed.y += tile.y;
+ }
+
+ if (!zones.some(zone => zone.intersectsFully(transformed))) {
+ return STOP_PROPAGATION;
+ }
+ }
+
+ /**
+ * Draws the zone
+ * @param {DrawParameters} parameters
+ * @param {MapChunkView} chunk
+ */
+ drawChunk(parameters, chunk) {
+ if (this.drawn) {
+ // oof
+ return;
+ }
+ this.drawn = true;
+
+ const mode = this.root.gameMode;
+
+ const zones = mode.getBuildableZones();
+ if (!zones) {
+ return;
+ }
+
+ const zone = zones[0].allScaled(globalConfig.tileSize);
+ const context = parameters.context;
+
+ context.lineWidth = 2;
+ context.strokeStyle = THEME.map.zone.borderSolid;
+ context.beginPath();
+ context.rect(zone.x - 1, zone.y - 1, zone.w + 2, zone.h + 2);
+ context.stroke();
+
+ const outer = zone;
+ const padding = 40 * globalConfig.tileSize;
+ context.fillStyle = THEME.map.zone.outerColor;
+ context.fillRect(outer.x + outer.w, outer.y, padding, outer.h);
+ context.fillRect(outer.x - padding, outer.y, padding, outer.h);
+ context.fillRect(
+ outer.x - padding - globalConfig.tileSize,
+ outer.y - padding,
+ 2 * padding + zone.w + 2 * globalConfig.tileSize,
+ padding
+ );
+ context.fillRect(
+ outer.x - padding - globalConfig.tileSize,
+ outer.y + outer.h,
+ 2 * padding + zone.w + 2 * globalConfig.tileSize,
+ padding
+ );
+
+ context.globalAlpha = 1;
+ }
+}
diff --git a/src/js/game/themes/dark.json b/src/js/game/themes/dark.json
index 733b7682..cb111430 100644
--- a/src/js/game/themes/dark.json
+++ b/src/js/game/themes/dark.json
@@ -47,6 +47,11 @@
"textColor": "#fff",
"textColorCapped": "#ef5072",
"background": "rgba(40, 50, 60, 0.8)"
+ },
+
+ "zone": {
+ "borderSolid": "rgba(23, 192, 255, 1)",
+ "outerColor": "rgba(20 , 20, 25, 0.5)"
}
},
diff --git a/src/js/game/themes/light.json b/src/js/game/themes/light.json
index 0c793c26..0962eb93 100644
--- a/src/js/game/themes/light.json
+++ b/src/js/game/themes/light.json
@@ -48,6 +48,11 @@
"textColor": "#fff",
"textColorCapped": "#ef5072",
"background": "rgba(40, 50, 60, 0.8)"
+ },
+
+ "zone": {
+ "borderSolid": "rgba(23, 192, 255, 1)",
+ "outerColor": "rgba(240, 240, 255, 0.5)"
}
},
diff --git a/src/js/globals.d.ts b/src/js/globals.d.ts
index d1fb5305..5bb3bbba 100644
--- a/src/js/globals.d.ts
+++ b/src/js/globals.d.ts
@@ -185,6 +185,7 @@ declare const STOP_PROPAGATION = "stop_propagation";
declare interface TypedSignal> {
add(receiver: (...args: T) => /* STOP_PROPAGATION */ string | void, scope?: object);
+ addToTop(receiver: (...args: T) => /* STOP_PROPAGATION */ string | void, scope?: object);
remove(receiver: (...args: T) => /* STOP_PROPAGATION */ string | void);
dispatch(...args: T): /* STOP_PROPAGATION */ string | void;
diff --git a/src/js/languages.js b/src/js/languages.js
index 6899ef09..4dfb15d4 100644
--- a/src/js/languages.js
+++ b/src/js/languages.js
@@ -184,4 +184,12 @@ export const LANGUAGES = {
code: "uk",
region: "",
},
+
+ "he": {
+ // hebrew
+ name: "עברית",
+ data: require("./built-temp/base-he.json"),
+ code: "he",
+ region: "",
+ },
};
diff --git a/src/js/main.js b/src/js/main.js
index 5b9df699..94f3d37a 100644
--- a/src/js/main.js
+++ b/src/js/main.js
@@ -9,6 +9,7 @@ import { initComponentRegistry } from "./game/component_registry";
import { initDrawUtils } from "./core/draw_utils";
import { initItemRegistry } from "./game/item_registry";
import { initMetaBuildingRegistry } from "./game/meta_building_registry";
+import { initGameModeRegistry } from "./game/game_mode_registry";
import { initGameSpeedRegistry } from "./game/game_speed_registry";
const logger = createLogger("main");
@@ -81,6 +82,7 @@ initDrawUtils();
initComponentRegistry();
initItemRegistry();
initMetaBuildingRegistry();
+initGameModeRegistry();
initGameSpeedRegistry();
let app = null;
diff --git a/src/js/platform/api.js b/src/js/platform/api.js
new file mode 100644
index 00000000..cbecfb15
--- /dev/null
+++ b/src/js/platform/api.js
@@ -0,0 +1,203 @@
+/* typehints:start */
+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");
+const rusha = require("rusha");
+
+export class ClientAPI {
+ /**
+ *
+ * @param {Application} app
+ */
+ constructor(app) {
+ this.app = app;
+
+ /**
+ * The current users session token
+ * @type {string|null}
+ */
+ this.token = null;
+
+ this.syncToken = window.localStorage.getItem("tmp.syncToken");
+ if (!this.syncToken) {
+ this.syncToken = rusha
+ .createHash()
+ .update(new Date().getTime() + "=" + Math.random())
+ .digest("hex");
+ window.localStorage.setItem("tmp.syncToken", this.syncToken);
+ }
+ }
+
+ 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": "d5c54aaa491f200709afff082c153ef2",
+ "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, reject) => setTimeout(() => reject("timeout"), 15000)),
+ ])
+ .then(data => {
+ if (data && data.error) {
+ logger.warn("Got error from api:", data);
+ throw T.backendErrors[data.error] || 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: {
+ token: this.syncToken,
+ },
+ });
+ }
+
+ /**
+ * @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} 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
+ * @param {number} payload.time
+ * @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,
+ data: compressX64(JSON.stringify(payload.data)),
+ },
+ });
+ }
+}
diff --git a/src/js/platform/browser/game_analytics.js b/src/js/platform/browser/game_analytics.js
index a3947be6..65fc5080 100644
--- a/src/js/platform/browser/game_analytics.js
+++ b/src/js/platform/browser/game_analytics.js
@@ -3,6 +3,7 @@ import { createLogger } from "../../core/logging";
import { queryParamOptions } from "../../core/query_parameters";
import { BeltComponent } from "../../game/components/belt";
import { StaticMapEntityComponent } from "../../game/components/static_map_entity";
+import { RegularGameMode } from "../../game/modes/regular";
import { GameRoot } from "../../game/root";
import { InGameState } from "../../states/ingame";
import { GameAnalyticsInterface } from "../game_analytics";
@@ -163,6 +164,10 @@ export class ShapezGameAnalytics extends GameAnalyticsInterface {
return;
}
+ if (!(root.gameMode instanceof RegularGameMode)) {
+ return;
+ }
+
logger.log("Sending event", category, value);
this.sendToApi("/v1/game-event", {
diff --git a/src/js/platform/sound.js b/src/js/platform/sound.js
index 9d5a8461..d43c76c2 100644
--- a/src/js/platform/sound.js
+++ b/src/js/platform/sound.js
@@ -35,6 +35,10 @@ export const MUSIC = {
menu: "menu",
};
+if (G_IS_STANDALONE || G_IS_DEV) {
+ MUSIC.puzzle = "puzzle-full";
+}
+
export class SoundInstanceInterface {
constructor(key, url) {
this.key = key;
diff --git a/src/js/savegame/puzzle_serializer.js b/src/js/savegame/puzzle_serializer.js
new file mode 100644
index 00000000..c7bfa652
--- /dev/null
+++ b/src/js/savegame/puzzle_serializer.js
@@ -0,0 +1,211 @@
+/* typehints:start */
+import { GameRoot } from "../game/root";
+import { PuzzleGameMode } from "../game/modes/puzzle";
+/* typehints:end */
+import { enumConstantSignalType } from "../game/components/constant_signal";
+import { StaticMapEntityComponent } from "../game/components/static_map_entity";
+import { ShapeItem } from "../game/items/shape_item";
+import { Vector } from "../core/vector";
+import { MetaConstantProducerBuilding } from "../game/buildings/constant_producer";
+import { defaultBuildingVariant, MetaBuilding } from "../game/meta_building";
+import { gMetaBuildingRegistry } from "../core/global_registries";
+import { MetaGoalAcceptorBuilding } from "../game/buildings/goal_acceptor";
+import { createLogger } from "../core/logging";
+import { BaseItem } from "../game/base_item";
+import trim from "trim";
+import { enumColors } from "../game/colors";
+import { COLOR_ITEM_SINGLETONS } from "../game/items/color_item";
+import { ShapeDefinition } from "../game/shape_definition";
+import { MetaBlockBuilding } from "../game/buildings/block";
+
+const logger = createLogger("puzzle-serializer");
+
+export class PuzzleSerializer {
+ /**
+ * Serializes the game root into a dump
+ * @param {GameRoot} root
+ * @returns {import("./savegame_typedefs").PuzzleGameData}
+ */
+ generateDumpFromGameRoot(root) {
+ console.log("serializing", root);
+
+ /**
+ * @type {import("./savegame_typedefs").PuzzleGameData["buildings"]}
+ */
+ let buildings = [];
+ for (const entity of root.entityMgr.getAllWithComponent(StaticMapEntityComponent)) {
+ const staticComp = entity.components.StaticMapEntity;
+ const signalComp = entity.components.ConstantSignal;
+
+ if (signalComp) {
+ assert(signalComp.type === enumConstantSignalType.wireless, "not a wireless signal");
+ assert(["shape", "color"].includes(signalComp.signal.getItemType()), "not a shape signal");
+ buildings.push({
+ type: "emitter",
+ item: signalComp.signal.getAsCopyableKey(),
+ pos: {
+ x: staticComp.origin.x,
+ y: staticComp.origin.y,
+ r: staticComp.rotation,
+ },
+ });
+ continue;
+ }
+
+ const goalComp = entity.components.GoalAcceptor;
+ if (goalComp) {
+ assert(goalComp.item, "goals is missing item");
+ assert(goalComp.item.getItemType() === "shape", "goal is not an item");
+ buildings.push({
+ type: "goal",
+ item: goalComp.item.getAsCopyableKey(),
+ pos: {
+ x: staticComp.origin.x,
+ y: staticComp.origin.y,
+ r: staticComp.rotation,
+ },
+ });
+ continue;
+ }
+
+ if (staticComp.getMetaBuilding().id === gMetaBuildingRegistry.findByClass(MetaBlockBuilding).id) {
+ buildings.push({
+ type: "block",
+ pos: {
+ x: staticComp.origin.x,
+ y: staticComp.origin.y,
+ r: staticComp.rotation,
+ },
+ });
+ }
+ }
+
+ const mode = /** @type {PuzzleGameMode} */ (root.gameMode);
+
+ const handles = root.hud.parts.buildingsToolbar.buildingHandles;
+ const ids = gMetaBuildingRegistry.getAllIds();
+
+ /** @type {Array} */
+ let excludedBuildings = [];
+ for (let i = 0; i < ids.length; ++i) {
+ const handle = handles[ids[i]];
+ if (handle && handle.puzzleLocked) {
+ // @ts-ignore
+ excludedBuildings.push(handle.metaBuilding.getId());
+ }
+ }
+
+ return {
+ version: 1,
+ buildings,
+ bounds: {
+ w: mode.zoneWidth,
+ h: mode.zoneHeight,
+ },
+ //read from the toolbar when making a puzzle
+ excludedBuildings,
+ };
+ }
+
+ /**
+ * Tries to parse a signal code
+ * @param {GameRoot} root
+ * @param {string} code
+ * @returns {BaseItem}
+ */
+ parseItemCode(root, code) {
+ if (!root || !root.shapeDefinitionMgr) {
+ // Stale reference
+ return null;
+ }
+
+ code = trim(code);
+ const codeLower = code.toLowerCase();
+
+ if (enumColors[codeLower]) {
+ return COLOR_ITEM_SINGLETONS[codeLower];
+ }
+
+ if (ShapeDefinition.isValidShortKey(code)) {
+ return root.shapeDefinitionMgr.getShapeItemFromShortKey(code);
+ }
+
+ return null;
+ }
+ /**
+ * @param {GameRoot} root
+ * @param {import("./savegame_typedefs").PuzzleGameData} puzzle
+ */
+ deserializePuzzle(root, puzzle) {
+ if (puzzle.version !== 1) {
+ return "invalid-version";
+ }
+
+ for (const building of puzzle.buildings) {
+ switch (building.type) {
+ case "emitter": {
+ const item = this.parseItemCode(root, building.item);
+ if (!item) {
+ return "bad-item:" + building.item;
+ }
+
+ const entity = root.logic.tryPlaceBuilding({
+ origin: new Vector(building.pos.x, building.pos.y),
+ building: gMetaBuildingRegistry.findByClass(MetaConstantProducerBuilding),
+ originalRotation: building.pos.r,
+ rotation: building.pos.r,
+ rotationVariant: 0,
+ variant: defaultBuildingVariant,
+ });
+ if (!entity) {
+ logger.warn("Failed to place emitter:", building);
+ return "failed-to-place-emitter";
+ }
+
+ entity.components.ConstantSignal.signal = item;
+ break;
+ }
+ case "goal": {
+ const item = this.parseItemCode(root, building.item);
+ if (!item) {
+ return "bad-item:" + building.item;
+ }
+ const entity = root.logic.tryPlaceBuilding({
+ origin: new Vector(building.pos.x, building.pos.y),
+ building: gMetaBuildingRegistry.findByClass(MetaGoalAcceptorBuilding),
+ originalRotation: building.pos.r,
+ rotation: building.pos.r,
+ rotationVariant: 0,
+ variant: defaultBuildingVariant,
+ });
+ if (!entity) {
+ logger.warn("Failed to place goal:", building);
+ return "failed-to-place-goal";
+ }
+
+ entity.components.GoalAcceptor.item = item;
+ break;
+ }
+ case "block": {
+ const entity = root.logic.tryPlaceBuilding({
+ origin: new Vector(building.pos.x, building.pos.y),
+ building: gMetaBuildingRegistry.findByClass(MetaBlockBuilding),
+ originalRotation: building.pos.r,
+ rotation: building.pos.r,
+ rotationVariant: 0,
+ variant: defaultBuildingVariant,
+ });
+ if (!entity) {
+ logger.warn("Failed to place block:", building);
+ return "failed-to-place-block";
+ }
+ break;
+ }
+ default: {
+ // @ts-ignore
+ return "invalid-building-type: " + building.type;
+ }
+ }
+ }
+ }
+}
diff --git a/src/js/savegame/savegame.js b/src/js/savegame/savegame.js
index e56ae1dc..999b90ec 100644
--- a/src/js/savegame/savegame.js
+++ b/src/js/savegame/savegame.js
@@ -13,6 +13,7 @@ import { SavegameInterface_V1005 } from "./schemas/1005";
import { SavegameInterface_V1006 } from "./schemas/1006";
import { SavegameInterface_V1007 } from "./schemas/1007";
import { SavegameInterface_V1008 } from "./schemas/1008";
+import { SavegameInterface_V1009 } from "./schemas/1009";
const logger = createLogger("savegame");
@@ -53,7 +54,7 @@ export class Savegame extends ReadWriteProxy {
* @returns {number}
*/
static getCurrentVersion() {
- return 1008;
+ return 1009;
}
/**
@@ -136,6 +137,11 @@ export class Savegame extends ReadWriteProxy {
data.version = 1008;
}
+ if (data.version === 1008) {
+ SavegameInterface_V1009.migrate1008to1009(data);
+ data.version = 1009;
+ }
+
return ExplainedResult.good();
}
diff --git a/src/js/savegame/savegame_interface_registry.js b/src/js/savegame/savegame_interface_registry.js
index 395040b3..b4dc4233 100644
--- a/src/js/savegame/savegame_interface_registry.js
+++ b/src/js/savegame/savegame_interface_registry.js
@@ -9,6 +9,7 @@ import { SavegameInterface_V1005 } from "./schemas/1005";
import { SavegameInterface_V1006 } from "./schemas/1006";
import { SavegameInterface_V1007 } from "./schemas/1007";
import { SavegameInterface_V1008 } from "./schemas/1008";
+import { SavegameInterface_V1009 } from "./schemas/1009";
/** @type {Object.} */
export const savegameInterfaces = {
@@ -21,6 +22,7 @@ export const savegameInterfaces = {
1006: SavegameInterface_V1006,
1007: SavegameInterface_V1007,
1008: SavegameInterface_V1008,
+ 1009: SavegameInterface_V1009,
};
const logger = createLogger("savegame_interface_registry");
diff --git a/src/js/savegame/savegame_serializer.js b/src/js/savegame/savegame_serializer.js
index c1247225..3230cdd5 100644
--- a/src/js/savegame/savegame_serializer.js
+++ b/src/js/savegame/savegame_serializer.js
@@ -2,6 +2,8 @@ import { ExplainedResult } from "../core/explained_result";
import { createLogger } from "../core/logging";
import { gComponentRegistry } from "../core/global_registries";
import { SerializerInternal } from "./serializer_internal";
+import { HUDPinnedShapes } from "../game/hud/parts/pinned_shapes";
+import { HUDWaypoints } from "../game/hud/parts/waypoints";
/**
* @typedef {import("../game/component").Component} Component
@@ -33,12 +35,13 @@ export class SavegameSerializer {
camera: root.camera.serialize(),
time: root.time.serialize(),
map: root.map.serialize(),
+ gameMode: root.gameMode.serialize(),
entityMgr: root.entityMgr.serialize(),
hubGoals: root.hubGoals.serialize(),
- pinnedShapes: root.hud.parts.pinnedShapes.serialize(),
- waypoints: root.hud.parts.waypoints.serialize(),
entities: this.internal.serializeEntityArray(root.entityMgr.entities),
beltPaths: root.systemMgr.systems.belt.serializePaths(),
+ pinnedShapes: root.hud.parts.pinnedShapes ? root.hud.parts.pinnedShapes.serialize() : null,
+ waypoints: root.hud.parts.waypoints ? root.hud.parts.waypoints.serialize() : null,
};
if (G_IS_DEV) {
@@ -130,12 +133,19 @@ export class SavegameSerializer {
errorReason = errorReason || root.time.deserialize(savegame.time);
errorReason = errorReason || root.camera.deserialize(savegame.camera);
errorReason = errorReason || root.map.deserialize(savegame.map);
+ errorReason = errorReason || root.gameMode.deserialize(savegame.gameMode);
errorReason = errorReason || root.hubGoals.deserialize(savegame.hubGoals, root);
- errorReason = errorReason || root.hud.parts.pinnedShapes.deserialize(savegame.pinnedShapes);
- errorReason = errorReason || root.hud.parts.waypoints.deserialize(savegame.waypoints);
errorReason = errorReason || this.internal.deserializeEntityArray(root, savegame.entities);
errorReason = errorReason || root.systemMgr.systems.belt.deserializePaths(savegame.beltPaths);
+ if (root.hud.parts.pinnedShapes) {
+ errorReason = errorReason || root.hud.parts.pinnedShapes.deserialize(savegame.pinnedShapes);
+ }
+
+ if (root.hud.parts.waypoints) {
+ errorReason = errorReason || root.hud.parts.waypoints.deserialize(savegame.waypoints);
+ }
+
// Check for errors
if (errorReason) {
return ExplainedResult.bad(errorReason);
diff --git a/src/js/savegame/savegame_typedefs.js b/src/js/savegame/savegame_typedefs.js
index fb872113..c5e0e5c5 100644
--- a/src/js/savegame/savegame_typedefs.js
+++ b/src/js/savegame/savegame_typedefs.js
@@ -12,6 +12,7 @@
* time: any,
* entityMgr: any,
* map: any,
+ * gameMode: object,
* hubGoals: any,
* pinnedShapes: any,
* waypoints: any,
@@ -40,4 +41,61 @@
* }} SavegamesData
*/
+import { MetaBuilding } from "../game/meta_building";
+
+// Notice: Update backend too
+/**
+ * @typedef {{
+ * id: number;
+ * shortKey: string;
+ * likes: number;
+ * downloads: number;
+ * completions: number;
+ * difficulty: number | null;
+ * averageTime: number | null;
+ * title: string;
+ * author: string;
+ * completed: boolean;
+ * }} PuzzleMetadata
+ */
+
+/**
+ * @typedef {{
+ * type: "emitter";
+ * item: string;
+ * pos: { x: number; y: number; r: number }
+ * }} PuzzleGameBuildingConstantProducer
+ */
+
+/**
+ * @typedef {{
+ * type: "goal";
+ * item: string;
+ * pos: { x: number; y: number; r: number }
+ * }} PuzzleGameBuildingGoal
+ */
+
+/**
+ * @typedef {{
+ * type: "block";
+ * pos: { x: number; y: number; r: number }
+ * }} PuzzleGameBuildingBlock
+ */
+
+/**
+ * @typedef {{
+ * version: number;
+ * bounds: { w: number; h: number; },
+ * buildings: (PuzzleGameBuildingGoal | PuzzleGameBuildingConstantProducer | PuzzleGameBuildingBlock)[],
+ * excludedBuildings: Array,
+ * }} PuzzleGameData
+ */
+
+/**
+ * @typedef {{
+ * meta: PuzzleMetadata,
+ * game: PuzzleGameData
+ * }} PuzzleFullData
+ */
+
export default {};
diff --git a/src/js/savegame/schemas/1009.js b/src/js/savegame/schemas/1009.js
new file mode 100644
index 00000000..e6e1abc6
--- /dev/null
+++ b/src/js/savegame/schemas/1009.js
@@ -0,0 +1,34 @@
+import { createLogger } from "../../core/logging.js";
+import { RegularGameMode } from "../../game/modes/regular.js";
+import { SavegameInterface_V1008 } from "./1008.js";
+
+const schema = require("./1009.json");
+const logger = createLogger("savegame_interface/1009");
+
+export class SavegameInterface_V1009 extends SavegameInterface_V1008 {
+ getVersion() {
+ return 1009;
+ }
+
+ getSchemaUncached() {
+ return schema;
+ }
+
+ /**
+ * @param {import("../savegame_typedefs.js").SavegameData} data
+ */
+ static migrate1008to1009(data) {
+ logger.log("Migrating 1008 to 1009");
+ const dump = data.dump;
+ if (!dump) {
+ return true;
+ }
+
+ dump.gameMode = {
+ mode: {
+ id: RegularGameMode.getId(),
+ data: {},
+ },
+ };
+ }
+}
diff --git a/src/js/savegame/schemas/1009.json b/src/js/savegame/schemas/1009.json
new file mode 100644
index 00000000..6682f615
--- /dev/null
+++ b/src/js/savegame/schemas/1009.json
@@ -0,0 +1,5 @@
+{
+ "type": "object",
+ "required": [],
+ "additionalProperties": true
+}
diff --git a/src/js/states/ingame.js b/src/js/states/ingame.js
index 316c536c..0dd6c72a 100644
--- a/src/js/states/ingame.js
+++ b/src/js/states/ingame.js
@@ -8,6 +8,7 @@ import { KeyActionMapper } from "../game/key_action_mapper";
import { Savegame } from "../savegame/savegame";
import { GameCore } from "../game/core";
import { MUSIC } from "../platform/sound";
+import { enumGameModeIds } from "../game/game_mode";
const logger = createLogger("state/ingame");
@@ -39,8 +40,14 @@ export class GameCreationPayload {
/** @type {boolean|undefined} */
this.fastEnter;
+ /** @type {string} */
+ this.gameModeId;
+
/** @type {Savegame} */
this.savegame;
+
+ /** @type {object|undefined} */
+ this.gameModeParameters;
}
}
@@ -97,6 +104,9 @@ export class InGameState extends GameState {
}
getThemeMusic() {
+ if (this.creationPayload.gameModeId && this.creationPayload.gameModeId.includes("puzzle")) {
+ return MUSIC.puzzle;
+ }
return MUSIC.theme;
}
@@ -147,7 +157,11 @@ export class InGameState extends GameState {
* Goes back to the menu state
*/
goBackToMenu() {
- this.saveThenGoToState("MainMenuState");
+ if ([enumGameModeIds.puzzleEdit, enumGameModeIds.puzzlePlay].includes(this.gameModeId)) {
+ this.saveThenGoToState("PuzzleMenuState");
+ } else {
+ this.saveThenGoToState("MainMenuState");
+ }
}
/**
@@ -220,7 +234,7 @@ export class InGameState extends GameState {
logger.log("Creating new game core");
this.core = new GameCore(this.app);
- this.core.initializeRoot(this, this.savegame);
+ this.core.initializeRoot(this, this.savegame, this.gameModeId);
if (this.savegame.hasGameDump()) {
this.stage4bResumeGame();
@@ -354,6 +368,7 @@ export class InGameState extends GameState {
this.creationPayload = payload;
this.savegame = payload.savegame;
+ this.gameModeId = payload.gameModeId;
this.loadingOverlay = new GameLoadingOverlay(this.app, this.getDivElement());
this.loadingOverlay.showBasic();
@@ -361,7 +376,13 @@ export class InGameState extends GameState {
// Remove unneded default element
document.body.querySelector(".modalDialogParent").remove();
- this.asyncChannel.watch(waitNextFrame()).then(() => this.stage3CreateCore());
+ this.asyncChannel
+ .watch(waitNextFrame())
+ .then(() => this.stage3CreateCore())
+ .catch(ex => {
+ logger.error(ex);
+ throw ex;
+ });
}
/**
@@ -433,6 +454,11 @@ export class InGameState extends GameState {
logger.warn("Skipping double save and returning same promise");
return this.currentSavePromise;
}
+
+ if (!this.core.root.gameMode.getIsSaveable()) {
+ return Promise.resolve();
+ }
+
logger.log("Starting to save game ...");
this.savegame.updateData(this.core.root);
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 3afad9bf..25d93d7a 100644
--- a/src/js/states/main_menu.js
+++ b/src/js/states/main_menu.js
@@ -66,7 +66,7 @@ export class MainMenuState extends GameState {
- v${G_BUILD_VERSION} - Achievements!
+ v${G_BUILD_VERSION} - Puzzle DLC!
@@ -82,6 +82,19 @@ export class MainMenuState extends GameState {
}
+
+ ${
+ // @TODO: Only display if DLC is owned, otherwise show ad for store page
+ showDemoBadges
+ ? ""
+ : `
+
+
+
+
`
+ }