/* Copyright (c) 2016 Jesper Öqvist <jesper@llbit.se> * * This file is part of Chunky. * * Chunky is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Chunky is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * You should have received a copy of the GNU General Public License * along with Chunky. If not, see <http://www.gnu.org/licenses/>. */ package se.llbit.chunky.ui; import javafx.application.Platform; import javafx.beans.property.DoubleProperty; import javafx.beans.property.IntegerProperty; import javafx.beans.property.SimpleDoubleProperty; import javafx.beans.property.SimpleIntegerProperty; import javafx.fxml.FXML; import javafx.fxml.Initializable; import javafx.scene.Scene; import javafx.scene.canvas.Canvas; import javafx.scene.control.Alert; import javafx.scene.control.Button; import javafx.scene.control.ButtonType; import javafx.scene.control.CheckBox; import javafx.scene.control.ChoiceBox; import javafx.scene.control.Hyperlink; import javafx.scene.control.MenuItem; import javafx.scene.control.Slider; import javafx.scene.control.Tab; import javafx.scene.control.TextField; import javafx.scene.control.ToggleButton; import javafx.scene.control.Tooltip; import javafx.scene.image.ImageView; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import javafx.scene.input.MouseEvent; import javafx.scene.layout.BorderPane; import javafx.scene.layout.StackPane; import javafx.stage.FileChooser; import javafx.stage.Stage; import javafx.util.converter.NumberStringConverter; import se.llbit.chunky.PersistentSettings; import se.llbit.chunky.launcher.LauncherSettings; import se.llbit.chunky.main.Chunky; import se.llbit.chunky.map.WorldMapLoader; import se.llbit.chunky.renderer.ChunkViewListener; import se.llbit.chunky.renderer.RenderContext; import se.llbit.chunky.renderer.scene.AsynchronousSceneManager; import se.llbit.chunky.renderer.scene.Camera; import se.llbit.chunky.renderer.scene.SceneLoadingError; import se.llbit.chunky.ui.render.RenderControlsFx; import se.llbit.chunky.world.Block; import se.llbit.chunky.world.ChunkPosition; import se.llbit.chunky.world.ChunkSelectionListener; import se.llbit.chunky.world.ChunkView; import se.llbit.chunky.world.Icon; import se.llbit.chunky.world.World; import se.llbit.chunky.world.listeners.ChunkUpdateListener; import se.llbit.fxutil.GroupedChangeListener; import se.llbit.log.Level; import se.llbit.log.Log; import se.llbit.math.Vector3; import java.awt.Desktop; import java.io.File; import java.io.IOException; import java.net.URL; import java.util.Collection; import java.util.ResourceBundle; /** * Controller for the main Chunky window. */ public class ChunkyFxController implements Initializable, ChunkViewListener, ChunkSelectionListener, ChunkUpdateListener { private Chunky chunky; private WorldMapLoader mapLoader; private ChunkMap map; private Minimap minimap; @FXML private Canvas mapCanvas; @FXML private Canvas mapOverlay; @FXML private Canvas minimapCanvas; @FXML private MenuItem menuExit; @FXML private BorderPane borderPane; @FXML private Button clearSelectionBtn; @FXML private Button changeWorldBtn; @FXML private Button reloadWorldBtn; @FXML private ToggleButton overworldBtn; @FXML private ToggleButton netherBtn; @FXML private ToggleButton endBtn; @FXML private ChoiceBox<MapViewMode> mapViewCb; @FXML private TextField scaleField; @FXML private TextField layerField; @FXML private Slider scaleSlider; @FXML private Slider layerSlider; @FXML private ToggleButton trackPlayerBtn; @FXML private ToggleButton trackCameraBtn; @FXML private Tab mapViewTab; @FXML private Tab chunksTab; @FXML private Tab optionsTab; @FXML private Tab renderTab; @FXML private Tab aboutTab; @FXML private CheckBox highlightBtn; @FXML private ChoiceBox<Block> highlightCb; @FXML private SimpleColorPicker highlightColor; @FXML private Button editResourcePacks; @FXML private CheckBox singleColorBtn; @FXML private CheckBox showLauncherBtn; @FXML private Button clearSelectionBtn2; @FXML private Button newSceneBtn; @FXML private Button loadSceneBtn; @FXML private Button openSceneDirBtn; @FXML private Button changeSceneDirBtn; @FXML private Hyperlink documentationLink; @FXML private Hyperlink gitHubLink; @FXML private Hyperlink issueTrackerLink; @FXML private Hyperlink forumLink; @FXML private Button creditsBtn; @FXML private TextField xPosition; @FXML private TextField zPosition; @FXML private Button deleteChunks; @FXML private Button exportZip; @FXML private Button renderPng; @FXML private StackPane mapPane; @FXML private StackPane minimapPane; private RenderControlsFx controls = null; private Stage stage; public ChunkyFxController() { mapLoader = new WorldMapLoader(this); map = new ChunkMap(mapLoader, this); minimap = new Minimap(mapLoader, this); mapLoader.addViewListener(this); mapLoader.addViewListener(map); mapLoader.addViewListener(minimap); mapLoader.getChunkSelection().addSelectionListener(this); mapLoader.getChunkSelection().addChunkUpdateListener(map); mapLoader.getChunkSelection().addChunkUpdateListener(minimap); mapLoader.addWorldLoadListener(() -> { map.redrawMap(); minimap.redrawMap(); }); } @Override public void initialize(URL fxmlUrl, ResourceBundle resources) { Log.setReceiver(new UILogReceiver(), Level.ERROR, Level.WARNING); map.setCanvas(mapCanvas); minimap.setCanvas(minimapCanvas); mapPane.widthProperty().addListener((observable, oldValue, newValue) -> { mapCanvas.setWidth(newValue.doubleValue()); mapOverlay.setWidth(newValue.doubleValue()); mapLoader.setMapSize((int) mapCanvas.getWidth(), (int) mapCanvas.getHeight()); }); mapPane.heightProperty().addListener((observable, oldValue, newValue) -> { mapCanvas.setHeight(newValue.doubleValue()); mapOverlay.setHeight(newValue.doubleValue()); mapLoader.setMapSize((int) mapCanvas.getWidth(), (int) mapCanvas.getHeight()); }); minimapPane.widthProperty().addListener((observable, oldValue, newValue) -> { minimapCanvas.setWidth(newValue.doubleValue()); mapLoader.setMinimapSize((int) minimapCanvas.getWidth(), (int) minimapCanvas.getHeight()); }); minimapPane.heightProperty().addListener((observable, oldValue, newValue) -> { minimapCanvas.setHeight(newValue.doubleValue()); mapLoader.setMinimapSize((int) minimapCanvas.getWidth(), (int) minimapCanvas.getHeight()); }); mapOverlay.setOnMouseExited(e -> map.tooltip.hide()); // Set up property bindings for the map view. ChunkView mapView = map.getView(); // Initial map view - only used to initialize controls. // A scale factor of 16 is used to convert map positions between block/chunk coordinates. DoubleProperty xProperty = new SimpleDoubleProperty(mapView.x); DoubleProperty zProperty = new SimpleDoubleProperty(mapView.z); IntegerProperty scaleProperty = new SimpleIntegerProperty(mapView.scale); IntegerProperty layerProperty = new SimpleIntegerProperty(mapView.layer); // Bind controls with properties. xPosition.textProperty().bindBidirectional(xProperty, new NumberStringConverter()); zPosition.textProperty().bindBidirectional(zProperty, new NumberStringConverter()); scaleField.textProperty().bindBidirectional(scaleProperty, new NumberStringConverter()); scaleSlider.valueProperty().bindBidirectional(scaleProperty); layerField.textProperty().bindBidirectional(layerProperty, new NumberStringConverter()); layerSlider.valueProperty().bindBidirectional(layerProperty); // Add listeners to the properties to control the map view. GroupedChangeListener<Object> group = new GroupedChangeListener<>(null); xProperty.addListener(new GroupedChangeListener<>(group, (observable, oldValue, newValue) -> { ChunkView view = mapLoader.getMapView(); mapLoader.panTo(newValue.doubleValue() / 16, view.z); })); zProperty.addListener(new GroupedChangeListener<>(group, (observable, oldValue, newValue) -> { ChunkView view = mapLoader.getMapView(); mapLoader.panTo(view.x, newValue.doubleValue() / 16); })); scaleProperty.addListener(new GroupedChangeListener<>(group, (observable, oldValue, newValue) -> mapLoader.setScale(newValue.intValue()))); layerProperty.addListener(new GroupedChangeListener<>(group, (observable, oldValue, newValue) -> mapLoader.setLayer(newValue.intValue()))); // Add map view listener to control the individual value properties. mapLoader.getMapViewProperty().addListener(new GroupedChangeListener<>(group, (observable, oldValue, newValue) -> { xProperty.set(newValue.x * 16); zProperty.set(newValue.z * 16); scaleProperty.set(newValue.scale); layerProperty.set(newValue.layer); mapViewCb.getSelectionModel().select(newValue.renderer); })); clearSelectionBtn2.setOnAction(e -> mapLoader.clearChunkSelection()); deleteChunks.setTooltip(new Tooltip("Delete selected chunks.")); deleteChunks.setOnAction(e -> { Alert alert = new Alert(Alert.AlertType.CONFIRMATION); alert.setTitle("Delete Selected Chunks"); alert.setContentText( "Do you really want to delete the selected chunks? This can not be undone."); if (alert.showAndWait().get() == ButtonType.OK) { mapLoader.deleteSelectedChunks(ProgressTracker.NONE); } }); exportZip.setTooltip(new Tooltip("Export selected chunks to Zip archive.")); exportZip.setOnAction(e -> { FileChooser fileChooser = new FileChooser(); fileChooser.setTitle("Export Chunks to Zip"); fileChooser.setSelectedExtensionFilter(new FileChooser.ExtensionFilter("Zip files", "*.zip")); fileChooser.setInitialFileName(String.format("%s.zip", mapLoader.getWorldName())); File target = fileChooser.showSaveDialog(stage); if (target != null) { mapLoader.exportZip(target, ProgressTracker.NONE); } }); renderPng.setOnAction(e -> { FileChooser fileChooser = new FileChooser(); fileChooser.setTitle("Export PNG"); fileChooser .setSelectedExtensionFilter(new FileChooser.ExtensionFilter("PNG images", "*.png")); fileChooser.setInitialFileName(String.format("%s.png", mapLoader.getWorldName())); File target = fileChooser.showSaveDialog(stage); if (target != null) { map.renderView(target, ProgressTracker.NONE); } }); newSceneBtn.setTooltip( new Tooltip("Creates a new 3D scene with the currently selected chunks.")); newSceneBtn.setOnAction(e -> createNew3DScene()); loadSceneBtn.setGraphic(new ImageView(Icon.load.fxImage())); loadSceneBtn.setOnAction(e -> loadScene()); openSceneDirBtn.setOnAction(e -> openSceneDirectory()); changeSceneDirBtn.setOnAction(e -> SceneDirectoryPicker.changeSceneDirectory(chunky.options)); creditsBtn.setOnAction(e -> { try { Credits credits = new Credits(); credits.show(); } catch (IOException e1) { Log.warn("Failed to create credits window.", e1); } }); mapViewTab.setGraphic(new ImageView(Icon.map.fxImage())); chunksTab.setGraphic(new ImageView(Icon.mapSelected.fxImage())); optionsTab.setGraphic(new ImageView(Icon.wrench.fxImage())); renderTab.setGraphic(new ImageView(Icon.sky.fxImage())); aboutTab.setGraphic(new ImageView(Icon.question.fxImage())); editResourcePacks.setTooltip( new Tooltip("Select resource packs Chunky uses to load textures.")); editResourcePacks.setGraphic(new ImageView(Icon.pencil.fxImage())); editResourcePacks.setOnAction(e -> { ResourceLoadOrderEditor editor = new ResourceLoadOrderEditor(); editor.show(); }); LauncherSettings settings = new LauncherSettings(); settings.load(); showLauncherBtn .setTooltip(new Tooltip("Opens the Chunky launcher when starting Chunky next time.")); showLauncherBtn.setSelected(settings.showLauncher); showLauncherBtn.selectedProperty().addListener((observable, oldValue, newValue) -> { LauncherSettings launcherSettings = new LauncherSettings(); launcherSettings.load(); launcherSettings.showLauncher = newValue; launcherSettings.save(); }); singleColorBtn.setSelected(PersistentSettings.getSingleColorTextures()); singleColorBtn.selectedProperty().addListener((observable, oldValue, newValue) -> { PersistentSettings.setSingleColorTextures(newValue); }); highlightBtn.setTooltip(new Tooltip("Highlight the selected block type in the current layer.")); highlightBtn.selectedProperty().bindBidirectional(mapLoader.highlightEnabledProperty()); highlightCb.getItems().addAll( Block.get(Block.DIRT_ID), Block.get(Block.GRASS_ID), Block.get(Block.STONE_ID), Block.get(Block.COBBLESTONE_ID), Block.get(Block.MOSSSTONE_ID), Block.get(Block.IRONORE_ID), Block.get(Block.COALORE_ID), Block.get(Block.REDSTONEORE_ID), Block.get(Block.DIAMONDORE_ID), Block.get(Block.GOLDORE_ID), Block.get(Block.MONSTERSPAWNER_ID), Block.get(Block.BRICKS_ID), Block.get(Block.CLAY_ID), Block.get(Block.LAPIS_ORE_ID), Block.get(Block.EMERALDORE_ID), Block.get(Block.NETHERQUARTZORE_ID)); highlightCb.getSelectionModel().select(Block.get(Block.DIAMONDORE_ID)); highlightCb.getSelectionModel().selectedItemProperty().addListener((item, prev, next) -> { mapLoader.highlightEnabledProperty().set(true); mapLoader.highlightBlock(next); }); highlightColor.setColor(mapLoader.highlightColor()); highlightColor.setTooltip(new Tooltip("Choose highlight color")); highlightColor.colorProperty().addListener( (observable, oldValue, newValue) -> mapLoader.highlightColor(newValue)); trackPlayerBtn.selectedProperty().bindBidirectional(mapLoader.trackPlayerProperty()); trackCameraBtn.selectedProperty().bindBidirectional(mapLoader.trackCameraProperty()); overworldBtn.setSelected(mapLoader.getDimension() == World.OVERWORLD_DIMENSION); overworldBtn.setTooltip(new Tooltip("Full of grass and Creepers!")); netherBtn.setSelected(mapLoader.getDimension() == World.NETHER_DIMENSION); netherBtn.setTooltip(new Tooltip("The land of Zombie Pigmen.")); endBtn.setSelected(mapLoader.getDimension() == World.END_DIMENSION); endBtn.setTooltip(new Tooltip("Watch out for the dragon.")); changeWorldBtn.setOnAction(e -> { try { WorldChooser worldChooser = new WorldChooser(mapLoader); worldChooser.show(); } catch (IOException e1) { Log.error("Failed to create world chooser window.", e1); } }); reloadWorldBtn.setGraphic(new ImageView(Icon.reload.fxImage())); reloadWorldBtn.setOnAction(e -> mapLoader.reloadWorld()); overworldBtn.setGraphic(new ImageView(Icon.grass.fxImage())); overworldBtn.setOnAction(e -> mapLoader.setDimension(World.OVERWORLD_DIMENSION)); netherBtn.setGraphic(new ImageView(Icon.netherrack.fxImage())); netherBtn.setOnAction(e -> mapLoader.setDimension(World.NETHER_DIMENSION)); endBtn.setGraphic(new ImageView(Icon.endStone.fxImage())); endBtn.setOnAction(e -> mapLoader.setDimension(World.END_DIMENSION)); mapViewCb.getItems().addAll(MapViewMode.values()); mapViewCb.getSelectionModel().select(MapViewMode.AUTO); mapViewCb.getSelectionModel().selectedItemProperty() .addListener((item, prev, next) -> mapLoader.setRenderer(next)); menuExit.setAccelerator(new KeyCodeCombination(KeyCode.Q, KeyCombination.CONTROL_DOWN)); clearSelectionBtn.setOnAction(event -> mapLoader.clearChunkSelection()); mapLoader.setMapSize((int) mapCanvas.getWidth(), (int) mapCanvas.getHeight()); mapLoader.setMinimapSize((int) minimapCanvas.getWidth(), (int) minimapCanvas.getHeight()); mapOverlay.setOnScroll(map::onScroll); mapOverlay.setOnMousePressed(map::onMousePressed); mapOverlay.setOnMouseReleased(map::onMouseReleased); mapOverlay.setOnMouseMoved(map::onMouseMoved); mapOverlay.setOnMouseDragged(map::onMouseDragged); mapOverlay.addEventFilter(MouseEvent.ANY, event -> mapOverlay.requestFocus()); mapOverlay.setOnKeyPressed(map::onKeyPressed); mapOverlay.setOnKeyReleased(map::onKeyReleased); minimapCanvas.setOnMousePressed(minimap::onMousePressed); mapLoader.loadWorld(PersistentSettings.getLastWorld()); } public void setStageAndScene(ChunkyFx app, Stage stage, Scene scene) { this.stage = stage; documentationLink.setOnAction( e -> app.getHostServices().showDocument("http://chunky.llbit.se")); issueTrackerLink.setOnAction( e -> app.getHostServices().showDocument("https://github.com/llbit/chunky/issues")); gitHubLink.setOnAction( e -> app.getHostServices().showDocument("https://github.com/llbit/chunky")); forumLink.setOnAction( e -> app.getHostServices().showDocument("https://www.reddit.com/r/chunky")); stage.setOnCloseRequest(event -> { Platform.exit(); System.exit(0); }); menuExit.setOnAction(event -> { Platform.exit(); System.exit(0); }); borderPane.prefHeightProperty().bind(scene.heightProperty()); borderPane.prefWidthProperty().bind(scene.widthProperty()); } /** * Open the 3D chunk view. */ private synchronized void open3DView() { try { if (controls == null) { controls = new RenderControlsFx(this); controls.show(); } else { controls.show(); controls.toFront(); } } catch (IOException e) { Log.error("Failed to create render controls window.", e); } } public void createNew3DScene() { if (hasActiveRenderControls()) { Alert alert = new Alert(Alert.AlertType.CONFIRMATION); alert.setTitle("Create New Scene"); alert.setHeaderText("Overwrite existing scene?"); alert.setContentText( "It seems like a scene already exists. Do you wish to overwrite it?"); if (alert.showAndWait().get() != ButtonType.OK) { return; } } // Choose a default scene name. World world = mapLoader.getWorld(); RenderContext context = chunky.getRenderContext(); String preferredName = AsynchronousSceneManager.preferredSceneName(context, world.levelName()); if (!AsynchronousSceneManager.sceneNameIsValid(preferredName) || !AsynchronousSceneManager.sceneNameIsAvailable(context, preferredName)) { preferredName = "Untitled Scene"; } // Reset the scene state to the default scene state. chunky.getRenderController().getSceneManager().getScene().resetScene(preferredName, chunky.getSceneFactory()); // Show the render controls etc. open3DView(); // Load selected chunks. Collection<ChunkPosition> selection = mapLoader.getChunkSelection().getSelection(); if (!selection.isEmpty()) { chunky.getSceneManager().loadFreshChunks(mapLoader.getWorld(), selection); } } public void loadScene(String sceneName) { open3DView(); try { chunky.getSceneManager().loadScene(sceneName); } catch (IOException | SceneLoadingError | InterruptedException e) { Log.error("Failed to load scene", e); } } /** * Show the scene selector dialog. */ public void loadScene() { try { SceneChooser chooser = new SceneChooser(this); chooser.show(); } catch (IOException e) { Log.error("Failed to create scene chooser window.", e); } } public void panToCamera() { chunky.getRenderController().getSceneProvider() .withSceneProtected(scene -> mapLoader.panTo(scene.camera().getPosition())); } public void moveCameraTo(double x, double z) { chunky.getRenderController().getSceneProvider().withEditSceneProtected(scene -> { Camera camera = scene.camera(); Vector3 pos = new Vector3(x, camera.getPosition().y, z); camera.setPosition(pos); }); } public boolean hasActiveRenderControls() { return controls != null && controls.isShowing(); } public ChunkMap getMap() { return map; } @Override public void viewUpdated() { } @Override public void layerChanged(int layer) { } @Override public void viewMoved() { mapLoader.trackPlayerProperty().set(false); mapLoader.trackCameraProperty().set(false); } @Override public void cameraPositionUpdated() { } @Override public void chunkSelectionChanged() { } public Canvas getMinimapCanvas() { return minimapCanvas; } @Override public void regionUpdated(ChunkPosition region) { } @Override public void chunkUpdated(ChunkPosition region) { } public Minimap getMinimap() { return minimap; } public Chunky getChunky() { return chunky; } public WorldMapLoader getMapLoader() { return mapLoader; } public Canvas getMapOverlay() { return mapOverlay; } /** * This must be called when creating the Chunky User Interface. */ public void setChunky(Chunky chunky) { this.chunky = chunky; } public void openSceneDirectory() { File sceneDir = chunky.options.sceneDir; if (sceneDir != null) { // Running Desktop.open() on the JavaFX application thread seems to // lock up the application on Linux, so we create a new thread to run that. // This StackOverflow question seems to ask about the same bug: // http://stackoverflow.com/questions/23176624/javafx-freeze-on-desktop-openfile-desktop-browseuri new Thread(() -> { try { if (Desktop.isDesktopSupported()) { Desktop.getDesktop().open(sceneDir); } else { Log.warn("Can not open system file browser."); } } catch (IOException e1) { Log.warn("Failed to open scene directory.", e1); } }).start(); } } }