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 0000000..c07b420 Binary files /dev/null and b/src/main/resources/fonts/default.ttf differ