diff --git a/core/src/main/java/com/volmit/iris/core/gui/LoadingAnimation.java b/core/src/main/java/com/volmit/iris/core/gui/LoadingAnimation.java new file mode 100644 index 000000000..6853d1b38 --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/LoadingAnimation.java @@ -0,0 +1,145 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program 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. + * + * This program 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 this program. If not, see . + */ +package com.volmit.iris.core.gui; + +import java.awt.AlphaComposite; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.geom.Path2D; +import java.awt.image.BufferedImage; + +/** + * A little cube of blocks building itself while something loads: the blocks drop in one by one, the cube holds a + * moment, fades, and starts over. Loads shorter than {@link #SHOW_AFTER_MILLIS} never show it, so they don't flash. + */ +final class LoadingAnimation { + static final int SHOW_AFTER_MILLIS = 300; + static final int FRAME_MILLIS = 33; + + // Back to front, so each block lands on or beside ones already there. + private static final int[][] BUILD_ORDER = { + {0, 0, 0}, {1, 0, 0}, {0, 0, 1}, {1, 0, 1}, + {0, 1, 0}, {1, 1, 0}, {0, 1, 1}, {1, 1, 1}}; + private static final int STEP_MILLIS = 140; + private static final int DROP_MILLIS = 260; + private static final int HOLD_MILLIS = 450; + private static final int FADE_MILLIS = 320; + private static final int CYCLE_MILLIS = STEP_MILLIS * (BUILD_ORDER.length - 1) + DROP_MILLIS + HOLD_MILLIS + FADE_MILLIS; + private static final double DROP_HEIGHT_BLOCKS = 1.6D; + private static final double ISOMETRIC_WIDTH = Math.cos(Math.PI / 6D); + private static final double ISOMETRIC_DEPTH = 0.5D; + private static final float SIDE_SHADE = 0.78F; + private static final float FAR_SIDE_SHADE = 0.6F; + + private LoadingAnimation() { + } + + /** + * Whether a load that began at {@code loadingSinceMillis} has taken long enough to show the animation. + */ + static boolean isDue(long loadingSinceMillis) { + return System.currentTimeMillis() - loadingSinceMillis >= SHOW_AFTER_MILLIS; + } + + /** + * Draws the cube centred on a point, {@code blockSize} pixels to a block edge, as it looks + * {@code elapsedMillis} after it first showed. + */ + static void paint(Graphics2D graphics, double centerX, double centerY, double blockSize, long elapsedMillis) { + long time = Math.floorMod(elapsedMillis, CYCLE_MILLIS); + long fadeStart = CYCLE_MILLIS - FADE_MILLIS; + if (time < fadeStart) { + drawCube(graphics, centerX, centerY, blockSize, time); + return; + } + fadeCube(graphics, centerX, centerY, blockSize, time, 1F - (time - fadeStart) / (float) FADE_MILLIS); + } + + // Faded block by block, the blocks behind would show through; the whole cube is drawn first and faded as one. + private static void fadeCube(Graphics2D graphics, double centerX, double centerY, double blockSize, long time, float opacity) { + int extent = (int) Math.ceil(blockSize * (2D + DROP_HEIGHT_BLOCKS) * 2D); + BufferedImage cube = new BufferedImage(extent, extent, BufferedImage.TYPE_INT_ARGB); + Graphics2D cubeCanvas = cube.createGraphics(); + drawCube(cubeCanvas, extent / 2D, extent / 2D, blockSize, time); + cubeCanvas.dispose(); + Graphics2D canvas = (Graphics2D) graphics.create(); + try { + canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, Math.max(0F, opacity))); + canvas.drawImage(cube, (int) Math.round(centerX - extent / 2D), (int) Math.round(centerY - extent / 2D), null); + } finally { + canvas.dispose(); + } + } + + private static void drawCube(Graphics2D graphics, double centerX, double centerY, double blockSize, long time) { + Graphics2D canvas = (Graphics2D) graphics.create(); + try { + canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + for (int index = 0; index < BUILD_ORDER.length; index++) { + long sinceDropStart = time - (long) index * STEP_MILLIS; + if (sinceDropStart < 0) { + break; + } + double fallen = easeOut(Math.min(1D, sinceDropStart / (double) DROP_MILLIS)); + int[] block = BUILD_ORDER[index]; + drawBlock(canvas, centerX, centerY, blockSize, block[0], block[1] + DROP_HEIGHT_BLOCKS * (1D - fallen), block[2]); + } + } finally { + canvas.dispose(); + } + } + + private static double easeOut(double progress) { + double remaining = 1D - progress; + return 1D - remaining * remaining * remaining; + } + + // The cube's middle, (1, 1, 1), lands on the centre point; the top and the two sides facing the viewer show. + private static void drawBlock(Graphics2D canvas, double centerX, double centerY, double size, double x, double y, double z) { + Color top = StudioTheme.ACCENT; + canvas.setColor(top); + canvas.fill(face(centerX, centerY, size, x, y, z, new double[][]{{0, 1, 0}, {1, 1, 0}, {1, 1, 1}, {0, 1, 1}})); + canvas.setColor(shaded(top, SIDE_SHADE)); + canvas.fill(face(centerX, centerY, size, x, y, z, new double[][]{{1, 0, 0}, {1, 1, 0}, {1, 1, 1}, {1, 0, 1}})); + canvas.setColor(shaded(top, FAR_SIDE_SHADE)); + canvas.fill(face(centerX, centerY, size, x, y, z, new double[][]{{0, 0, 1}, {1, 0, 1}, {1, 1, 1}, {0, 1, 1}})); + } + + private static Path2D face(double centerX, double centerY, double size, double x, double y, double z, double[][] corners) { + Path2D path = new Path2D.Double(); + for (int corner = 0; corner < corners.length; corner++) { + double cornerX = x + corners[corner][0] - 1D; + double cornerY = y + corners[corner][1] - 1D; + double cornerZ = z + corners[corner][2] - 1D; + double screenX = centerX + (cornerX - cornerZ) * ISOMETRIC_WIDTH * size; + double screenY = centerY + (cornerX + cornerZ) * ISOMETRIC_DEPTH * size - cornerY * size; + if (corner == 0) { + path.moveTo(screenX, screenY); + } else { + path.lineTo(screenX, screenY); + } + } + path.closePath(); + return path; + } + + private static Color shaded(Color color, float shade) { + return new Color(Math.round(color.getRed() * shade), Math.round(color.getGreen() * shade), Math.round(color.getBlue() * shade)); + } +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectExplorer.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectExplorer.java index a650d48b4..77093c4d9 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/ObjectExplorer.java +++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectExplorer.java @@ -155,7 +155,7 @@ final class ObjectExplorer { // Textures load first, since the first object would otherwise be drawn in flat colours. void open() { - statusBar.showActivity("Loading Minecraft textures..."); + viewport.showLoading("Loading Minecraft textures"); loader.execute(() -> { loadTextures(); List keys = objectKeys(); @@ -217,7 +217,7 @@ final class ObjectExplorer { variantPicker.forgetTriedSeeds(); showVariantList(List.of(), OptionalLong.empty(), OptionalLong.empty(), 0); } - long revision = startLoading(key); + long revision = startBaking(key); loader.execute(() -> { if (revision != objectRevision.get()) { return; @@ -235,7 +235,7 @@ final class ObjectExplorer { int variantIndex = shownVariantIndex; shownSeed = OptionalLong.of(seed); variantPicker.showSeed(shownSeed, pack); - long revision = startLoading(key + " with seed " + seed); + long revision = startBaking(key + " with seed " + seed); loader.execute(() -> { if (revision == objectRevision.get()) { loadVariants(revision, key, pack, OptionalLong.of(seed), variantIndex); @@ -277,7 +277,7 @@ final class ObjectExplorer { return; } if (variants.isEmpty()) { - gallery.showMessage("Loading " + shownKey + "..."); + gallery.showLoading(); } else if (variants.size() == 1) { toggleComparing(); } else { @@ -380,12 +380,19 @@ final class ObjectExplorer { return new ObjectMesh.Bounds(0, 0, 0, sizeX, sizeY, sizeZ); } + // A new list of variants is on its way, so every thumbnail is outdated, unlike when stepping to another variant. + private long startBaking(String key) { + if (comparingVariants) { + gallery.showLoading(); + } + return startLoading(key); + } + private long startLoading(String key) { shown = null; wholeMesh = null; meshRevision.incrementAndGet(); - viewport.showMessage("Loading " + key + "..."); - statusBar.showActivity("Loading " + key + "..."); + viewport.showLoading("Loading " + key); return objectRevision.incrementAndGet(); } diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectVariantGallery.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectVariantGallery.java index a58c2a3ba..4900da7e0 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/ObjectVariantGallery.java +++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectVariantGallery.java @@ -25,6 +25,8 @@ import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.KeyStroke; import javax.swing.Scrollable; +import javax.swing.Timer; +import java.awt.AlphaComposite; import java.awt.BorderLayout; import java.awt.Cursor; import java.awt.Dimension; @@ -44,7 +46,8 @@ import java.util.function.IntConsumer; /** * Every variant of a procedural object side by side, drawn at one scale so their sizes compare. Clicking one opens it - * in the 3D view. + * in the 3D view. While new thumbnails are drawn, each card keeps its old one faded until its own arrives; cards + * still waiting after a moment play the loading animation, one after another like a wave. */ final class ObjectVariantGallery extends JPanel { private static final long serialVersionUID = 6153024487310558262L; @@ -54,14 +57,19 @@ final class ObjectVariantGallery extends JPanel { private static final int EDGE_MARGIN = 16; // The variant bar floats over the bottom edge, so the last row scrolls clear of it. private static final int VARIANT_BAR_CLEARANCE = 64; + private static final float OUTDATED_THUMBNAIL_OPACITY = 0.35F; + private static final int THUMBNAIL_SIZE_PER_LOADING_BLOCK = 14; + private static final int LOADING_WAVE_STEP_MILLIS = 90; private final JLabel title = new JLabel(" "); private final CardGrid grid = new CardGrid(); private final IntConsumer onVariantOpened; private final Runnable onThemeChanged; private final List cards = new ArrayList<>(); + private final Timer loadingTicker = new Timer(LoadingAnimation.FRAME_MILLIS, event -> repaintWaitingCards()); private int coveredLeftWidth; private int selectedIndex; + private long waitingSinceMillis; /** * @param onVariantOpened told the index of a variant the user clicks @@ -113,10 +121,33 @@ final class ObjectVariantGallery extends JPanel { } /** - * Empty cards for {@code count} variants, waiting for their thumbnails. + * Cards for {@code count} variants, waiting for their thumbnails. The cards already shown stay, with their old + * thumbnails faded, so a reroll never blanks the view. */ void showVariants(String heading, int count, int selected) { title.setText(heading); + if (count != cards.size()) { + replaceCards(count); + } + startWaiting(); + select(selected); + } + + /** Fades every thumbnail while something new loads. */ + void showLoading() { + startWaiting(); + } + + void showThumbnail(int index, BufferedImage thumbnail) { + if (index < cards.size()) { + cards.get(index).showThumbnail(thumbnail); + } + if (cards.stream().noneMatch(VariantCard::isWaiting)) { + loadingTicker.stop(); + } + } + + private void replaceCards(int count) { cards.clear(); grid.removeAll(); for (int index = 0; index < count; index++) { @@ -124,14 +155,21 @@ final class ObjectVariantGallery extends JPanel { cards.add(card); grid.add(card); } - select(selected); grid.revalidate(); grid.repaint(); } - void showThumbnail(int index, BufferedImage thumbnail) { - if (index < cards.size()) { - cards.get(index).showThumbnail(thumbnail); + private void startWaiting() { + if (!loadingTicker.isRunning()) { + waitingSinceMillis = System.currentTimeMillis(); + loadingTicker.start(); + } + cards.forEach(VariantCard::markOutdated); + } + + private void repaintWaitingCards() { + if (LoadingAnimation.isDue(waitingSinceMillis)) { + cards.stream().filter(VariantCard::isWaiting).forEach(VariantCard::repaint); } } @@ -146,6 +184,7 @@ final class ObjectVariantGallery extends JPanel { void showMessage(String text) { title.setText(text); + loadingTicker.stop(); cards.clear(); grid.removeAll(); grid.revalidate(); @@ -230,6 +269,7 @@ final class ObjectVariantGallery extends JPanel { private final int index; private BufferedImage thumbnail; + private boolean thumbnailOutdated; private boolean hovered; VariantCard(int index) { @@ -256,9 +296,19 @@ final class ObjectVariantGallery extends JPanel { void showThumbnail(BufferedImage image) { thumbnail = image; + thumbnailOutdated = false; repaint(); } + void markOutdated() { + thumbnailOutdated = true; + repaint(); + } + + boolean isWaiting() { + return thumbnail == null || thumbnailOutdated; + } + private void setHovered(boolean nowHovered) { hovered = nowHovered; repaint(); @@ -277,12 +327,7 @@ final class ObjectVariantGallery extends JPanel { int size = thumbnailSize(); canvas.setFont(StudioTheme.smallFont()); FontMetrics metrics = canvas.getFontMetrics(); - if (thumbnail != null) { - canvas.drawImage(thumbnail, padding, padding, size, size, null); - } else { - canvas.setColor(StudioTheme.MUTED_TEXT); - drawCentered(canvas, "Loading...", padding + size / 2); - } + drawThumbnail(canvas, padding, size); canvas.setColor(StudioTheme.TEXT); drawCentered(canvas, "Variant " + (index + 1), padding * 2 + size + metrics.getAscent()); } finally { @@ -290,6 +335,22 @@ final class ObjectVariantGallery extends JPanel { } } + private void drawThumbnail(Graphics2D canvas, int padding, int size) { + if (thumbnail != null) { + Graphics2D faded = (Graphics2D) canvas.create(); + if (thumbnailOutdated) { + faded.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, OUTDATED_THUMBNAIL_OPACITY)); + } + faded.drawImage(thumbnail, padding, padding, size, size, null); + faded.dispose(); + } + if (isWaiting() && LoadingAnimation.isDue(waitingSinceMillis)) { + long shownMillis = System.currentTimeMillis() - waitingSinceMillis - LoadingAnimation.SHOW_AFTER_MILLIS; + LoadingAnimation.paint(canvas, padding + size / 2D, padding + size / 2D, size / (double) THUMBNAIL_SIZE_PER_LOADING_BLOCK, + shownMillis - (long) index * LOADING_WAVE_STEP_MILLIS); + } + } + private void drawCentered(Graphics2D canvas, String text, int baseline) { canvas.drawString(text, (getWidth() - canvas.getFontMetrics().stringWidth(text)) / 2, baseline); } diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectViewport.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectViewport.java index c1ee7087c..7debd0f26 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/ObjectViewport.java +++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectViewport.java @@ -22,6 +22,8 @@ import javax.swing.AbstractAction; import javax.swing.JComponent; import javax.swing.KeyStroke; import javax.swing.SwingUtilities; +import javax.swing.Timer; +import java.awt.AlphaComposite; import java.awt.EventQueue; import java.awt.Graphics; import java.awt.Graphics2D; @@ -42,12 +44,16 @@ import java.util.function.UnaryOperator; /** * The 3D view of one object. Drag to orbit, shift-drag or middle-drag to pan, scroll to zoom; arrow keys orbit and * plus and minus zoom too. Frames are drawn on a background thread, one at a time, always for the latest camera. + * While the next object loads, the last picture stays up; only a slow load dims it under the loading animation. */ final class ObjectViewport extends JComponent { private static final long serialVersionUID = -3175226517409913524L; private static final double WHEEL_ZOOM_EXPONENT = 0.12D; private static final double KEY_ZOOM_FACTOR = 1.1D; private static final int KEY_ORBIT_PIXELS = 12; + private static final float LOADING_DIM_OPACITY = 0.88F; + private static final double LOADING_BLOCK_SIZE = 16D; + private static final int LOADING_TEXT_GAP = 46; private final ExecutorService renderThread = Executors.newSingleThreadExecutor(runnable -> { Thread thread = new Thread(runnable, "Iris Object Viewer"); @@ -62,6 +68,9 @@ final class ObjectViewport extends JComponent { private boolean renderAgain; private Point dragPoint; private int coveredLeftWidth; + private final Timer loadingTicker = new Timer(LoadingAnimation.FRAME_MILLIS, event -> repaint()); + private String loadingText; + private long loadingSinceMillis; ObjectViewport() { setOpaque(true); @@ -84,15 +93,37 @@ final class ObjectViewport extends JComponent { requestFrame(); } + // The last picture stays up until the first one of this mesh is drawn. void show(ObjectMesh nextMesh) { mesh = nextMesh; camera = ObjectCamera.START; message = nextMesh.isEmpty() ? "No visible blocks" : null; - frame = null; + if (nextMesh.isEmpty()) { + frame = null; + finishLoading(); + } requestFrame(); repaint(); } + /** + * Marks the view as loading something new. A load already under way keeps its start, so stepping quickly through + * objects still shows the animation once the wait adds up. + */ + void showLoading(String text) { + if (loadingText == null) { + loadingSinceMillis = System.currentTimeMillis(); + } + loadingText = text; + message = null; + loadingTicker.start(); + } + + private void finishLoading() { + loadingText = null; + loadingTicker.stop(); + } + /** * Swaps in another mesh of the same object, like one with its top layers cut away, keeping the camera. */ @@ -117,6 +148,7 @@ final class ObjectViewport extends JComponent { mesh = null; message = text; frame = null; + finishLoading(); repaint(); } @@ -136,6 +168,7 @@ final class ObjectViewport extends JComponent { } void close() { + loadingTicker.stop(); renderThread.shutdownNow(); } @@ -168,6 +201,7 @@ final class ObjectViewport extends JComponent { rendering = false; if (frameMesh == mesh) { frame = image; + finishLoading(); repaint(); } if (renderAgain) { @@ -183,11 +217,38 @@ final class ObjectViewport extends JComponent { if (frame != null) { graphics.drawImage(frame, 0, 0, null); } + if (loadingText != null && LoadingAnimation.isDue(loadingSinceMillis)) { + drawLoading((Graphics2D) graphics); + } if (message != null) { drawMessage((Graphics2D) graphics); } } + private void drawLoading(Graphics2D graphics) { + Graphics2D canvas = (Graphics2D) graphics.create(); + try { + if (frame != null) { + canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, LOADING_DIM_OPACITY)); + canvas.setColor(getBackground()); + canvas.fillRect(0, 0, getWidth(), getHeight()); + canvas.setComposite(AlphaComposite.SrcOver); + } + int centerX = coveredLeftWidth + (getWidth() - coveredLeftWidth) / 2; + int centerY = getHeight() / 2; + long shownMillis = System.currentTimeMillis() - loadingSinceMillis - LoadingAnimation.SHOW_AFTER_MILLIS; + LoadingAnimation.paint(canvas, centerX, centerY, StudioTheme.scale(LOADING_BLOCK_SIZE), shownMillis); + canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + canvas.setFont(StudioTheme.font()); + canvas.setColor(StudioTheme.MUTED_TEXT); + String text = loadingText + "..."; + int textWidth = canvas.getFontMetrics().stringWidth(text); + canvas.drawString(text, centerX - textWidth / 2, centerY + StudioTheme.scale(LOADING_TEXT_GAP)); + } finally { + canvas.dispose(); + } + } + private void drawMessage(Graphics2D canvas) { canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); canvas.setFont(StudioTheme.font());