diff --git a/core/src/main/java/com/threerings/media/VirtualMediaPanel.java b/core/src/main/java/com/threerings/media/VirtualMediaPanel.java index 0b88ad6e..42a5361c 100644 --- a/core/src/main/java/com/threerings/media/VirtualMediaPanel.java +++ b/core/src/main/java/com/threerings/media/VirtualMediaPanel.java @@ -19,27 +19,25 @@ package com.threerings.media; -import java.util.ArrayList; - import java.awt.Graphics; import java.awt.Graphics2D; +import java.awt.Point; import java.awt.Rectangle; import java.awt.event.MouseEvent; import java.awt.event.MouseWheelEvent; +import java.awt.geom.AffineTransform; +import java.util.ArrayList; import javax.swing.SwingUtilities; import com.google.common.collect.Lists; - import com.samskivert.util.RunAnywhere; - +import static com.threerings.media.Log.log; import com.threerings.media.image.ImageUtil; import com.threerings.media.image.Mirage; import com.threerings.media.util.MathUtil; import com.threerings.media.util.Pathable; -import static com.threerings.media.Log.log; - /** * Extends the base media panel with the notion of a virtual coordinate system. All entities in * the virtual media panel have virtual coordinates and the virtual media panel displays a window @@ -67,6 +65,11 @@ public class VirtualMediaPanel extends MediaPanel public VirtualMediaPanel (FrameManager framemgr) { super(framemgr); + + addZoomListener((oldZoom, newZoom) -> { + _vbounds = _zoomManager.rescaleBounds(_vbounds, getWidth(), getHeight()); + _metamgr.getRegionManager().addDirtyRegion(_vbounds); + }); } /** @@ -149,7 +152,8 @@ public class VirtualMediaPanel extends MediaPanel @Override protected void processMouseEvent (MouseEvent event) { - event.translatePoint(_vbounds.x, _vbounds.y); + event.translatePoint(_nx, _ny); + _zoomManager.adjustMouseEvent(event, _vbounds); super.processMouseEvent(event); } @@ -160,7 +164,8 @@ public class VirtualMediaPanel extends MediaPanel @Override protected void processMouseMotionEvent (MouseEvent event) { - event.translatePoint(_vbounds.x, _vbounds.y); + event.translatePoint(_nx, _ny); + _zoomManager.adjustMouseEvent(event, _vbounds); super.processMouseMotionEvent(event); } @@ -171,7 +176,8 @@ public class VirtualMediaPanel extends MediaPanel @Override protected void processMouseWheelEvent (MouseWheelEvent event) { - event.translatePoint(_vbounds.x, _vbounds.y); + event.translatePoint(_nx, _ny); + _zoomManager.adjustMouseEvent(event, _vbounds); super.processMouseWheelEvent(event); } @@ -179,7 +185,8 @@ public class VirtualMediaPanel extends MediaPanel protected void dirtyScreenRect (Rectangle rect) { // translate the screen rect into happy coordinates - rect.translate(_vbounds.x, _vbounds.y); + rect.translate(_nx, _ny); + rect = _zoomManager.scaleOnCenter(rect); _metamgr.getRegionManager().addDirtyRegion(rect); } @@ -201,6 +208,7 @@ public class VirtualMediaPanel extends MediaPanel // keep track of the size of the viewport _vbounds.width = getWidth(); _vbounds.height = getHeight(); + _vbounds = _zoomManager.scaleOnCenter(_vbounds); // we need to obtain our absolute screen coordinates to work // around the Windows copyArea() bug @@ -212,7 +220,7 @@ public class VirtualMediaPanel extends MediaPanel { // Adjust for any scrolling we're currently doing. super.addObscurerDirtyRegion( - new Rectangle(region.x - _dx, region.y - _dy, region.width, region.height)); + new Rectangle(region.x - (int) _dx, region.y - (int) _dy, region.width, region.height)); } /** @@ -235,47 +243,60 @@ public class VirtualMediaPanel extends MediaPanel protected void adjustBoundsCenter () { - int width = getWidth(), height = getHeight(); + int width = _vbounds.width, height = _vbounds.height; // adjusts our view location to track any pathable we might be tracking trackPathable(); + // our offset is from the center of the view panel, so we need to adjust + // coordinates to account for zoom. + Point viewCenter = ZoomManager.center(_vbounds); + Point panelCenter = new Point(getWidth() / 2, getHeight() / 2); + int offsetX = viewCenter.x - panelCenter.x; + int offsetY = viewCenter.y - panelCenter.y; + // if we have a new target location, we'll need to generate dirty // regions for the area exposed by the scrolling - if (_nx != _vbounds.x || _ny != _vbounds.y) { + if (_nx != offsetX || _ny != offsetY) { // determine how far we'll be moving on this tick - int dx = _nx - _vbounds.x, dy = _ny - _vbounds.y; + int dx = _nx - offsetX, dy = _ny - offsetY; // log.info("Scrolling into place [n=(" + _nx + ", " + _ny + // "), t=(" + _vbounds.x + ", " + _vbounds.y + // "), d=(" + dx + ", " + dy + // "), width=" + width + ", height=" + height + "]."); - _dx = dx; - _dy = dy; - - // these are used to prevent the vertical strip from - // overlapping the horizontal strip - int sy = _ny, shei = height; - - // and add invalid rectangles for the exposed areas - if (dy > 0) { - shei = Math.max(shei - dy, 0); - _metamgr.getRegionManager().invalidateRegion(_nx, _ny + height - dy, width, dy); - } else if (dy < 0) { - sy -= dy; - _metamgr.getRegionManager().invalidateRegion(_nx, _ny, width, -dy); - } - if (dx > 0) { - _metamgr.getRegionManager().invalidateRegion(_nx + width - dx, sy, dx, shei); - } else if (dx < 0) { - _metamgr.getRegionManager().invalidateRegion(_nx, sy, -dx, shei); - } - + _dx += dx * getZoomLevel(); + _dy += dy * getZoomLevel(); // now go ahead and update our location so that changes in between here and the call // to paint() for this tick don't booch everything - _vbounds.x = _nx; _vbounds.y = _ny; + _vbounds.x += dx; + _vbounds.y += dy; + + // these are used to prevent the vertical strip from + // overlapping the horizontal strip + int sy = _vbounds.y, shei = height; + + // and add invalid rectangles for the exposed areas + if (dy > 0) { + // zoom rounding makes it so copyArea can't always get the full region + // so we extend each invalidation region by 1 pixel to make sure we don't leave artifacts + int dy2 = dy + 1; + shei = Math.max(shei - dy2, 0); + _metamgr.getRegionManager().invalidateRegion(_vbounds.x, _vbounds.y + height - dy2, width, dy2); + } else if (dy < 0) { + int dy2 = dy - 1; + sy -= dy2; + _metamgr.getRegionManager().invalidateRegion(_vbounds.x, _vbounds.y, width, -dy2); + } + if (dx > 0) { + int dx2 = dx + 1; + _metamgr.getRegionManager().invalidateRegion(_vbounds.x + width - dx2, sy, dx2, shei); + } else if (dx < 0) { + int dx2 = dx - 1; + _metamgr.getRegionManager().invalidateRegion(_vbounds.x, sy, -dx2, shei); + } addObscurerDirtyRegions(false); @@ -313,8 +334,8 @@ public class VirtualMediaPanel extends MediaPanel return; } - int width = getWidth(), height = getHeight(); - int nx = _vbounds.x, ny = _vbounds.y; + int width = _vbounds.width, height = _vbounds.height; + int nx = _nx, ny = _ny; // figure out where to move switch (_fmode) { @@ -356,11 +377,12 @@ public class VirtualMediaPanel extends MediaPanel @Override protected void paint (Graphics2D gfx, Rectangle[] dirty) { - // if we're scrolling, go ahead and do the business - if (_dx != 0 || _dy != 0) { + int dx = (int) Math.round(_dx); + int dy = (int) Math.round(_dy); + if (dx != 0 || dy != 0) { int width = getWidth(), height = getHeight(); - int cx = (_dx > 0) ? _dx : 0; - int cy = (_dy > 0) ? _dy : 0; + int cx = (dx > 0) ? dx : 0; + int cy = (dy > 0) ? dy : 0; // set the clip to the bounds of the component (we can't assume the clip is anything // sensible upon entry to paint() because the frame manager expects us to set our own @@ -373,14 +395,14 @@ public class VirtualMediaPanel extends MediaPanel if (RunAnywhere.isWindows()) { gfx.translate(-_abounds.x, -_abounds.y); gfx.copyArea(_abounds.x + cx, _abounds.y + cy, - width - Math.abs(_dx), - height - Math.abs(_dy), -_dx, -_dy); + width - Math.abs(dx), + height - Math.abs(dy), -dx, -dy); gfx.translate(_abounds.x, _abounds.y); } else if (RunAnywhere.isMacOS()) { try { gfx.copyArea(cx, cy, - width - Math.abs(_dx), - height - Math.abs(_dy), -_dx, -_dy); + width - Math.abs(dx), + height - Math.abs(dy), -dx, -dy); } catch (Exception e) { // HACK when it throws an exception trying to do the copy area, just repaint // everything @@ -388,29 +410,41 @@ public class VirtualMediaPanel extends MediaPanel } } else { gfx.copyArea(cx, cy, - width - Math.abs(_dx), - height - Math.abs(_dy), -_dx, -_dy); + width - Math.abs(dx), + height - Math.abs(dy), -dx, -dy); } // and clear out our scroll deltas - _dx = 0; _dy = 0; + _dx -= dx; + _dy -= dy; } + AffineTransform originalTransform = gfx.getTransform(); + + int centerX = _vbounds.x + _vbounds.width / 2; + int centerY = _vbounds.y + _vbounds.height / 2; + + // if we zoom too quickly, the zoomManager scale may not be in sync with the paint tick + // so we check based on the actual view bounds to be safe + double scale = getWidth() / (double) _vbounds.width; + // translate into happy space - gfx.translate(-_vbounds.x, -_vbounds.y); + gfx.translate(getWidth() / 2, getHeight() / 2); + gfx.scale(scale, scale); + gfx.translate(-centerX, -centerY); // now do the actual painting super.paint(gfx, dirty); // translate back out of happy space - gfx.translate(_vbounds.x, _vbounds.y); + gfx.setTransform(originalTransform); } @Override protected void constrainToBounds (Rectangle dirty) { SwingUtilities.computeIntersection( - _vbounds.x, _vbounds.y, getWidth(), getHeight(), dirty); + _vbounds.x, _vbounds.y, _vbounds.width, _vbounds.height, dirty); } @Override @@ -429,14 +463,48 @@ public class VirtualMediaPanel extends MediaPanel } } + protected void enableZoom() { + addMouseWheelListener(_zoomManager.createMouseWheelListener()); + } + + protected void enableZoom(double min, double max) { + enableZoom(); + setZoomConstraints(min, max); + } + + protected void setZoomConstraints(double min, double max) { + _zoomManager.setMinZoomLevel(min); + _zoomManager.setMaxZoomLevel(max); + } + + protected void setZoomLevel(double zoom) { + _zoomManager.setZoomLevel(zoom); + } + + protected double getZoomLevel() { + return _zoomManager.getZoomLevel(); + } + + protected void addZoomListener(ZoomManager.ZoomListener listener) { + _zoomManager.addZoomListener(listener); + } + + protected void removeZoomListener(ZoomManager.ZoomListener listener) { + _zoomManager.removeZoomListener(listener); + } + + protected ZoomManager _zoomManager = new ZoomManager(); + /** Our viewport bounds in virtual coordinates. */ protected Rectangle _vbounds = new Rectangle(); /** Our target offsets to be effected on the next tick. */ protected int _nx, _ny; - /** Our scroll offsets. */ - protected int _dx, _dy; + /** + * Our scroll offsets in real pixels. + */ + protected double _dx, _dy; /** Our tiling background image. */ protected Mirage _background; diff --git a/core/src/main/java/com/threerings/media/ZoomManager.java b/core/src/main/java/com/threerings/media/ZoomManager.java new file mode 100644 index 00000000..db3ba9e7 --- /dev/null +++ b/core/src/main/java/com/threerings/media/ZoomManager.java @@ -0,0 +1,217 @@ +package com.threerings.media; + +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.event.MouseEvent; +import java.awt.event.MouseWheelListener; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +public class ZoomManager { + + protected static final double ZOOM_SNAP_THRESHOLD = 0.05; + protected static final double[] DEFAULT_ZOOM_STEPS = { 0.25, 0.3333, 0.5, 1.0, 2.0 }; + + protected double _zoomLevel = 1.0; + protected double _minZoomLevel = 0.25; + protected double _maxZoomLevel = 2.0; + protected double _zoomSteps[] = DEFAULT_ZOOM_STEPS; + + public interface ZoomListener { + + void zoomChanged(double oldZoom, double newZoom); + } + + private final List _listeners = new CopyOnWriteArrayList<>(); + + public ZoomManager() { + } + + public ZoomManager(double zoomLevel) { + _zoomLevel = zoomLevel; + } + + public ZoomManager(double zoomLevel, double minZoomLevel, double maxZoomLevel) { + _zoomLevel = zoomLevel; + _minZoomLevel = minZoomLevel; + _maxZoomLevel = maxZoomLevel; + } + + /** + * Returns the center point of the given rectangle. + * + * @param rect the rectangle to find the center of + * @return the center point of the rectangle + */ + public static Point center(Rectangle rect) { + int centerX = rect.x + rect.width / 2; + int centerY = rect.y + rect.height / 2; + return new Point(centerX, centerY); + } + + /** + * Rescales the given bounds rectangle based on the current zoom level. + * Useful when the zoom level changes to update the viewport. + * + * @param viewport the rectangle to rescale + * @param width the new width + * @param height the new height + * @return a new rectangle representing the rescaled bounds + */ + public Rectangle rescaleBounds(Rectangle viewPort, int width, int height) { + Point center = center(viewPort); + int newWidth = (int) (width / _zoomLevel); + int newHeight = (int) (height / _zoomLevel); + return new Rectangle(center.x - newWidth / 2, center.y - newHeight / 2, newWidth, newHeight); + } + + /** + * Scales the given viewport rectangle based on the current zoom level, + * centering the scaling on the viewport's center. + * + * @param viewPort the viewport rectangle to scale + * @return a new rectangle representing the scaled viewport + */ + public Rectangle scaleOnCenter(Rectangle viewPort) { + Point center = center(viewPort); + int newWidth = (int) (viewPort.width / _zoomLevel); + int newHeight = (int) (viewPort.height / _zoomLevel); + return new Rectangle(center.x - newWidth / 2, center.y - newHeight / 2, newWidth, newHeight); + } + + /** + * Adjusts the given mouse event for the current zoom level. Uses scaled + * distance from the center of the panel + * + * @param event the mouse event to adjust + */ + public void adjustMouseEvent(MouseEvent event, Rectangle viewPort) { + Point adjustedPoint = screenToVirtual(viewPort, event.getPoint()); + event.translatePoint( + adjustedPoint.x - event.getX(), + adjustedPoint.y - event.getY()); + } + + /** + * Adjusts the given point for the current zoom level. Zoom happens from the + * center of the viewport. + * + * @param viewPort the viewport rectangle + * @param toAdjust the point to adjust + * @return a new point representing the adjusted coordinates + */ + public Point screenToVirtual(Rectangle viewPort, Point screenPoint) { + Point center = center(viewPort); + double scale = _zoomLevel; + int virtX = (int) ((screenPoint.x - center.x) / scale + center.x); + int virtY = (int) ((screenPoint.y - center.y) / scale + center.y); + return new Point(virtX, virtY); + } + + /** + * Sets the zoom steps to use when zooming in and out. + * + * @param zoomSteps the zoom steps to set + */ + public void setZoomSteps(double[] zoomSteps) { + _zoomSteps = zoomSteps; + } + + /** + * Creates a mouse wheel listener that adjusts the zoom level. + * + * @return a mouse wheel listener + */ + public MouseWheelListener createMouseWheelListener() { + return e -> { + int closestStepIndex = 0; + double smallestDiff = Math.abs(_zoomLevel - _zoomSteps[0]); + for (int i = 0; i < _zoomSteps.length; i++) { + double diff = Math.abs(_zoomLevel - _zoomSteps[i]); + if (diff < smallestDiff) { + smallestDiff = diff; + closestStepIndex = i; + } + } + + int notches = e.getWheelRotation(); + int newIndex = closestStepIndex + (notches > 0 ? -1 : 1); + if (newIndex < 0) { + newIndex = 0; + } else if (newIndex >= _zoomSteps.length) { + newIndex = _zoomSteps.length - 1; + } + setZoomLevel(_zoomSteps[newIndex]); + }; + } + + public double getZoomLevel() { + return _zoomLevel; + } + + /** + * Scales the current zoom level by the given factor. + * + * @param scale the scale factor + * @return the actual scale factor applied after clamping to min and max + */ + public double scaleZoomLevel(double scale) { + double oldZoom = _zoomLevel; + setZoomLevel(_zoomLevel * scale); + return _zoomLevel / oldZoom; + } + + /** + * Sets the current zoom level, clamping it to the min and max zoom levels. + * + * @param zoomLevel the new zoom level + */ + public void setZoomLevel(double zoomLevel) { + double oldZoom = _zoomLevel; + if (zoomLevel < _minZoomLevel) { + _zoomLevel = _minZoomLevel; + } else if (zoomLevel > _maxZoomLevel) { + _zoomLevel = _maxZoomLevel; + } else { + _zoomLevel = zoomLevel; + // if we're close to 1.0, we might as well snap to it + if (Math.abs(_zoomLevel - 1.0) < ZOOM_SNAP_THRESHOLD) { + _zoomLevel = 1.0; + } + } + + if (oldZoom != _zoomLevel) { + fireZoomChanged(oldZoom, _zoomLevel); + } + } + + public double getMaxZoomLevel() { + return _maxZoomLevel; + } + + public void setMaxZoomLevel(double maxZoomLevel) { + _maxZoomLevel = maxZoomLevel; + } + + public double getMinZoomLevel() { + return _minZoomLevel; + } + + public void setMinZoomLevel(double minZoomLevel) { + _minZoomLevel = minZoomLevel; + } + + public void addZoomListener(ZoomListener listener) { + _listeners.add(listener); + } + + public void removeZoomListener(ZoomListener listener) { + _listeners.remove(listener); + } + + private void fireZoomChanged(double oldZoom, double newZoom) { + for (ZoomListener listener : _listeners) { + listener.zoomChanged(oldZoom, newZoom); + } + } +} diff --git a/core/src/main/java/com/threerings/miso/client/MisoScenePanel.java b/core/src/main/java/com/threerings/miso/client/MisoScenePanel.java index 8abd9a2e..05ecb2f6 100644 --- a/core/src/main/java/com/threerings/miso/client/MisoScenePanel.java +++ b/core/src/main/java/com/threerings/miso/client/MisoScenePanel.java @@ -118,6 +118,10 @@ public class MisoScenePanel extends VirtualMediaPanel _resolver.start(); _resolvers.put(_ctx, _resolver); } + + addZoomListener((oldZoom, newZoom) -> { + rethink(); + }); } /** @@ -716,7 +720,7 @@ public class MisoScenePanel extends VirtualMediaPanel // compute the tile coordinates of our upper left screen coordinate and request a rethink // if they've changed - MisoUtil.screenToTile(_metrics, _vbounds.x, _vbounds.y, _tcoords); + MisoUtil.screenToTile(_metrics, _nx, _ny, _tcoords); if (_ulpos == null || !_tcoords.equals(_ulpos)) { // if this is a forced rethink (_ulpos is null), we might delay paint as a result of // it, but only if we queue up blocks for resolution in our rethink