/* Copyright (c) 2012-2014 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.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.control.Tooltip;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.stage.PopupWindow;
import se.llbit.chunky.map.WorldMapLoader;
import se.llbit.chunky.renderer.scene.Camera;
import se.llbit.chunky.world.Chunk;
import se.llbit.chunky.world.ChunkPosition;
import se.llbit.chunky.world.ChunkView;
import se.llbit.chunky.world.Icon;
import se.llbit.chunky.world.PlayerEntityData;
import se.llbit.chunky.world.World;
import se.llbit.log.Log;
import se.llbit.math.QuickMath;
import se.llbit.math.Ray;
import se.llbit.math.Vector2;
import se.llbit.math.Vector3;
import java.io.File;
import java.io.IOException;
/**
* UI component that draws a 2D Minecraft map.
*
* @author Jesper Öqvist <jesper@llbit.se>
*/
public class ChunkMap extends Map2D {
/** Controls the selection area when selecting visible chunks. */
private static final double CHUNK_SELECT_RADIUS = -8 * 1.4142;
/**
* Indicates whether or not the selection rectangle should be drawn.
*/
protected volatile boolean selectRect = false;
private final ContextMenu contextMenu = new ContextMenu();
private final MenuItem moveCameraHere;
private final MenuItem selectVisible;
private volatile ChunkPosition start = ChunkPosition.get(0, 0);
private volatile ChunkPosition end = ChunkPosition.get(0, 0);
public int lastX;
public int lastY;
public int clickX;
public int clickY;
protected boolean ctrlModifier = false;
protected boolean shiftModifier = false;
protected boolean dragging = false;
protected boolean mouseDown = false;
public Tooltip tooltip = new Tooltip();
public ChunkMap(final WorldMapLoader loader, final ChunkyFxController controller) {
super(loader, controller);
tooltip.setAutoHide(true);
tooltip.setConsumeAutoHidingEvents(false);
tooltip.setAnchorLocation(PopupWindow.AnchorLocation.WINDOW_BOTTOM_LEFT);
MenuItem createScene = new MenuItem("New 3D scene...");
createScene.setGraphic(new ImageView(Icon.sky.fxImage()));
createScene.setOnAction(event -> controller.createNew3DScene());
MenuItem loadScene = new MenuItem("Load scene...");
loadScene.setGraphic(new ImageView(Icon.load.fxImage()));
loadScene.setOnAction(event -> controller.loadScene());
MenuItem clearSelection = new MenuItem("Clear selection");
clearSelection.setGraphic(new ImageView(Icon.clear.fxImage()));
clearSelection.setOnAction(event -> loader.clearChunkSelection());
moveCameraHere = new MenuItem("Move camera here");
moveCameraHere.setOnAction(event -> {
ChunkView theView = new ChunkView(view); // Make thread-local copy.
double scale = theView.scale;
double x = theView.x + (clickX - getWidth() / 2) / scale;
double z = theView.z + (clickY - getHeight() / 2) / scale;
controller.moveCameraTo(x * 16, z * 16);
});
selectVisible = new MenuItem("Select visible chunks");
selectVisible.setGraphic(new ImageView(Icon.eye.fxImage()));
selectVisible.setOnAction(event -> {
ChunkView mapView = new ChunkView(view); // Make thread-local copy.
if (controller.getChunky().sceneInitialized()) {
controller.getChunky().getRenderController().getSceneProvider().withSceneProtected(
scene -> selectVisibleChunks(mapView, loader, scene));
}
});
contextMenu.getItems()
.addAll(createScene, loadScene, clearSelection, moveCameraHere, selectVisible);
}
/**
* Draws a visualization of the 3D camera view on the 2D map.
*/
protected void drawViewBounds(Canvas canvas) {
ChunkView mapView = new ChunkView(view); // Make thread-local copy.
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.clearRect(0, 0, canvas.getWidth(), canvas.getHeight());
if (controller.hasActiveRenderControls()) {
// TODO: this can block for a long time, so it should ideally not be done on the JavaFX application thread.
controller.getChunky().getRenderController().getSceneProvider().withSceneProtected(
scene -> drawViewBounds(gc, mapView, scene));
}
}
protected synchronized void selectWithinRect() {
if (selectRect) {
ChunkPosition cp0 = start;
ChunkPosition cp1 = end;
int x0 = Math.min(cp0.x, cp1.x);
int x1 = Math.max(cp0.x, cp1.x);
int z0 = Math.min(cp0.z, cp1.z);
int z1 = Math.max(cp0.z, cp1.z);
if (ctrlModifier) {
mapLoader.deselectChunks(x0, x1, z0, z1);
} else {
mapLoader.selectChunks(x0, x1, z0, z1);
}
}
}
protected void clearSelectionRect() {
if (selectRect) {
selectRect = false;
repaintDeferred();
}
}
/**
* Draw the selection rectangle or chunk hover rectangle.
*/
private void drawSelectionRect(GraphicsContext gc) {
ChunkView mapView = new ChunkView(view); // Make thread-local copy.
ChunkPosition cp = end;
gc.setStroke(javafx.scene.paint.Color.RED);
if (selectRect) {
ChunkPosition cp0 = start;
ChunkPosition cp1 = end;
int x0 = Math.min(cp0.x, cp1.x);
int x1 = Math.max(cp0.x, cp1.x);
int z0 = Math.min(cp0.z, cp1.z);
int z1 = Math.max(cp0.z, cp1.z);
x0 = (int) (mapView.scale * (x0 - mapView.x0));
z0 = (int) (mapView.scale * (z0 - mapView.z0));
x1 = (int) (mapView.scale * (x1 - mapView.x0 + 1));
z1 = (int) (mapView.scale * (z1 - mapView.z0 + 1));
gc.strokeRect(x0, z0, x1 - x0, z1 - z0);
} else {
// Test if hovered chunk is visible.
if (mapView.isChunkVisible(cp)) {
if (mapView.scale >= 16) {
int x0 = (int) (mapView.scale * (cp.x - mapView.x0));
int y0 = (int) (mapView.scale * (cp.z - mapView.z0));
int blockScale = mapView.scale;
gc.strokeRect(x0, y0, blockScale, blockScale);
} else {
// Hovered region.
int rx = cp.x >> 5;
int rz = cp.z >> 5;
int x0 = (int) (mapView.scale * (rx * 32 - mapView.x0));
int y0 = (int) (mapView.scale * (rz * 32 - mapView.z0));
gc.strokeRect(x0, y0, mapView.scale * 32, mapView.scale * 32);
}
}
}
}
/**
* Render the current view to a PNG image.
*/
public void renderView(File targetFile, ProgressTracker progress) {
if (!progress.isBusy()) {
if (progress.tryStartJob()) {
progress.setJobName("PNG export");
progress.setJobSize(1);
try {
mapBuffer.renderPng(targetFile);
} catch (IOException e) {
Log.error("Failed to export PNG.", e);
}
progress.finishJob();
}
}
}
public int getWidth() {
return mapLoader.getMapView().width;
}
public int getHeight() {
return mapLoader.getMapView().height;
}
public void onKeyPressed(KeyEvent keyEvent) {
switch (keyEvent.getCode()) {
case CONTROL:
ctrlModifier = true;
break;
case SHIFT:
shiftModifier = true;
break;
}
}
public void onKeyReleased(KeyEvent keyEvent) {
switch (keyEvent.getCode()) {
case CONTROL:
ctrlModifier = false;
break;
case SHIFT:
shiftModifier = false;
break;
}
}
public void onMouseDragged(MouseEvent event) {
int dx = lastX - (int) event.getX();
int dy = lastY - (int) event.getY();
lastX = (int) event.getX();
lastY = (int) event.getY();
ChunkPosition chunk = getChunk(event);
if (chunk != end) {
end = chunk;
repaintDirect();
}
if (selectRect || !dragging && shiftModifier) {
selectRect = true;
} else {
dragging = true;
mapLoader.viewDragged(dx, dy);
}
}
private ChunkPosition getChunk(MouseEvent event) {
ChunkView mapView = new ChunkView(view); // Make thread-local copy.
double scale = mapView.scale;
double x = mapView.x + (event.getX() - getWidth() / 2.0) / scale;
double z = mapView.z + (event.getY() - getHeight() / 2.0) / scale;
int cx = (int) QuickMath.floor(x);
int cz = (int) QuickMath.floor(z);
int bx = (int) QuickMath.floor((x - cx) * 16);
int bz = (int) QuickMath.floor((z - cz) * 16);
bx = Math.max(0, Math.min(Chunk.X_MAX - 1, bx));
bz = Math.max(0, Math.min(Chunk.Z_MAX - 1, bz));
ChunkPosition cp = ChunkPosition.get(cx, cz);
if (!mouseDown) {
Chunk hoveredChunk = mapLoader.getWorld().getChunk(cp);
if (!hoveredChunk.isEmpty()) {
tooltip.setText(
String.format("%s, %s", hoveredChunk.toString(), hoveredChunk.biomeAt(bx, bz)));
} else {
tooltip.setText(hoveredChunk.toString());
}
Canvas mapOverlay = controller.getMapOverlay();
Scene scene = mapOverlay.getScene();
if (mapOverlay.isFocused()) {
tooltip.show(scene.getWindow(), scene.getWindow().getX(),
scene.getWindow().getY() + scene.getWindow().getHeight());
}
}
return cp;
}
public void onMousePressed(MouseEvent event) {
lastX = (int) event.getX();
lastY = (int) event.getY();
if (event.getButton() == MouseButton.SECONDARY) {
clickX = lastX;
clickY = lastY;
moveCameraHere.setVisible(controller.hasActiveRenderControls());
selectVisible.setVisible(controller.hasActiveRenderControls());
contextMenu.show(controller.getMapOverlay(), event.getScreenX(), event.getScreenY());
} else {
if (contextMenu.isShowing()) {
contextMenu.hide();
} else {
mouseDown = true;
}
}
}
public void onMouseReleased(MouseEvent event) {
if (!mouseDown) {
return;
}
mouseDown = false;
if (!selectRect) {
if (!dragging) {
int x = (int) event.getX();
int y = (int) event.getY();
ChunkView theView = mapLoader.getMapView();
double scale = theView.scale;
int cx = (int) QuickMath.floor(theView.x + (x - getWidth() / 2) / scale);
int cz = (int) QuickMath.floor(theView.z + (y - getHeight() / 2) / scale);
if (theView.scale >= 16) {
mapLoader.toggleChunkSelection(cx, cz);
} else {
mapLoader.selectRegion(cx, cz);
}
}
} else {
selectWithinRect();
clearSelectionRect();
}
start = end;
dragging = false;
}
public void onMouseMoved(MouseEvent event) {
ChunkPosition chunk = getChunk(event);
if (chunk != start) {
start = chunk;
end = chunk;
repaintDirect();
}
lastX = (int) event.getX();
lastY = (int) event.getY();
}
public void onScroll(ScrollEvent event) {
int diff = (int) (-event.getDeltaY() / event.getMultiplierY());
if (ctrlModifier) {
mapLoader.setLayer(mapLoader.getLayer() + diff);
} else {
int scale = mapLoader.getScale();
if ((scale - diff) <= 16) {
mapLoader.setScale(scale - diff);
} else if ((scale - diff * 4) <= 64) {
mapLoader.setScale(scale - diff * 4);
} else if ((scale - diff * 16) <= 128) {
mapLoader.setScale(scale - diff * 16);
} else {
mapLoader.setScale(scale - diff * 64);
}
}
}
@Override protected void repaint(GraphicsContext gc) {
super.repaint(gc);
drawPlayers(gc);
drawSpawn(gc);
drawSelectionRect(gc);
if (controller.hasActiveRenderControls()) {
drawViewBounds(controller.getMapOverlay());
}
}
private void drawPlayers(GraphicsContext gc) {
ChunkView mapView = new ChunkView(view); // Make thread-local copy.
World world = mapLoader.getWorld();
double blockScale = mapView.scale / 16.;
for (PlayerEntityData player : world.getPlayerPositions()) {
int px = (int) QuickMath.floor(player.x * blockScale);
int py = (int) QuickMath.floor(player.y);
int pz = (int) QuickMath.floor(player.z * blockScale);
int ppx = px - (int) QuickMath.floor(mapView.x0 * mapView.scale);
int ppy = pz - (int) QuickMath.floor(mapView.z0 * mapView.scale);
int pw = (int) QuickMath.max(8, QuickMath.min(16, blockScale * 2));
ppx = Math.min(mapView.width - pw, Math.max(0, ppx - pw / 2));
ppy = Math.min(mapView.height - pw, Math.max(0, ppy - pw / 2));
if (py == mapLoader.getLayer()) {
gc.drawImage(Icon.face.fxImage(), ppx, ppy, pw, pw);
} else {
gc.drawImage(Icon.face_t.fxImage(), ppx, ppy, pw, pw);
}
}
}
private void drawSpawn(GraphicsContext gc) {
ChunkView mapView = new ChunkView(view); // Make thread-local copy.
World world = mapLoader.getWorld();
double blockScale = mapView.scale / 16.;
if (!world.haveSpawnPos()) {
return;
}
int px = (int) QuickMath.floor(world.spawnPosX() * blockScale);
int py = (int) QuickMath.floor(world.spawnPosY());
int pz = (int) QuickMath.floor(world.spawnPosZ() * blockScale);
int ppx = px - (int) QuickMath.floor(mapView.x0 * mapView.scale);
int ppy = pz - (int) QuickMath.floor(mapView.z0 * mapView.scale);
int pw = (int) QuickMath.max(8, QuickMath.min(16, blockScale * 2));
ppx = Math.min(mapView.width - pw, Math.max(0, ppx - pw / 2));
ppy = Math.min(mapView.height - pw, Math.max(0, ppy - pw / 2));
if (py == mapLoader.getLayer()) {
gc.drawImage(Icon.home.fxImage(), ppx, ppy, pw, pw);
} else {
gc.drawImage(Icon.home_t.fxImage(), ppx, ppy, pw, pw);
}
}
@Override public void cameraPositionUpdated() {
drawViewBounds(controller.getMapOverlay());
}
public void selectVisibleChunks(ChunkView cv, WorldMapLoader loader,
se.llbit.chunky.renderer.scene.Scene scene) {
Camera camera = scene.camera();
int width = scene.canvasWidth();
int height = scene.canvasHeight();
double halfWidth = width / (2.0 * height);
Vector3 o = new Vector3(camera.getPosition());
Ray ray = new Ray();
Vector3[] corners = new Vector3[4];
camera.calcViewRay(ray, -halfWidth, -0.5);
corners[0] = new Vector3(ray.d);
camera.calcViewRay(ray, -halfWidth, 0.5);
corners[1] = new Vector3(ray.d);
camera.calcViewRay(ray, halfWidth, 0.5);
corners[2] = new Vector3(ray.d);
camera.calcViewRay(ray, halfWidth, -0.5);
corners[3] = new Vector3(ray.d);
Vector3[] norm = new Vector3[4];
norm[0] = new Vector3();
norm[0].cross(corners[1], corners[0]);
norm[0].normalize();
norm[1] = new Vector3();
norm[1].cross(corners[2], corners[1]);
norm[1].normalize();
norm[2] = new Vector3();
norm[2].cross(corners[3], corners[2]);
norm[2].normalize();
norm[3] = new Vector3();
norm[3].cross(corners[0], corners[3]);
norm[3].normalize();
for (int x = cv.px0; x <= cv.px1; ++x) {
for (int z = cv.pz0; z <= cv.pz1; ++z) {
// Chunk top center position:
Vector3 pos = new Vector3((x + 0.5) * 16, 63, (z + 0.5) * 16);
pos.sub(o);
if (norm[0].dot(pos) > CHUNK_SELECT_RADIUS && norm[1].dot(pos) > CHUNK_SELECT_RADIUS
&& norm[2].dot(pos) > CHUNK_SELECT_RADIUS && norm[3].dot(pos) > CHUNK_SELECT_RADIUS) {
loader.selectChunk(x, z);
}
}
}
}
/**
* Draws a visualization of the camera view from the specified scene on the map.
*/
public static void drawViewBounds(GraphicsContext gc, ChunkView cv,
se.llbit.chunky.renderer.scene.Scene scene) {
Camera camera = scene.camera();
int width = scene.canvasWidth();
int height = scene.canvasHeight();
double halfWidth = width / (2.0 * height);
Ray ray = new Ray();
Vector3[] corners = new Vector3[4];
Vector2[] bounds = new Vector2[4];
camera.calcViewRay(ray, -halfWidth, -0.5);
corners[0] = new Vector3(ray.d);
bounds[0] = findMapPos(ray, cv);
camera.calcViewRay(ray, -halfWidth, 0.5);
corners[1] = new Vector3(ray.d);
bounds[1] = findMapPos(ray, cv);
camera.calcViewRay(ray, halfWidth, 0.5);
corners[2] = new Vector3(ray.d);
bounds[2] = findMapPos(ray, cv);
camera.calcViewRay(ray, halfWidth, -0.5);
corners[3] = new Vector3(ray.d);
bounds[3] = findMapPos(ray, cv);
gc.setStroke(javafx.scene.paint.Color.YELLOW);
for (int i = 0; i < 4; ++i) {
int j = (i + 1) % 4;
if (bounds[i] != null && bounds[j] != null) {
drawLine(gc, bounds[i], bounds[j]);
} else if (bounds[i] != null && bounds[j] == null) {
drawExtended(gc, cv, bounds, corners, i, j);
} else if (bounds[j] != null && bounds[i] == null) {
drawExtended(gc, cv, bounds, corners, j, i);
}
}
int ox = (int) (cv.scale * (ray.o.x / 16 - cv.x0));
int oy = (int) (cv.scale * (ray.o.z / 16 - cv.z0));
// Draw the camera facing direction indicator.
camera.calcViewRay(ray, 0, 0);
Vector3 o = new Vector3(ray.o);
o.x /= 16;
o.z /= 16;
o.scaleAdd(1, ray.d);
int x = (int) (cv.scale * (o.x - cv.x0));
int y = (int) (cv.scale * (o.z - cv.z0));
gc.strokeLine(ox, oy, x, y);
// Draw the camera icon.
gc.drawImage(Icon.camera.fxImage(), ox - 8, oy - 8);
}
/**
* Find the point where the ray intersects the ground (y=63).
*/
private static Vector2 findMapPos(Ray ray, ChunkView cv) {
if (ray.d.y < 0 && ray.o.y > 63 || ray.d.y > 0 && ray.o.y < 63) {
// Ray intersects ground.
double d = (63 - ray.o.y) / ray.d.y;
Vector3 pos = new Vector3();
pos.scaleAdd(d, ray.d, ray.o);
return new Vector2(cv.scale * (pos.x / 16 - cv.x0), cv.scale * (pos.z / 16 - cv.z0));
} else {
return null;
}
}
private static void drawExtended(GraphicsContext gc, ChunkView cv, Vector2[] bounds, Vector3[] corners,
int i, int j) {
Vector3 c = new Vector3();
c.cross(corners[i], corners[j]);
Vector2 c2 = new Vector2();
c2.x = c.z;
c2.y = -c.x;
c2.normalize();
if (corners[i].y > 0) {
c2.scale(-1);
}
double tNear = Double.POSITIVE_INFINITY;
double t = -bounds[i].x / c2.x;
if (t > 0 && t < tNear) {
tNear = t;
}
t = (cv.scale * (cv.x1 - cv.x0) - bounds[i].x) / c2.x;
if (t > 0 && t < tNear) {
tNear = t;
}
t = -bounds[i].y / c2.y;
if (t > 0 && t < tNear) {
tNear = t;
}
t = (cv.scale * (cv.z1 - cv.z0) - bounds[i].y) / c2.y;
if (t > 0 && t < tNear) {
tNear = t;
}
if (tNear != Double.POSITIVE_INFINITY) {
Vector2 p = new Vector2(bounds[i]);
p.scaleAdd(tNear, c2);
drawLine(gc, p, bounds[i]);
}
}
private static void drawLine(GraphicsContext gc, Vector2 v1, Vector2 v2) {
int x1 = (int) v1.x;
int y1 = (int) v1.y;
int x2 = (int) v2.x;
int y2 = (int) v2.y;
gc.strokeLine(x1, y1, x2, y2);
}
}