diff --git a/src/main/java/net/rptools/maptool/client/AppStatePersisted.java b/src/main/java/net/rptools/maptool/client/AppStatePersisted.java index 60d06a7458..f647ff59bf 100644 --- a/src/main/java/net/rptools/maptool/client/AppStatePersisted.java +++ b/src/main/java/net/rptools/maptool/client/AppStatePersisted.java @@ -67,6 +67,9 @@ public class AppStatePersisted { /** Represents the key used to save the paint textures to the preferences. */ private static final String KEY_SAVED_PAINT_TEXTURES = "savedTextures"; + private static final String KEY_VBL_PEN_RADIUS = "vblPenRadius"; + private static final int DEFAULT_VBL_PEN_RADIUS = 3; + /** Will be null until read from preferences. */ private static EnumSet topologyTypes = null; @@ -245,4 +248,12 @@ public static List getSavedPaintTextures() { } return savedTextures; } + + public static int getVblPenRadius() { + return prefs.getInt(KEY_VBL_PEN_RADIUS, DEFAULT_VBL_PEN_RADIUS); + } + + public static void setVblPenRadius(int radius) { + prefs.putInt(KEY_VBL_PEN_RADIUS, radius); + } } diff --git a/src/main/java/net/rptools/maptool/client/tool/drawing/VBLPenTool.java b/src/main/java/net/rptools/maptool/client/tool/drawing/VBLPenTool.java new file mode 100644 index 0000000000..49cd118b12 --- /dev/null +++ b/src/main/java/net/rptools/maptool/client/tool/drawing/VBLPenTool.java @@ -0,0 +1,272 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code 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. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.client.tool.drawing; + +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.FlowLayout; +import java.awt.Graphics2D; +import java.awt.Shape; +import java.awt.event.KeyEvent; +import java.awt.event.MouseEvent; +import java.awt.event.MouseWheelEvent; +import java.awt.geom.Area; +import java.awt.geom.Ellipse2D; +import java.awt.geom.Path2D; +import javax.swing.BorderFactory; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSpinner; +import javax.swing.SpinnerNumberModel; +import javax.swing.SwingUtilities; +import javax.swing.border.BevelBorder; +import net.rptools.maptool.client.AppStatePersisted; +import net.rptools.maptool.client.AppStyle; +import net.rptools.maptool.client.MapTool; +import net.rptools.maptool.client.swing.TopologyModeSelectionPanel; +import net.rptools.maptool.client.ui.zone.renderer.ZoneRenderer; +import net.rptools.maptool.language.I18N; +import net.rptools.maptool.model.ZonePoint; + +/** + * A freehand drawing tool for Vision Blocking Layer (VBL) and other topology types. + * + *

Design: Applies changes incrementally during {@code mouseDragged} by submitting small + * {@link Area} segments (circles connected by thick lines) to the server. This provides real-time + * responsiveness without accumulating large paths. + * + *

Integration: Uses a custom {@link RadiusPanel} for thickness and integrates with {@link + * TopologyModeSelectionPanel} for layer selection. + * + *

Interactions: + *

    + *
  • LClick+Drag: Incremental draw/erase. + *
  • Shift: Toggle Eraser mode (real-time indicator update). + *
  • Ctrl+Wheel: Dynamic radius adjustment. + *
+ */ +public final class VBLPenTool extends AbstractDrawingLikeTool { + private final TopologyModeSelectionPanel topologyModeSelectionPanel; + private final TopologyTool.MaskOverlay maskOverlay; + private static final RadiusPanel RADIUS_PANEL = new RadiusPanel(); + + private ZonePoint lastPoint; + private boolean shiftDown; + + public VBLPenTool(TopologyModeSelectionPanel modePanel) { + super("tool.vblpen.instructions", "tool.vblpen.tooltip"); + topologyModeSelectionPanel = modePanel; + maskOverlay = new TopologyTool.MaskOverlay(); + } + + @Override + public boolean isAvailable() { + return MapTool.getPlayer().isGM(); + } + + @Override + protected void attachTo(ZoneRenderer renderer) { + topologyModeSelectionPanel.setEnabled(true); + MapTool.getFrame().showControlPanel(RADIUS_PANEL); + super.attachTo(renderer); + } + + @Override + protected void detachFrom(ZoneRenderer renderer) { + topologyModeSelectionPanel.setEnabled(false); + MapTool.getFrame().removeControlPanel(); + super.detachFrom(renderer); + } + + @Override + protected void resetTool() { + lastPoint = null; + shiftDown = false; + renderer.repaint(); + super.resetTool(); + } + + @Override + protected boolean isEraser() { + return shiftDown; + } + + @Override + protected boolean isSnapToGrid(MouseEvent e) { + return false; + } + + @Override + public void keyPressed(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_SHIFT) { + shiftDown = true; + renderer.repaint(); + } + super.keyPressed(e); + } + + @Override + public void keyReleased(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_SHIFT) { + shiftDown = false; + renderer.repaint(); + } + super.keyReleased(e); + } + + private void submit(Shape shape) { + if (shape == null) { + return; + } + Area area = new Area(shape); + for (var type : AppStatePersisted.getTopologyTypes()) { + MapTool.serverCommand().updateMaskTopology(getZone(), area, isEraser(), type); + } + } + + @Override + public void paintOverlay(ZoneRenderer renderer, Graphics2D g) { + maskOverlay.paintOverlay(renderer, g); + + var zoneScale = renderer.getViewModel().getZoneScale(); + Graphics2D g2 = (Graphics2D) g.create(); + g2.transform(zoneScale.toScreenTransform()); + + int thickness = AppStatePersisted.getVblPenRadius(); + double radius = thickness / 2.0; + + // Show a preview of the pen at the current mouse position. + var mousePoint = renderer.getMousePosition(); + if (mousePoint != null) { + ZonePoint zp = + getPoint(new MouseEvent(renderer, 0, 0, 0, mousePoint.x, mousePoint.y, 0, false)); + Ellipse2D circle = new Ellipse2D.Double(zp.x - radius, zp.y - radius, thickness, thickness); + Color color = isEraser() ? AppStyle.topologyRemoveColor : AppStyle.topologyAddColor; + g2.setColor(color); + g2.fill(circle); + g2.setStroke(new BasicStroke(1 / (float) zoneScale.getScale())); + g2.draw(circle); + } + + g2.dispose(); + } + + @Override + public void mousePressed(MouseEvent e) { + if (SwingUtilities.isLeftMouseButton(e)) { + shiftDown = e.isShiftDown(); + lastPoint = getPoint(e); + addPointToTopology(lastPoint); + renderer.repaint(); + } + super.mousePressed(e); + } + + @Override + public void mouseDragged(MouseEvent e) { + if (lastPoint != null && SwingUtilities.isLeftMouseButton(e)) { + cancelMapDrag(); + shiftDown = e.isShiftDown(); + ZonePoint point = getPoint(e); + if (!point.equals(lastPoint)) { + addPointToTopology(point); + lastPoint = point; + renderer.repaint(); + } + } else { + super.mouseDragged(e); + } + } + + @Override + public void mouseReleased(MouseEvent e) { + if (lastPoint != null && SwingUtilities.isLeftMouseButton(e)) { + shiftDown = e.isShiftDown(); + lastPoint = null; + renderer.repaint(); + } + super.mouseReleased(e); + } + + @Override + public void mouseMoved(MouseEvent e) { + super.mouseMoved(e); + shiftDown = e.isShiftDown(); + renderer.repaint(); // Repaint to update the pen preview. + } + + @Override + public void mouseWheelMoved(MouseWheelEvent e) { + if (net.rptools.maptool.client.swing.SwingUtil.isControlDown(e)) { + int thickness = AppStatePersisted.getVblPenRadius(); + // Increase/decrease by 1 or more depending on rotation. + // Scrolling down (positive) decreases, up (negative) increases. + thickness -= e.getWheelRotation(); + thickness = Math.max(1, Math.min(300, thickness)); + + AppStatePersisted.setVblPenRadius(thickness); + RADIUS_PANEL.updateSpinner(thickness); + renderer.repaint(); + return; + } + super.mouseWheelMoved(e); + } + + private void addPointToTopology(ZonePoint point) { + int thickness = AppStatePersisted.getVblPenRadius(); + double radius = thickness / 2.0; + + Path2D segmentPath = new Path2D.Double(); + Ellipse2D circle = + new Ellipse2D.Double(point.x - radius, point.y - radius, thickness, thickness); + segmentPath.append(circle, false); + + if (lastPoint != null) { + BasicStroke stroke = + new BasicStroke(thickness, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND); + Path2D line = new Path2D.Double(); + line.moveTo(lastPoint.x, lastPoint.y); + line.lineTo(point.x, point.y); + segmentPath.append(stroke.createStrokedShape(line), false); + } + submit(segmentPath); + } + + private static class RadiusPanel extends JPanel { + private final JSpinner radiusSpinner; + + public RadiusPanel() { + setLayout(new FlowLayout(FlowLayout.CENTER)); + setBorder(BorderFactory.createBevelBorder(BevelBorder.RAISED)); + + add(new JLabel(I18N.getText("label.radius") + ":")); + + radiusSpinner = + new JSpinner(new SpinnerNumberModel(AppStatePersisted.getVblPenRadius(), 1, 300, 1)); + radiusSpinner.addChangeListener( + e -> { + AppStatePersisted.setVblPenRadius((Integer) radiusSpinner.getValue()); + if (MapTool.getFrame().getCurrentZoneRenderer() != null) { + MapTool.getFrame().getCurrentZoneRenderer().repaint(); + } + }); + add(radiusSpinner); + } + + public void updateSpinner(int value) { + radiusSpinner.setValue(value); + } + } +} diff --git a/src/main/java/net/rptools/maptool/client/ui/ToolbarPanel.java b/src/main/java/net/rptools/maptool/client/ui/ToolbarPanel.java index 5b5294a0c7..53ab7bac73 100644 --- a/src/main/java/net/rptools/maptool/client/ui/ToolbarPanel.java +++ b/src/main/java/net/rptools/maptool/client/ui/ToolbarPanel.java @@ -481,6 +481,10 @@ private OptionPanel createTopologyPanel() { topologyModeSelectionPanel)) .setIcon(RessourceManager.getBigIcon(Icons.TOOLBAR_TOPOLOGY_DIAMOND_HOLLOW)); + panel + .addTool(new VBLPenTool(topologyModeSelectionPanel)) + .setIcon(RessourceManager.getBigIcon(Icons.TOOLBAR_DRAW_FREEHAND)); + // Add with separator to separate mode button group from shape button group. addSeparator(panel, 11); diff --git a/src/main/resources/net/rptools/maptool/language/i18n.properties b/src/main/resources/net/rptools/maptool/language/i18n.properties index eee02f0da2..3273c7c618 100644 --- a/src/main/resources/net/rptools/maptool/language/i18n.properties +++ b/src/main/resources/net/rptools/maptool/language/i18n.properties @@ -373,6 +373,7 @@ ColorPicker.tooltip.lineType = Line Type ColorPicker.tooltip.eraser = Eraser ColorPicker.tooltip.penWidth = Pen Width ColorPicker.tooltip.opacity = Opacity +label.radius = Radius ConnectToServerDialog.msg.headingServer = Server Name ConnectToServerDialog.msg.headingVersion = Version @@ -2960,6 +2961,8 @@ tool.ovalexpose.tooltip = Expose/Hide an oval on the Fog of War. tool.ovaltopology.instructions = LClick: set initial/final point, Shift+LClick: start erase oval tool.ovaltopology.tooltip = Draw an oval VBL. tool.ovaltopologyhollow.tooltip = Draw a hollow oval VBL. +tool.vblpen.instructions = LClick+Drag: draw VBL with configurable radius; Shift+LClick+Drag: erase; Ctrl: snap-to-grid +tool.vblpen.tooltip = Draw VBL with a pen (configurable radius) tool.pointer.instructions = LClick: select, LDrag: move selected, RClick: menu, RDrag: move map, MWheel: zoom, MClick and Spacebar: Toggle waypoint, Shift+MouseOver: no statsheet tool.pointer.tooltip = Pointer tool tool.poly.instructions = LClick: lay initial/final point, RClick (while drawing): set intermediate point, RDrag: move map, Shift+LClick (initial): Erase poly area