From eef269c853fcde29b5f56d414f666102c4bea0ac Mon Sep 17 00:00:00 2001 From: jzitnik-dev Date: Mon, 19 Jan 2026 17:20:49 +0100 Subject: [PATCH] feat: Implemented text rendering --- .../jzitnik/events/handlers/CliHandler.java | 37 +++- .../events/handlers/DialogEventHandler.java | 160 ++++++++++++++ .../handlers/MouseActionEventHandler.java | 5 +- .../handlers/QuestionAnswerEventHandler.java | 8 +- .../handlers/TerminalResizeEventHandler.java | 4 + src/main/java/cz/jzitnik/game/GameState.java | 4 - .../java/cz/jzitnik/game/dialog/OnEnd.java | 16 +- .../java/cz/jzitnik/game/mobs/DialogMob.java | 20 +- .../cz/jzitnik/game/mobs/HittableMob.java | 4 +- .../java/cz/jzitnik/game/objects/Chest.java | 3 +- .../cz/jzitnik/game/objects/DroppedItem.java | 17 +- .../cz/jzitnik/game/objects/Interactable.java | 4 +- .../cz/jzitnik/game/setup/enemies/Pepa.java | 16 +- .../cz/jzitnik/game/setup/rooms/MainRoom.java | 7 +- .../java/cz/jzitnik/states/DialogState.java | 11 + .../java/cz/jzitnik/states/ScreenBuffer.java | 2 + src/main/java/cz/jzitnik/ui/DialogUI.java | 76 +++++++ .../java/cz/jzitnik/ui/pixels/AlphaPixel.java | 13 ++ .../cz/jzitnik/ui/pixels/ColoredPixel.java | 8 +- src/main/java/cz/jzitnik/ui/pixels/Empty.java | 4 +- src/main/java/cz/jzitnik/ui/pixels/Pixel.java | 2 +- .../java/cz/jzitnik/utils/TextRenderer.java | 201 ++++++++++++++++++ src/main/resources/fonts/default.ttf | Bin 0 -> 17568 bytes 23 files changed, 563 insertions(+), 59 deletions(-) create mode 100644 src/main/java/cz/jzitnik/events/handlers/DialogEventHandler.java create mode 100644 src/main/java/cz/jzitnik/states/DialogState.java create mode 100644 src/main/java/cz/jzitnik/ui/DialogUI.java create mode 100644 src/main/java/cz/jzitnik/ui/pixels/AlphaPixel.java create mode 100644 src/main/java/cz/jzitnik/utils/TextRenderer.java create mode 100644 src/main/resources/fonts/default.ttf diff --git a/src/main/java/cz/jzitnik/events/handlers/CliHandler.java b/src/main/java/cz/jzitnik/events/handlers/CliHandler.java index 6af2415..92c873e 100644 --- a/src/main/java/cz/jzitnik/events/handlers/CliHandler.java +++ b/src/main/java/cz/jzitnik/events/handlers/CliHandler.java @@ -9,6 +9,8 @@ import cz.jzitnik.game.Constants; import cz.jzitnik.states.RenderState; import cz.jzitnik.states.ScreenBuffer; import cz.jzitnik.states.TerminalState; +import cz.jzitnik.ui.pixels.AlphaPixel; +import cz.jzitnik.ui.pixels.ColoredPixel; import cz.jzitnik.ui.pixels.Empty; import cz.jzitnik.ui.pixels.Pixel; import cz.jzitnik.utils.DependencyManager; @@ -41,6 +43,7 @@ public class CliHandler extends AbstractEventHandler { var parts = event.parts(); var buffer = screenBuffer.getRenderedBuffer(); + var globalOverrideBuffer = screenBuffer.getGlobalOverrideBuffer(); var terminalScreen = terminalState.getTerminalScreen(); var tg = terminalState.getTextGraphics(); @@ -53,9 +56,9 @@ public class CliHandler extends AbstractEventHandler { for (int y = startYNormalized; y <= endYNormalized; y += 2) { for (int x = start.getColumn(); x <= end.getColumn(); x++) { try { - Pixel topPixel = buffer[y][x]; + Pixel topPixel = getPixel(buffer[y][x], globalOverrideBuffer[y][x]); Pixel bottomPixel = (y + 1 <= end.getRow()) - ? buffer[y + 1][x] + ? getPixel(buffer[y + 1][x], globalOverrideBuffer[y + 1][x]) : new Empty(); TextColor topColor = topPixel instanceof Empty @@ -81,6 +84,36 @@ public class CliHandler extends AbstractEventHandler { } } + private Pixel getPixel(Pixel buffer, AlphaPixel globalOverride) { + if (globalOverride instanceof Empty) { + return buffer; + } + + if (buffer instanceof Empty) { + return getPixel(new ColoredPixel(Constants.BACKGROUND_COLOR), globalOverride); + } + + TextColor blended = blendColors( + buffer.getColor(), + globalOverride.getColor(), + globalOverride.getAlpha() + ); + + return new ColoredPixel(blended); + } + + private TextColor blendColors(TextColor base, TextColor overlay, float alpha) { + int r = blend(base.getRed(), overlay.getRed(), alpha); + int g = blend(base.getGreen(), overlay.getGreen(), alpha); + int b = blend(base.getBlue(), overlay.getBlue(), alpha); + + return new TextColor.RGB(r, g, b); + } + + private int blend(int base, int overlay, float alpha) { + return Math.round(base * (1 - alpha) + overlay * alpha); + } + private void drawHalfPixel(TextGraphics tg, int x, int y, TextColor topColor, TextColor bottomColor) { diff --git a/src/main/java/cz/jzitnik/events/handlers/DialogEventHandler.java b/src/main/java/cz/jzitnik/events/handlers/DialogEventHandler.java new file mode 100644 index 0000000..bb63a87 --- /dev/null +++ b/src/main/java/cz/jzitnik/events/handlers/DialogEventHandler.java @@ -0,0 +1,160 @@ +package cz.jzitnik.events.handlers; + +import com.googlecode.lanterna.TerminalPosition; +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.TextColor; +import cz.jzitnik.annotations.EventHandler; +import cz.jzitnik.annotations.injectors.InjectDependency; +import cz.jzitnik.annotations.injectors.InjectState; +import cz.jzitnik.events.RerenderScreen; +import cz.jzitnik.game.GameState; +import cz.jzitnik.game.dialog.Dialog; +import cz.jzitnik.game.dialog.OnEnd; +import cz.jzitnik.states.DialogState; +import cz.jzitnik.states.ScreenBuffer; +import cz.jzitnik.states.TerminalState; +import cz.jzitnik.ui.pixels.AlphaPixel; +import cz.jzitnik.ui.pixels.ColoredPixel; +import cz.jzitnik.ui.pixels.Empty; +import cz.jzitnik.ui.pixels.Pixel; +import cz.jzitnik.utils.DependencyManager; +import cz.jzitnik.utils.TextRenderer; +import cz.jzitnik.utils.events.AbstractEventHandler; +import cz.jzitnik.utils.events.EventManager; +import lombok.extern.slf4j.Slf4j; + +import java.awt.*; +import java.util.List; +import java.util.ArrayList; + +@Slf4j +@EventHandler(Dialog.class) +public class DialogEventHandler extends AbstractEventHandler { + public DialogEventHandler(DependencyManager dm) { + super(dm); + } + + @InjectState + private DialogState dialogState; + + @InjectState + private TerminalState terminalState; + + @InjectState + private ScreenBuffer screenBuffer; + + @InjectDependency + private EventManager eventManager; + + @InjectDependency + private TextRenderer textRenderer; + + private static final int MARGIN_BOTTOM = 15; + private static final int PADDING = 7; + private static final int BUTTON_TEXT_PADDING = 4; + private static final int QUESTION_ACTIONS_GAP = 10; + private static final int BUTTON_HEIGHT = 15; + private static final int BUTTON_PADDING = 5; + private static final float FONT_SIZE = 15f; + + public static TerminalSize getSize(TextRenderer textRenderer, Dialog dialog) { + int WIDTH = 355; + var textSize = textRenderer.measureText(dialog.getText(), WIDTH - PADDING * 2, FONT_SIZE); + + return new TerminalSize(355, PADDING + textSize.height + ( + dialog.getOnEnd() instanceof OnEnd.AskQuestion( + OnEnd.AskQuestion.Answer[] answers + ) ? answers.length * BUTTON_HEIGHT + (answers.length - 1) * BUTTON_PADDING : 0 + ) + PADDING); + } + + public static TerminalPosition getStart(TerminalSize terminalSize, TerminalSize size) { + int startY = terminalSize.getRows() * 2 - MARGIN_BOTTOM - size.getRows(); + int startX = (terminalSize.getColumns() / 2) - (size.getColumns() / 2); + + return new TerminalPosition(startX, startY); + } + + @Override + public void handle(Dialog event) { + dialogState.setCurrentDialog(event); + TerminalSize terminalSize = terminalState.getTerminalScreen().getTerminalSize(); + var overrideBuffer = screenBuffer.getGlobalOverrideBuffer(); + var size = getSize(textRenderer, event); + + var start = getStart(terminalSize, size); + + var animation = textRenderer.renderTypingAnimation(event.getText(), size.getColumns() - PADDING * 2, size.getRows() - PADDING * 2, Color.WHITE, FONT_SIZE); + var textSize = textRenderer.measureText(event.getText(), size.getColumns() - PADDING * 2, FONT_SIZE); + OnEnd onEnd = event.getOnEnd(); + + List answersBuf = new ArrayList<>(); + + if (onEnd instanceof OnEnd.AskQuestion( + OnEnd.AskQuestion.Answer[] answers + )) { + for (OnEnd.AskQuestion.Answer answer : answers) { + answersBuf.add(textRenderer.renderText(answer.answer(), size.getColumns() - PADDING * 2, BUTTON_HEIGHT, Color.BLACK, FONT_SIZE)); + } + } + + try { + for (AlphaPixel[][] buf : animation) { + for (int y = 0; y < size.getRows(); y++) { + for (int x = 0; x < size.getColumns(); x++) { + var textPixel = buf[Math.min(Math.max(0, y - PADDING), buf.length - 1)][Math.min(Math.max(0, x - PADDING), buf[0].length - 1)]; + if (textPixel instanceof Empty || y < PADDING || x < PADDING || x >= size.getColumns() - PADDING || y >= size.getRows() - PADDING) { + if (y - 2 > textSize.height + QUESTION_ACTIONS_GAP && onEnd instanceof OnEnd.AskQuestion( + OnEnd.AskQuestion.Answer[] answers + )) { + int buttonsY = y - textSize.height - QUESTION_ACTIONS_GAP - 2; + int buttonIndex = buttonsY / (BUTTON_HEIGHT + BUTTON_PADDING); + int rest = buttonsY % (BUTTON_HEIGHT + BUTTON_PADDING); + + if (buttonIndex < answers.length && rest < BUTTON_HEIGHT && x >= PADDING && x < size.getColumns() - PADDING) { + int localY = rest - BUTTON_TEXT_PADDING; + int localX = x - PADDING - BUTTON_TEXT_PADDING; + var buttonBuf = answersBuf.get(buttonIndex); + var buttonTextPixel = buttonBuf[Math.min(Math.max(0, localY), buttonBuf.length - 1)][Math.min(Math.max(0, localX), buttonBuf[0].length - 1)]; + + if (buttonTextPixel instanceof Empty || localY < 0 || localX < 0 || localY >= buttonBuf.length || localX >= buttonBuf[0].length) { + overrideBuffer[start.getRow() + y][start.getColumn() + x] = new ColoredPixel(new TextColor.RGB(255, 255, 255), 1f); + } else { + overrideBuffer[start.getRow() + y][start.getColumn() + x] = buttonTextPixel; + } + + continue; + } + } + + overrideBuffer[start.getRow() + y][start.getColumn() + x] = new ColoredPixel(new TextColor.RGB(0, 0, 0), 0.6f); + continue; + } + + overrideBuffer[start.getRow() + y][start.getColumn() + x] = textPixel; + } + } + + eventManager.emitEvent( + new RerenderScreen( + new RerenderScreen.ScreenPart( + start, + new TerminalPosition(start.getColumn() + size.getColumns(), start.getRow() + size.getRows()) + ) + ) + ); + + Thread.sleep(1000 / event.getTypingSpeed()); + } + + if (onEnd instanceof OnEnd.Continue(Dialog nextDialog)) { + Thread.sleep(1000); + eventManager.emitEvent(nextDialog); + } else if (onEnd instanceof OnEnd.AskQuestion(OnEnd.AskQuestion.Answer[] answers)) { + + } + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/cz/jzitnik/events/handlers/MouseActionEventHandler.java b/src/main/java/cz/jzitnik/events/handlers/MouseActionEventHandler.java index 0720465..1f84ec2 100644 --- a/src/main/java/cz/jzitnik/events/handlers/MouseActionEventHandler.java +++ b/src/main/java/cz/jzitnik/events/handlers/MouseActionEventHandler.java @@ -76,7 +76,10 @@ public class MouseActionEventHandler extends AbstractEventHandler { gameState.getPlayer().swing(playerConfig.getSwingTimeMs()); - object.ifPresent(selectable -> selectable.interact(dm)); + object.ifPresent(selectable -> { + dm.inject(selectable); + selectable.interact(); + }); } default -> uiRoomClickHandlerRepository.handleElse(event); } diff --git a/src/main/java/cz/jzitnik/events/handlers/QuestionAnswerEventHandler.java b/src/main/java/cz/jzitnik/events/handlers/QuestionAnswerEventHandler.java index 77ea448..b1ed8fb 100644 --- a/src/main/java/cz/jzitnik/events/handlers/QuestionAnswerEventHandler.java +++ b/src/main/java/cz/jzitnik/events/handlers/QuestionAnswerEventHandler.java @@ -4,9 +4,9 @@ import cz.jzitnik.annotations.EventHandler; import cz.jzitnik.annotations.injectors.InjectDependency; import cz.jzitnik.annotations.injectors.InjectState; import cz.jzitnik.events.QuestionAnswerEvent; -import cz.jzitnik.game.GameState; import cz.jzitnik.game.dialog.Dialog; import cz.jzitnik.game.dialog.OnEnd; +import cz.jzitnik.states.DialogState; import cz.jzitnik.utils.DependencyManager; import cz.jzitnik.utils.events.AbstractEventHandler; import cz.jzitnik.utils.events.EventManager; @@ -18,17 +18,17 @@ public class QuestionAnswerEventHandler extends AbstractEventHandler lines = calculateWordWrapping(text, fm, width, height); + + int verticalOffset = calculateVerticalOffset(text, font, fm.getFontRenderContext(), lines); + + int lineHeight = fm.getHeight(); + int y = fm.getAscent() - verticalOffset; + + for (String line : lines) { + g2d.drawString(line, 0, y); + y += lineHeight; + } + g2d.dispose(); + + return convertToPixels(img, width, height); + } + + public AlphaPixel[][][] renderTypingAnimation(String text, int width, int height, Color textColor, float size) { + Font font = loadFont(size); + BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = createGraphics(img, font, textColor); + FontMetrics fm = g2d.getFontMetrics(); + + int lineHeight = fm.getHeight(); + int ascent = fm.getAscent(); + + List lines = calculateWordWrapping(text, fm, width, height); + + int verticalOffset = calculateVerticalOffset(text, font, fm.getFontRenderContext(), lines); + int startY = ascent - verticalOffset; + + int totalChars = 0; + for (String line : lines) totalChars += line.length(); + + AlphaPixel[][][] frames = new AlphaPixel[totalChars + 1][height][width]; + int frameIndex = 0; + + for (int l = 0; l < lines.size(); l++) { + String fullLine = lines.get(l); + for (int c = 0; c < fullLine.length(); c++) { + clearImage(img); + + int drawY = startY; + + for (int prevL = 0; prevL < l; prevL++) { + g2d.drawString(lines.get(prevL), 0, drawY); + drawY += lineHeight; + } + + String partialLine = fullLine.substring(0, c + 1); + g2d.drawString(partialLine, 0, drawY); + + frames[frameIndex++] = convertToPixels(img, width, height); + } + } + + if (frameIndex < frames.length) { + frames[frameIndex] = frames[frameIndex - 1]; + } + + g2d.dispose(); + return frames; + } + + private int calculateVerticalOffset(String text, Font font, FontRenderContext frc, List lines) { + if (lines.isEmpty() || text.isBlank()) return 0; + + String firstLine = lines.get(0); + + if (firstLine.isBlank() && lines.size() > 1) firstLine = lines.get(1); + if (firstLine.isBlank()) return 0; + + GlyphVector gv = font.createGlyphVector(frc, firstLine); + Rectangle2D visualBounds = gv.getVisualBounds(); + float ascent = font.getLineMetrics(firstLine, frc).getAscent(); + double topPixelPos = ascent + visualBounds.getY(); + return (int) topPixelPos; + } + + private Font loadFont(float size) { + try { + return Font.createFont(Font.TRUETYPE_FONT, resourceManager.getResourceAsStream("fonts/default.ttf")).deriveFont(size); + } catch (FontFormatException | IOException e) { + throw new RuntimeException("Failed to load font", e); + } + } + + private Graphics2D createGraphics(BufferedImage img, Font font, Color color) { + Graphics2D g2d = img.createGraphics(); + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g2d.setFont(font); + g2d.setColor(color); + return g2d; + } + + private void clearImage(BufferedImage img) { + Graphics2D g = img.createGraphics(); + g.setComposite(AlphaComposite.Clear); + g.fillRect(0, 0, img.getWidth(), img.getHeight()); + g.dispose(); + } + + private List calculateWordWrapping(String text, FontMetrics fm, int width, int height) { + List lines = new ArrayList<>(); + String[] words = text.split(" "); + StringBuilder line = new StringBuilder(); + + for (String word : words) { + String testLine = line.isEmpty() ? word : line + " " + word; + if (fm.stringWidth(testLine) > width) { + if (!line.isEmpty()) lines.add(line.toString()); + line = new StringBuilder(word); + } else { + line = new StringBuilder(testLine); + } + } + if (!line.isEmpty()) lines.add(line.toString()); + + int maxLines = height / fm.getHeight(); + if (lines.size() > maxLines) { + return lines.subList(0, maxLines); + } + return lines; + } + + private AlphaPixel[][] convertToPixels(BufferedImage img, int width, int height) { + AlphaPixel[][] pixels = new AlphaPixel[height][width]; + int[] rawPixels = new int[width * height]; + img.getRGB(0, 0, width, height, rawPixels, 0, width); + + for (int j = 0; j < height; j++) { + for (int i = 0; i < width; i++) { + int argb = rawPixels[j * width + i]; + int alpha = (argb >> 24) & 0xFF; + + if (alpha == 0) { + pixels[j][i] = new Empty(); + } else { + int r = (argb >> 16) & 0xFF; + int g = (argb >> 8) & 0xFF; + int b = argb & 0xFF; + pixels[j][i] = new ColoredPixel(new TextColor.RGB(r, g, b)); + } + } + } + return pixels; + } + + public Dimension measureText(String text, int maxWidth, float size) { + Font font = loadFont(size); + BufferedImage tmp = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = tmp.createGraphics(); + g2d.setFont(font); + FontMetrics fm = g2d.getFontMetrics(); + + List lines = calculateWordWrapping(text, fm, maxWidth, Integer.MAX_VALUE); + + int totalHeight = lines.size() * fm.getHeight(); + + int calculatedWidth = 0; + for (String line : lines) { + int lineWidth = fm.stringWidth(line); + if (lineWidth > calculatedWidth) { + calculatedWidth = lineWidth; + } + } + g2d.dispose(); + return new Dimension(calculatedWidth, totalHeight); + } +} diff --git a/src/main/resources/fonts/default.ttf b/src/main/resources/fonts/default.ttf new file mode 100644 index 0000000000000000000000000000000000000000..c07b42085790ac4c1da92684047b26b17dfdb779 GIT binary patch literal 17568 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