import { globalConfig } from "../../../core/config"; import { gMetaBuildingRegistry } from "../../../core/global_registries"; import { Signal, STOP_PROPAGATION } from "../../../core/signal"; import { TrackedState } from "../../../core/tracked_state"; import { Vector } from "../../../core/vector"; import { enumMouseButton } from "../../camera"; import { StaticMapEntityComponent } from "../../components/static_map_entity"; import { Entity } from "../../entity"; import { KEYMAPPINGS } from "../../key_action_mapper"; import { defaultBuildingVariant, MetaBuilding } from "../../meta_building"; import { BaseHUDPart } from "../base_hud_part"; import { SOUNDS } from "../../../platform/sound"; import { MetaMinerBuilding, enumMinerVariants } from "../../buildings/miner"; import { enumHubGoalRewards } from "../../tutorial_goals"; import { getBuildingDataFromCode, getCodeFromBuildingData } from "../../building_codes"; import { MetaHubBuilding } from "../../buildings/hub"; import { safeModulo } from "../../../core/utils"; /** * Contains all logic for the building placer - this doesn't include the rendering * of info boxes or drawing. */ export class HUDBuildingPlacerLogic extends BaseHUDPart { /** * Initializes the logic * @see BaseHUDPart.initialize */ initialize() { /** * We use a fake entity to get information about how a building will look * once placed * @type {Entity} */ this.fakeEntity = null; // Signals this.signals = { variantChanged: new Signal(), draggingStarted: new Signal(), }; /** * The current building * @type {TypedTrackedState} */ this.currentMetaBuilding = new TrackedState(this.onSelectedMetaBuildingChanged, this); /** * The current rotation * @type {number} */ this.currentBaseRotationGeneral = 0; /** * The current rotation preference for each building. * @type{Object.} */ this.preferredBaseRotations = {}; /** * Whether we are currently dragging * @type {boolean} */ this.currentlyDragging = false; /** * Current building variant * @type {TypedTrackedState} */ this.currentVariant = new TrackedState(() => this.signals.variantChanged.dispatch()); /** * Whether we are currently drag-deleting * @type {boolean} */ this.currentlyDeleting = false; /** * Stores which variants for each building we prefer, this is based on what * the user last selected * @type {Object.} */ this.preferredVariants = {}; /** * The tile we last dragged from * @type {Vector} */ this.lastDragTile = null; /** * The side for direction lock * @type {number} (0|1) */ this.currentDirectionLockSide = 0; /** * Whether the side for direction lock has not yet been determined. * @type {boolean} */ this.currentDirectionLockSideIndeterminate = true; this.initializeBindings(); } /** * Initializes all bindings */ initializeBindings() { // KEYBINDINGS const keyActionMapper = this.root.keyMapper; keyActionMapper.getBinding(KEYMAPPINGS.placement.rotateWhilePlacing).add(this.tryRotate, this); keyActionMapper.getBinding(KEYMAPPINGS.placement.cycleBuildingVariants).add(this.cycleVariants, this); keyActionMapper .getBinding(KEYMAPPINGS.placement.switchDirectionLockSide) .add(this.switchDirectionLockSide, this); keyActionMapper.getBinding(KEYMAPPINGS.general.back).add(this.abortPlacement, this); keyActionMapper.getBinding(KEYMAPPINGS.placement.pipette).add(this.startPipette, this); this.root.gameState.inputReciever.keyup.add(this.checkForDirectionLockSwitch, this); // BINDINGS TO GAME EVENTS this.root.hud.signals.buildingsSelectedForCopy.add(this.abortPlacement, this); this.root.hud.signals.pasteBlueprintRequested.add(this.abortPlacement, this); this.root.signals.storyGoalCompleted.add(() => this.signals.variantChanged.dispatch()); this.root.signals.storyGoalCompleted.add(() => this.currentMetaBuilding.set(null)); this.root.signals.upgradePurchased.add(() => this.signals.variantChanged.dispatch()); this.root.signals.editModeChanged.add(this.onEditModeChanged, this); // MOUSE BINDINGS this.root.camera.downPreHandler.add(this.onMouseDown, this); this.root.camera.movePreHandler.add(this.onMouseMove, this); this.root.camera.upPostHandler.add(this.onMouseUp, this); } /** * Called when the edit mode got changed * @param {Layer} layer */ onEditModeChanged(layer) { const metaBuilding = this.currentMetaBuilding.get(); if (metaBuilding) { if (metaBuilding.getLayer() !== layer) { // This layer doesn't fit the edit mode anymore this.currentMetaBuilding.set(null); } } } /** * Returns the current base rotation for the current meta-building. * @returns {number} */ get currentBaseRotation() { if (!this.root.app.settings.getAllSettings().rotationByBuilding) { return this.currentBaseRotationGeneral; } const metaBuilding = this.currentMetaBuilding.get(); if (metaBuilding && this.preferredBaseRotations.hasOwnProperty(metaBuilding.getId())) { return this.preferredBaseRotations[metaBuilding.getId()]; } else { return this.currentBaseRotationGeneral; } } /** * Sets the base rotation for the current meta-building. * @param {number} rotation The new rotation/angle. */ set currentBaseRotation(rotation) { if (!this.root.app.settings.getAllSettings().rotationByBuilding) { this.currentBaseRotationGeneral = rotation; } else { const metaBuilding = this.currentMetaBuilding.get(); if (metaBuilding) { this.preferredBaseRotations[metaBuilding.getId()] = rotation; } else { this.currentBaseRotationGeneral = rotation; } } } /** * Returns if the direction lock is currently active * @returns {boolean} */ get isDirectionLockActive() { const metaBuilding = this.currentMetaBuilding.get(); return ( metaBuilding && metaBuilding.getHasDirectionLockAvailable() && this.root.keyMapper.getBinding(KEYMAPPINGS.placementModifiers.lockBeltDirection).pressed ); } /** * Returns the current direction lock corner, that is, the corner between * mouse and original start point * @returns {Vector|null} */ get currentDirectionLockCorner() { const mousePosition = this.root.app.mousePosition; if (!mousePosition) { // Not on screen return null; } if (!this.lastDragTile) { // Haven't dragged yet return null; } // Figure which points the line visits const worldPos = this.root.camera.screenToWorld(mousePosition); const mouseTile = worldPos.toTileSpace(); // Figure initial direction const dx = Math.abs(this.lastDragTile.x - mouseTile.x); const dy = Math.abs(this.lastDragTile.y - mouseTile.y); if (dx === 0 && dy === 0) { // Back at the start. Try a new direction. this.currentDirectionLockSideIndeterminate = true; } else if (this.currentDirectionLockSideIndeterminate) { this.currentDirectionLockSideIndeterminate = false; this.currentDirectionLockSide = dx <= dy ? 0 : 1; } if (this.currentDirectionLockSide === 0) { return new Vector(this.lastDragTile.x, mouseTile.y); } else { return new Vector(mouseTile.x, this.lastDragTile.y); } } /** * Aborts the placement */ abortPlacement() { if (this.currentMetaBuilding.get()) { this.currentMetaBuilding.set(null); return STOP_PROPAGATION; } } /** * Aborts any dragging */ abortDragging() { this.currentlyDragging = true; this.currentlyDeleting = false; this.initialPlacementVector = null; this.lastDragTile = null; } /** * @see BaseHUDPart.update */ update() { // Abort placement if a dialog was shown in the meantime if (this.root.hud.hasBlockingOverlayOpen()) { this.abortPlacement(); return; } // Always update since the camera might have moved const mousePos = this.root.app.mousePosition; if (mousePos) { this.onMouseMove(mousePos); } // Make sure we have nothing selected while in overview mode if (this.root.camera.getIsMapOverlayActive()) { if (this.currentMetaBuilding.get()) { this.currentMetaBuilding.set(null); } } } /** * Tries to rotate the current building */ tryRotate() { const selectedBuilding = this.currentMetaBuilding.get(); if (selectedBuilding) { if (this.root.keyMapper.getBinding(KEYMAPPINGS.placement.rotateInverseModifier).pressed) { this.currentBaseRotation = (this.currentBaseRotation + 270) % 360; } else { this.currentBaseRotation = (this.currentBaseRotation + 90) % 360; } const staticComp = this.fakeEntity.components.StaticMapEntity; staticComp.rotation = this.currentBaseRotation; } } /** * Tries to delete the building under the mouse */ deleteBelowCursor() { const mousePosition = this.root.app.mousePosition; if (!mousePosition) { // Not on screen return false; } const worldPos = this.root.camera.screenToWorld(mousePosition); const tile = worldPos.toTileSpace(); const contents = this.root.map.getTileContent(tile, this.root.currentLayer); if (contents) { if (this.root.logic.tryDeleteBuilding(contents)) { this.root.soundProxy.playUi(SOUNDS.destroyBuilding); return true; } } return false; } /** * Starts the pipette function */ startPipette() { // Disable in overview if (this.root.camera.getIsMapOverlayActive()) { return; } const mousePosition = this.root.app.mousePosition; if (!mousePosition) { // Not on screen return; } const worldPos = this.root.camera.screenToWorld(mousePosition); const tile = worldPos.toTileSpace(); const contents = this.root.map.getTileContent(tile, this.root.currentLayer); if (!contents) { const tileBelow = this.root.map.getLowerLayerContentXY(tile.x, tile.y); // Check if there's a shape or color item below, if so select the miner if ( tileBelow && this.root.app.settings.getAllSettings().pickMinerOnPatch && this.root.currentLayer === "regular" ) { this.currentMetaBuilding.set(gMetaBuildingRegistry.findByClass(MetaMinerBuilding)); // Select chained miner if available, since that's always desired once unlocked if (this.root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_miner_chainable)) { this.currentVariant.set(enumMinerVariants.chainable); } } else { this.currentMetaBuilding.set(null); } return; } // Try to extract the building const buildingCode = contents.components.StaticMapEntity.code; const extracted = getBuildingDataFromCode(buildingCode); // Disable pipetting the hub if (extracted.metaInstance.getId() === gMetaBuildingRegistry.findByClass(MetaHubBuilding).getId()) { 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() && extracted.metaInstance.getId() === this.currentMetaBuilding.get().getId() && extracted.variant === this.currentVariant.get() ) { this.currentMetaBuilding.set(null); return; } this.currentMetaBuilding.set(extracted.metaInstance); this.currentVariant.set(extracted.variant); this.currentBaseRotation = contents.components.StaticMapEntity.rotation; } /** * Switches the side for the direction lock manually */ switchDirectionLockSide() { this.currentDirectionLockSide = 1 - this.currentDirectionLockSide; } /** * Checks if the direction lock key got released and if such, resets the placement * @param {any} args */ checkForDirectionLockSwitch({ keyCode }) { if ( keyCode === this.root.keyMapper.getBinding(KEYMAPPINGS.placementModifiers.lockBeltDirection).keyCode ) { this.abortDragging(); } } /** * Tries to place the current building at the given tile * @param {Vector} tile */ tryPlaceCurrentBuildingAt(tile) { if (this.root.camera.zoomLevel < globalConfig.mapChunkOverviewMinZoom) { // Dont allow placing in overview mode return; } const metaBuilding = this.currentMetaBuilding.get(); const { rotation, rotationVariant } = metaBuilding.computeOptimalDirectionAndRotationVariantAtTile({ root: this.root, tile, rotation: this.currentBaseRotation, variant: this.currentVariant.get(), layer: metaBuilding.getLayer(), }); const entity = this.root.logic.tryPlaceBuilding({ origin: tile, rotation, rotationVariant, originalRotation: this.currentBaseRotation, building: this.currentMetaBuilding.get(), variant: this.currentVariant.get(), }); if (entity) { // Succesfully placed, find which entity we actually placed this.root.signals.entityManuallyPlaced.dispatch(entity); // Check if we should flip the orientation (used for tunnels) if ( metaBuilding.getFlipOrientationAfterPlacement() && !this.root.keyMapper.getBinding( KEYMAPPINGS.placementModifiers.placementDisableAutoOrientation ).pressed ) { this.currentBaseRotation = (180 + this.currentBaseRotation) % 360; } // Check if we should stop placement if ( !metaBuilding.getStayInPlacementMode() && !this.root.keyMapper.getBinding(KEYMAPPINGS.placementModifiers.placeMultiple).pressed && !this.root.app.settings.getAllSettings().alwaysMultiplace ) { // Stop placement this.currentMetaBuilding.set(null); } return true; } else { return false; } } /** * Cycles through the variants */ cycleVariants() { const metaBuilding = this.currentMetaBuilding.get(); if (!metaBuilding) { this.currentVariant.set(defaultBuildingVariant); } else { const availableVariants = metaBuilding.getAvailableVariants(this.root); let index = availableVariants.indexOf(this.currentVariant.get()); if (index < 0) { index = 0; console.warn("Invalid variant selected:", this.currentVariant.get()); } const direction = this.root.keyMapper.getBinding(KEYMAPPINGS.placement.rotateInverseModifier) .pressed ? -1 : 1; const newIndex = safeModulo(index + direction, availableVariants.length); const newVariant = availableVariants[newIndex]; this.setVariant(newVariant); } } /** * Sets the current variant to the given variant * @param {string} variant */ setVariant(variant) { const metaBuilding = this.currentMetaBuilding.get(); this.currentVariant.set(variant); this.preferredVariants[metaBuilding.getId()] = variant; } /** * Performs the direction locked placement between two points after * releasing the mouse */ executeDirectionLockedPlacement() { const metaBuilding = this.currentMetaBuilding.get(); if (!metaBuilding) { // No active building return; } // Get path to place const path = this.computeDirectionLockPath(); // Store if we placed anything let anythingPlaced = false; // Perform this in bulk to avoid recalculations this.root.logic.performBulkOperation(() => { for (let i = 0; i < path.length; ++i) { const { rotation, tile } = path[i]; this.currentBaseRotation = rotation; if (this.tryPlaceCurrentBuildingAt(tile)) { anythingPlaced = true; } } }); if (anythingPlaced) { this.root.soundProxy.playUi(metaBuilding.getPlacementSound()); } } /** * Finds the path which the current direction lock will use * @returns {Array<{ tile: Vector, rotation: number }>} */ computeDirectionLockPath() { const mousePosition = this.root.app.mousePosition; if (!mousePosition) { // Not on screen return []; } let result = []; // Figure which points the line visits const worldPos = this.root.camera.screenToWorld(mousePosition); let endTile = worldPos.toTileSpace(); let startTile = this.lastDragTile; // if the alt key is pressed, reverse belt planner direction by switching start and end tile if (this.root.keyMapper.getBinding(KEYMAPPINGS.placementModifiers.placeInverse).pressed) { let tmp = startTile; startTile = endTile; endTile = tmp; } // Place from start to corner const pathToCorner = this.currentDirectionLockCorner.sub(startTile); const deltaToCorner = pathToCorner.normalize().round(); const lengthToCorner = Math.round(pathToCorner.length()); let currentPos = startTile.copy(); let rotation = (Math.round(Math.degrees(deltaToCorner.angle()) / 90) * 90 + 360) % 360; if (lengthToCorner > 0) { for (let i = 0; i < lengthToCorner; ++i) { result.push({ tile: currentPos.copy(), rotation, }); currentPos.addInplace(deltaToCorner); } } // Place from corner to end const pathFromCorner = endTile.sub(this.currentDirectionLockCorner); const deltaFromCorner = pathFromCorner.normalize().round(); const lengthFromCorner = Math.round(pathFromCorner.length()); if (lengthFromCorner > 0) { rotation = (Math.round(Math.degrees(deltaFromCorner.angle()) / 90) * 90 + 360) % 360; for (let i = 0; i < lengthFromCorner + 1; ++i) { result.push({ tile: currentPos.copy(), rotation, }); currentPos.addInplace(deltaFromCorner); } } else { // Finish last one result.push({ tile: currentPos.copy(), rotation, }); } return result; } /** * Selects a given building * @param {MetaBuilding} metaBuilding */ startSelection(metaBuilding) { this.currentMetaBuilding.set(metaBuilding); } /** * Called when the selected buildings changed * @param {MetaBuilding} metaBuilding */ onSelectedMetaBuildingChanged(metaBuilding) { this.abortDragging(); this.root.hud.signals.selectedPlacementBuildingChanged.dispatch(metaBuilding); if (metaBuilding) { const availableVariants = metaBuilding.getAvailableVariants(this.root); const preferredVariant = this.preferredVariants[metaBuilding.getId()]; // Choose last stored variant if possible, otherwise the default one let variant; if (!preferredVariant || !availableVariants.includes(preferredVariant)) { variant = availableVariants[0]; } else { variant = preferredVariant; } this.currentVariant.set(variant); this.fakeEntity = new Entity(null); metaBuilding.setupEntityComponents(this.fakeEntity, null); this.fakeEntity.addComponent( new StaticMapEntityComponent({ origin: new Vector(0, 0), rotation: 0, tileSize: metaBuilding.getDimensions(this.currentVariant.get()).copy(), code: getCodeFromBuildingData(metaBuilding, variant, 0), }) ); metaBuilding.updateVariants(this.fakeEntity, 0, this.currentVariant.get()); } else { this.fakeEntity = null; } // Since it depends on both, rerender twice this.signals.variantChanged.dispatch(); } /** * mouse down pre handler * @param {Vector} pos * @param {enumMouseButton} button */ onMouseDown(pos, button) { if (this.root.camera.getIsMapOverlayActive()) { // We do not allow dragging if the overlay is active return; } const metaBuilding = this.currentMetaBuilding.get(); // Placement if (button === enumMouseButton.left && metaBuilding) { this.currentlyDragging = true; this.currentlyDeleting = false; this.lastDragTile = this.root.camera.screenToWorld(pos).toTileSpace(); // Place initial building, but only if direction lock is not active if (!this.isDirectionLockActive) { if (this.tryPlaceCurrentBuildingAt(this.lastDragTile)) { this.root.soundProxy.playUi(metaBuilding.getPlacementSound()); } } return STOP_PROPAGATION; } // Deletion if ( button === enumMouseButton.right && (!metaBuilding || !this.root.app.settings.getAllSettings().clearCursorOnDeleteWhilePlacing) ) { this.currentlyDragging = true; this.currentlyDeleting = true; this.lastDragTile = this.root.camera.screenToWorld(pos).toTileSpace(); if (this.deleteBelowCursor()) { return STOP_PROPAGATION; } } // Cancel placement if (button === enumMouseButton.right && metaBuilding) { this.currentMetaBuilding.set(null); } } /** * mouse move pre handler * @param {Vector} pos */ onMouseMove(pos) { if (this.root.camera.getIsMapOverlayActive()) { return; } // Check for direction lock if (this.isDirectionLockActive) { return; } const metaBuilding = this.currentMetaBuilding.get(); if ((metaBuilding || this.currentlyDeleting) && this.lastDragTile) { const oldPos = this.lastDragTile; let newPos = this.root.camera.screenToWorld(pos).toTileSpace(); // Check if camera is moving, since then we do nothing if (this.root.camera.desiredCenter) { this.lastDragTile = newPos; return; } // Check if anything changed if (!oldPos.equals(newPos)) { // Automatic Direction if ( metaBuilding && metaBuilding.getRotateAutomaticallyWhilePlacing(this.currentVariant.get()) && !this.root.keyMapper.getBinding( KEYMAPPINGS.placementModifiers.placementDisableAutoOrientation ).pressed ) { const delta = newPos.sub(oldPos); const angleDeg = Math.degrees(delta.angle()); this.currentBaseRotation = (Math.round(angleDeg / 90) * 90 + 360) % 360; // Holding alt inverts the placement if (this.root.keyMapper.getBinding(KEYMAPPINGS.placementModifiers.placeInverse).pressed) { this.currentBaseRotation = (180 + this.currentBaseRotation) % 360; } } // bresenham let x0 = oldPos.x; let y0 = oldPos.y; let x1 = newPos.x; let y1 = newPos.y; var dx = Math.abs(x1 - x0); var dy = Math.abs(y1 - y0); var sx = x0 < x1 ? 1 : -1; var sy = y0 < y1 ? 1 : -1; var err = dx - dy; let anythingPlaced = false; let anythingDeleted = false; while (this.currentlyDeleting || this.currentMetaBuilding.get()) { if (this.currentlyDeleting) { // Deletion const contents = this.root.map.getLayerContentXY(x0, y0, this.root.currentLayer); if (contents && !contents.queuedForDestroy && !contents.destroyed) { if (this.root.logic.tryDeleteBuilding(contents)) { anythingDeleted = true; } } } else { // Placement if (this.tryPlaceCurrentBuildingAt(new Vector(x0, y0))) { anythingPlaced = true; } } if (x0 === x1 && y0 === y1) break; var e2 = 2 * err; if (e2 > -dy) { err -= dy; x0 += sx; } if (e2 < dx) { err += dx; y0 += sy; } } if (anythingPlaced) { this.root.soundProxy.playUi(metaBuilding.getPlacementSound()); } if (anythingDeleted) { this.root.soundProxy.playUi(SOUNDS.destroyBuilding); } } this.lastDragTile = newPos; return STOP_PROPAGATION; } } /** * Mouse up handler */ onMouseUp() { if (this.root.camera.getIsMapOverlayActive()) { return; } // Check for direction lock if (this.lastDragTile && this.currentlyDragging && this.isDirectionLockActive) { this.executeDirectionLockedPlacement(); } this.abortDragging(); } }