feat(io): SwingIO 4-region layout, JTextPane styling, bundled font loader

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 20:21:23 +02:00
parent 5f9d52ca41
commit 963ddfb89e
2 changed files with 151 additions and 33 deletions

View File

@@ -1,75 +1,143 @@
package thb.jeanluc.adventure.io;
import thb.jeanluc.adventure.io.text.Hud;
import thb.jeanluc.adventure.io.text.RoomView;
import thb.jeanluc.adventure.io.text.Span;
import thb.jeanluc.adventure.io.text.Style;
import thb.jeanluc.adventure.io.text.StyledText;
import javax.swing.BorderFactory;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.JTextPane;
import javax.swing.SwingUtilities;
import javax.swing.text.BadLocationException;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyledDocument;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.GraphicsEnvironment;
import java.awt.Insets;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.LinkedBlockingQueue;
import thb.jeanluc.adventure.io.text.StyledText;
/**
* {@link GameIO} backed by a Swing window. A read-only output area shows
* game text; a bottom text field collects player input. The input from
* the EDT is handed to the (worker-thread) game loop through a
* {@link LinkedBlockingQueue}.
* {@link GameIO} backed by a Swing window with four regions: a HUD bar (top),
* a styled output pane (centre), a side panel for exits / future map (right),
* and an input field (bottom). A bundled TrueType font is loaded if present so
* colours and glyphs work without the player installing anything.
*/
public class SwingIO implements GameIO {
/** Bridge between the EDT and the game loop. One element = one line. */
private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
/** Main window. */
private final JFrame frame;
/** Output text area; appended to from any thread via {@link SwingUtilities#invokeLater}. */
private final JTextArea output;
/** Input text field; ENTER pushes the line into {@link #inputs}. */
private final JTextPane output;
private final StyledDocument doc;
private final JTextField input;
private final JLabel hud;
private final JTextArea side;
private final Font baseFont;
/**
* Creates and shows the window with the given title. Must be called on
* the EDT (typical pattern: from {@code SwingUtilities.invokeLater}).
*
* @param title window title
*/
public SwingIO(String title) {
frame = new JFrame(title);
output = new JTextArea();
input = new JTextField();
baseFont = loadFont();
output = new JTextPane();
output.setEditable(false);
output.setLineWrap(true);
output.setWrapStyleWord(true);
output.setBackground(Color.BLACK);
output.setForeground(Color.LIGHT_GRAY);
output.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14));
output.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
output.setBackground(new Color(0x0b, 0x0e, 0x13));
output.setForeground(new Color(0xcf, 0xd6, 0xe0));
output.setFont(baseFont);
output.setMargin(new Insets(8, 8, 8, 8));
doc = output.getStyledDocument();
input.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14));
hud = new JLabel(" ");
hud.setOpaque(true);
hud.setBackground(new Color(0x11, 0x15, 0x1c));
hud.setForeground(new Color(0x8b, 0x94, 0xa3));
hud.setFont(baseFont.deriveFont(12f));
hud.setBorder(BorderFactory.createEmptyBorder(6, 12, 6, 12));
side = new JTextArea();
side.setEditable(false);
side.setBackground(new Color(0x0e, 0x12, 0x18));
side.setForeground(new Color(0x6f, 0xcf, 0x73));
side.setFont(baseFont.deriveFont(12f));
side.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
JScrollPane sideScroll = new JScrollPane(side);
sideScroll.setPreferredSize(new Dimension(210, 0));
input = new JTextField();
input.setFont(baseFont);
input.addActionListener(e -> {
String line = input.getText();
input.setText("");
output.append("> " + line + "\n");
appendSpans(List.of(new Span("> " + line + "\n", Style.DIM)));
inputs.offer(line);
});
frame = new JFrame(title);
frame.setLayout(new BorderLayout());
frame.add(hud, BorderLayout.NORTH);
frame.add(new JScrollPane(output), BorderLayout.CENTER);
frame.add(sideScroll, BorderLayout.EAST);
frame.add(input, BorderLayout.SOUTH);
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setSize(800, 600);
frame.setSize(900, 600);
frame.setLocationRelativeTo(null);
frame.setVisible(true);
input.requestFocusInWindow();
}
private Font loadFont() {
try (InputStream in = getClass().getResourceAsStream("/fonts/game.ttf")) {
if (in != null) {
Font f = Font.createFont(Font.TRUETYPE_FONT, in).deriveFont(14f);
GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(f);
return f;
}
} catch (Exception e) {
// fall through to the logical monospaced fallback
}
return new Font(Font.MONOSPACED, Font.PLAIN, 14);
}
private Color colorFor(Style s) {
return switch (s) {
case HEADING -> new Color(0xc9, 0x8a, 0xe0);
case ITEM -> new Color(0x46, 0xc8, 0xd8);
case NPC -> new Color(0xe6, 0xc3, 0x4a);
case EXIT -> new Color(0x6f, 0xcf, 0x73);
case DANGER -> new Color(0xe0, 0x6c, 0x6c);
case DIM -> new Color(0x6b, 0x72, 0x80);
case PLAIN -> new Color(0xcf, 0xd6, 0xe0);
};
}
private void appendSpans(List<Span> spans) {
SwingUtilities.invokeLater(() -> {
try {
for (Span sp : spans) {
SimpleAttributeSet as = new SimpleAttributeSet();
StyleConstants.setForeground(as, colorFor(sp.style()));
if (sp.style() == Style.HEADING) {
StyleConstants.setBold(as, true);
}
doc.insertString(doc.getLength(), sp.text(), as);
}
output.setCaretPosition(doc.getLength());
} catch (BadLocationException ignored) {
// append-only; cannot occur with getLength()
}
});
}
@Override
public String readLine() {
try {
@@ -82,6 +150,44 @@ public class SwingIO implements GameIO {
@Override
public void print(StyledText text) {
SwingUtilities.invokeLater(() -> output.append(text.plainText() + "\n"));
List<Span> spans = new ArrayList<>(text.spans());
spans.add(new Span("\n", Style.PLAIN));
appendSpans(spans);
}
@Override
public void showRoom(RoomView room) {
List<Span> spans = new ArrayList<>();
spans.add(new Span(room.name() + "\n", Style.HEADING));
spans.add(new Span(room.description().stripTrailing() + "\n", Style.PLAIN));
if (!room.items().isEmpty()) {
spans.add(new Span("You see ", Style.PLAIN));
addList(spans, room.items(), Style.ITEM);
spans.add(new Span(".\n", Style.PLAIN));
}
if (!room.npcs().isEmpty()) {
addList(spans, room.npcs(), Style.NPC);
spans.add(new Span(" is here.\n", Style.PLAIN));
}
appendSpans(spans);
String exits = room.exits().isEmpty() ? "(none)" : String.join("\n", room.exits());
SwingUtilities.invokeLater(() -> side.setText("EXITS\n\n" + exits));
}
@Override
public void setHud(Hud h) {
String txt = " " + h.location() + " gold " + h.gold() + " turn " + h.turn()
+ " light " + (h.lightOn() ? "on" : "off");
SwingUtilities.invokeLater(() -> hud.setText(txt));
}
private void addList(List<Span> spans, List<String> xs, Style st) {
for (int i = 0; i < xs.size(); i++) {
if (i > 0) {
spans.add(new Span(", ", Style.PLAIN));
}
spans.add(new Span(xs.get(i), st));
}
}
}

View File

@@ -0,0 +1,12 @@
# Bundled GUI font
Drop a TrueType font here named `game.ttf` to use it in the Swing GUI
(`SwingIO`). It is loaded via `Font.createFont` and registered at runtime —
no installation on the player's machine required.
- A monospaced font is recommended (the UI assumes fixed width for frames).
- A Nerd Font variant additionally provides icon glyphs.
- Check the font's licence before committing the `.ttf`.
If no `game.ttf` is present, `SwingIO` falls back to the logical
`Font.MONOSPACED`, so the game still runs.