feat(io): themed full-window menu overlay in the GUI

Replace the plain Swing JDialog menu with a glass-pane overlay that fills
the whole window: dark background matching the game palette, bundled font,
lavender heading, flat hover-highlighted buttons, Escape = last option. The
overlay hides the game chrome (HUD/map/quest panels) while in the menu and
rebuilds at the current frameScale*zoom on window resize, so it scales
correctly from startup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 16:21:46 +02:00
parent 1d0cbc25e4
commit b41ac80194

View File

@@ -14,7 +14,6 @@ import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
@@ -22,6 +21,7 @@ import javax.swing.JScrollPane;
import javax.swing.JTextField;
import javax.swing.JTextPane;
import javax.swing.KeyStroke;
import javax.swing.SwingConstants;
import javax.swing.SwingUtilities;
import javax.swing.text.BadLocationException;
import javax.swing.text.SimpleAttributeSet;
@@ -29,10 +29,13 @@ import javax.swing.text.StyleConstants;
import javax.swing.text.StyledDocument;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Cursor;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.GraphicsConfiguration;
import java.awt.GraphicsEnvironment;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Toolkit;
import java.awt.event.ActionEvent;
@@ -138,6 +141,7 @@ public class SwingIO implements GameIO {
public void componentResized(ComponentEvent e) {
updateSideWidth();
applyFonts();
rescaleMenuOverlayIfVisible();
}
});
@@ -348,41 +352,12 @@ public class SwingIO implements GameIO {
@Override
public int choose(String title, List<String> options) {
// Same EDT↔worker handoff as readLine: the worker blocks on take(),
// the EDT (button click / window close) offers the chosen index.
// Full-window overlay (glass pane), not a floating dialog: it covers the game
// chrome so no panels/outlines show behind the menu, and it fills + scales with
// the window. EDT↔worker handoff as in readLine: the worker blocks on take(),
// a button click / Escape offers the chosen index.
LinkedBlockingQueue<Integer> picked = new LinkedBlockingQueue<>();
SwingUtilities.invokeLater(() -> {
JDialog dialog = new JDialog(frame, title, true);
JPanel panel = new JPanel();
panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
panel.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
panel.add(new JLabel(title));
panel.add(Box.createVerticalStrut(8));
for (int i = 0; i < options.size(); i++) {
int idx = i;
JButton b = new JButton(options.get(i));
b.setAlignmentX(0f);
b.addActionListener(e -> {
dialog.dispose();
picked.offer(idx);
});
panel.add(b);
panel.add(Box.createVerticalStrut(4));
}
// Closing the dialog counts as the last (safe/cancel) option.
dialog.setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE);
dialog.addWindowListener(new java.awt.event.WindowAdapter() {
@Override
public void windowClosing(java.awt.event.WindowEvent e) {
dialog.dispose();
picked.offer(options.size() - 1);
}
});
dialog.setContentPane(panel);
dialog.pack();
dialog.setLocationRelativeTo(frame);
dialog.setVisible(true);
});
SwingUtilities.invokeLater(() -> showMenuOverlay(title, options, picked));
try {
return picked.take();
} catch (InterruptedException e) {
@@ -391,6 +366,123 @@ public class SwingIO implements GameIO {
}
}
/** Current menu being shown, so a window resize can rebuild it at the new scale. */
private String menuTitle;
private List<String> menuOptions;
private LinkedBlockingQueue<Integer> menuPicked;
/** Rebuilds the visible menu overlay at the current window scale (called on resize). */
private void rescaleMenuOverlayIfVisible() {
if (menuOptions != null && frame.getGlassPane().isVisible()) {
showMenuOverlay(menuTitle, menuOptions, menuPicked);
}
}
/** Installs the menu as the frame's glass pane (covers everything) and shows it. */
private void showMenuOverlay(String title, List<String> options, LinkedBlockingQueue<Integer> picked) {
menuTitle = title;
menuOptions = options;
menuPicked = picked;
float scale = frameScale() * zoom; // same scaling the rest of the UI uses
JPanel glass = new JPanel(new GridBagLayout());
glass.setOpaque(true);
glass.setBackground(MENU_BG);
glass.addMouseListener(new java.awt.event.MouseAdapter() { }); // swallow backdrop clicks
JPanel card = new JPanel();
card.setOpaque(false);
card.setLayout(new BoxLayout(card, BoxLayout.Y_AXIS));
JLabel heading = new JLabel(title.toUpperCase());
heading.setForeground(MENU_TITLE);
heading.setFont(baseFont.deriveFont(Font.BOLD, BASE_SIZE * scale * 1.6f));
heading.setAlignmentX(Component.CENTER_ALIGNMENT);
heading.setBorder(BorderFactory.createEmptyBorder(0, 0, (int) (22 * scale), 0));
card.add(heading);
Runnable cancel = () -> {
hideMenuOverlay();
picked.offer(options.size() - 1);
};
for (int i = 0; i < options.size(); i++) {
int idx = i;
JButton b = menuButton(options.get(i), scale);
b.addActionListener(e -> {
hideMenuOverlay();
picked.offer(idx);
});
card.add(b);
if (i < options.size() - 1) {
card.add(Box.createVerticalStrut((int) (10 * scale)));
}
}
// Escape selects the last (safe/cancel) option — mirrors the console EOF convention.
glass.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW)
.put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "menuCancel");
glass.getActionMap().put("menuCancel", action(e -> cancel.run()));
glass.add(card); // single child → centered by GridBagLayout
frame.setGlassPane(glass);
glass.setVisible(true);
glass.requestFocusInWindow();
}
private void hideMenuOverlay() {
menuOptions = null; // stop resize rebuilds once the menu is dismissed
frame.getGlassPane().setVisible(false);
}
/** Palette for the themed menu overlay, matching the game window's dark look. */
private static final Color MENU_BG = new Color(0x0b, 0x0e, 0x13);
private static final Color MENU_TITLE = new Color(0xc9, 0x8a, 0xe0);
private static final Color BTN_BG = new Color(0x16, 0x1b, 0x24);
private static final Color BTN_BG_HOVER = new Color(0x22, 0x2a, 0x37);
private static final Color BTN_FG = new Color(0xcf, 0xd6, 0xe0);
private static final Color BTN_LINE = new Color(0x2a, 0x2f, 0x3a);
private static final Color BTN_LINE_HOVER = new Color(0x6f, 0xcf, 0x73);
/** A flat, dark, fixed-width menu button matching the game palette, with hover feedback. */
private JButton menuButton(String text, float scale) {
int padV = (int) (11 * scale);
int padH = (int) (18 * scale);
JButton b = new JButton(text);
b.setFont(baseFont.deriveFont(BASE_SIZE * scale));
b.setForeground(BTN_FG);
b.setBackground(BTN_BG);
b.setFocusPainted(false);
b.setOpaque(true);
b.setHorizontalAlignment(SwingConstants.LEFT);
b.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
b.setAlignmentX(Component.CENTER_ALIGNMENT);
b.setBorder(menuButtonBorder(BTN_LINE, padV, padH));
Dimension size = new Dimension((int) (340 * scale), b.getPreferredSize().height);
b.setPreferredSize(size);
b.setMinimumSize(size);
b.setMaximumSize(size);
b.addMouseListener(new java.awt.event.MouseAdapter() {
@Override
public void mouseEntered(java.awt.event.MouseEvent e) {
b.setBackground(BTN_BG_HOVER);
b.setBorder(menuButtonBorder(BTN_LINE_HOVER, padV, padH));
}
@Override
public void mouseExited(java.awt.event.MouseEvent e) {
b.setBackground(BTN_BG);
b.setBorder(menuButtonBorder(BTN_LINE, padV, padH));
}
});
return b;
}
private static javax.swing.border.Border menuButtonBorder(Color line, int padV, int padH) {
return BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(line, 1),
BorderFactory.createEmptyBorder(padV, padH, padV, padH));
}
private void addList(List<Span> spans, List<String> xs, Style st) {
for (int i = 0; i < xs.size(); i++) {
if (i > 0) {