diff --git a/api/src/main/java/ru/dragonestia/msb3/api/ServerBootstrap.java b/api/src/main/java/ru/dragonestia/msb3/api/ServerBootstrap.java index 2c03545..20f42e4 100644 --- a/api/src/main/java/ru/dragonestia/msb3/api/ServerBootstrap.java +++ b/api/src/main/java/ru/dragonestia/msb3/api/ServerBootstrap.java @@ -25,10 +25,14 @@ public class ServerBootstrap { "==============================================", }; + @Getter private static ServerBootstrap instance; + private final MinecraftServer server; @Getter private final ResourcePackManager resourcePackManager; public ServerBootstrap() { + instance = this; + for (var line: LOGO) log.info(line); server = MinecraftServer.init(); diff --git a/api/src/main/java/ru/dragonestia/msb3/api/glyph/pack/DefaultGlyphResourcePack.java b/api/src/main/java/ru/dragonestia/msb3/api/glyph/pack/DefaultGlyphResourcePack.java index 7cf3141..4cac26b 100644 --- a/api/src/main/java/ru/dragonestia/msb3/api/glyph/pack/DefaultGlyphResourcePack.java +++ b/api/src/main/java/ru/dragonestia/msb3/api/glyph/pack/DefaultGlyphResourcePack.java @@ -52,7 +52,7 @@ public class DefaultGlyphResourcePack implements GlyphResourcePack { @Override public @NotNull T get(@NotNull ResourceIdentifier<@NotNull T> id) throws IllegalArgumentException { if (!compiled.containsKey(id.key())) { - throw new IllegalArgumentException("Producer with that identifier is not compiled"); + throw new IllegalArgumentException("Producer with that identifier is not compiled. Provided key: " + id.key()); } ResourceProducer producer = compiled.get(id.key()); if (!id.getType().isAssignableFrom(producer.getClass())) { diff --git a/api/src/main/java/ru/dragonestia/msb3/api/resource/ResourcePackManager.java b/api/src/main/java/ru/dragonestia/msb3/api/resource/ResourcePackManager.java index bc5ae9f..7aff414 100644 --- a/api/src/main/java/ru/dragonestia/msb3/api/resource/ResourcePackManager.java +++ b/api/src/main/java/ru/dragonestia/msb3/api/resource/ResourcePackManager.java @@ -7,6 +7,7 @@ import ru.dragonestia.msb3.api.glyph.compile.GlyphCompiler; import ru.dragonestia.msb3.api.glyph.font.GlyphFont; import ru.dragonestia.msb3.api.glyph.pack.GlyphResourcePack; import ru.dragonestia.msb3.api.item.BlankSlotItem; +import ru.dragonestia.msb3.api.talk.MonologueRenderer; import ru.dragonestia.msb3.api.title.BlackScreen; import team.unnamed.creative.ResourcePack; import team.unnamed.creative.atlas.Atlas; @@ -37,9 +38,11 @@ public class ResourcePackManager { } private void initDefaultGlyphs() { + glyphResourcePack.withMojangSpaces(); glyphResourcePack.with(BlankSlotItem.compile()); glyphResourcePack.with(GlyphCompiler.instance().compile(BlackScreen.GLYPH)); glyphResourcePack.with(GlyphFont.compile()); + MonologueRenderer.compile(glyphResourcePack); } public void compile() { diff --git a/api/src/main/java/ru/dragonestia/msb3/api/talk/MonologueRenderer.java b/api/src/main/java/ru/dragonestia/msb3/api/talk/MonologueRenderer.java new file mode 100644 index 0000000..a7f405d --- /dev/null +++ b/api/src/main/java/ru/dragonestia/msb3/api/talk/MonologueRenderer.java @@ -0,0 +1,128 @@ +package ru.dragonestia.msb3.api.talk; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.Style; +import net.kyori.adventure.text.format.TextDecoration; +import net.minestom.server.entity.Player; +import org.jetbrains.annotations.NotNull; +import ru.dragonestia.msb3.api.ServerBootstrap; +import ru.dragonestia.msb3.api.glyph.glyph.GlyphComponentBuilder; +import ru.dragonestia.msb3.api.glyph.glyph.image.ImageGlyph; +import ru.dragonestia.msb3.api.glyph.glyph.image.TextureProperties; +import ru.dragonestia.msb3.api.glyph.pack.GlyphResourcePack; +import ru.dragonestia.msb3.api.glyph.pack.ResourceIdentifier; +import ru.dragonestia.msb3.api.glyph.pack.StringIdentifier; +import ru.dragonestia.msb3.api.util.ResourceFromJar; +import ru.dragonestia.msb3.api.util.StringUtil; +import team.unnamed.creative.base.Writable; +import team.unnamed.creative.texture.Texture; + +public final class MonologueRenderer { + + private static final Key MONOLOG_FONT_KEY = Key.key("msb3", "monolog"); + private static final Writable DEFAULT_COMPANION_AVATAR_WRITABLE = ResourceFromJar.of(ServerBootstrap.CLASS_LOADER, "glyphs/monologue/default_avatar.png"); + private static final Writable COMPANION_AVATAR_FRAME_WRITABLE = ResourceFromJar.of(ServerBootstrap.CLASS_LOADER, "glyphs/monologue/avatar_frame.png"); + private static final Writable SPEECH_INDICATOR_WRITABLE = ResourceFromJar.of(ServerBootstrap.CLASS_LOADER, "glyphs/monologue/speech_indicator.png"); + private static final Component INDENT_COMPONENT = Component.text(" ".repeat(14)); + private static final ImageGlyph AVATAR_FRAME; + private static final ImageGlyph DEFAULT_AVATAR; + private static final ImageGlyph SPEECH_INDICATOR; + private static final ResourceIdentifier<@NotNull ImageGlyph> AVATAR_FRAME_RESOURCE_IDENTIFIER; + private static final ResourceIdentifier<@NotNull ImageGlyph> SPEECH_INDICATOR_RESOURCE_IDENTIFIER; + private static final ResourceIdentifier<@NotNull ImageGlyph> DEFAULT_AVATAR_RESOURCE_IDENTIFIER; + + static { + AVATAR_FRAME = ImageGlyph.of(MONOLOG_FONT_KEY, + Texture.texture().key(Key.key("msb3", "monolog/avatar_frame.png")).data(COMPANION_AVATAR_FRAME_WRITABLE).build(), + new TextureProperties(50, 6)); + + DEFAULT_AVATAR = ImageGlyph.of(MONOLOG_FONT_KEY, + Texture.texture().key(Key.key("msb3", "monolog/default_avatar.png")).data(DEFAULT_COMPANION_AVATAR_WRITABLE).build(), + new TextureProperties(42, 2)); + + SPEECH_INDICATOR = ImageGlyph.of(MONOLOG_FONT_KEY, + Texture.texture().key(Key.key("msb3", "monolog/speech_indicator.png")).data(SPEECH_INDICATOR_WRITABLE).build(), + new TextureProperties(8, 6)); + + AVATAR_FRAME_RESOURCE_IDENTIFIER = StringIdentifier.image("monolog_avatar_frame"); + SPEECH_INDICATOR_RESOURCE_IDENTIFIER = StringIdentifier.image("monolog_speech_indicator"); + DEFAULT_AVATAR_RESOURCE_IDENTIFIER = StringIdentifier.image("monolog_default_avatar"); + } + + private final Player player; + private final String title; + private final String message; + + private MonologueRenderer(Player player, String title, String message) { + this.player = player; + this.title = title; + this.message = message; + } + + public void show() { + player.sendMessage(toComponent(ServerBootstrap.getInstance().getResourcePackManager().getGlyphResourcePack())); + } + + public static MonologueRenderer create(Player player, String title, String message) { + return new MonologueRenderer(player, title, message); + } + + private Component toComponent(@NotNull GlyphResourcePack resourcePack) { + Component text = Component.text(message); + Component[] parts = StringUtil.splitIntoParts(text, 180, string -> string.length() * 4); + if (parts != null && parts.length != 0) { + int avatarLineStart = Math.max(parts.length - 4, 0) / 2; + if (parts.length > 3) ++avatarLineStart; + + var avatarComponent = GlyphComponentBuilder.universal(resourcePack.spaces()) + .append(resourcePack.get(AVATAR_FRAME_RESOURCE_IDENTIFIER)) + .append(4, resourcePack.get(DEFAULT_AVATAR_RESOURCE_IDENTIFIER)) + .build(); + + var titleComponent = Component.empty() + .append(resourcePack.get(SPEECH_INDICATOR_RESOURCE_IDENTIFIER).toAdventure()) + .append(Component.space()).append(Component.text(title, NamedTextColor.WHITE, TextDecoration.BOLD)); + + var monolog = Component.text(); + + int lineIdx; + for(lineIdx = 0; lineIdx < parts.length + 2; ++lineIdx) { + if (lineIdx == avatarLineStart) { + monolog.append(avatarComponent); + } + + monolog.append(INDENT_COMPONENT); + if (lineIdx == 0) { + monolog.append(Component.newline()); + } + + if (lineIdx == 1) { + monolog.append(titleComponent).append(Component.newline()); + } + + if (lineIdx > 1) { + monolog.append(parts[lineIdx - 2].applyFallbackStyle(Style.style(NamedTextColor.GRAY))) + .append(Component.newline()); + } + } + + if (parts.length < 4) { + for(lineIdx = parts.length + 2; lineIdx < 5; ++lineIdx) { + monolog.append(Component.newline()); + } + } + + return monolog.asComponent(); + } else { + throw new IllegalArgumentException("Cannot fit speech text"); + } + } + + public static void compile(GlyphResourcePack resourcePack) { + resourcePack.with(DEFAULT_AVATAR_RESOURCE_IDENTIFIER, DEFAULT_AVATAR) + .with(SPEECH_INDICATOR_RESOURCE_IDENTIFIER, SPEECH_INDICATOR) + .with(AVATAR_FRAME_RESOURCE_IDENTIFIER, AVATAR_FRAME); + } +} diff --git a/api/src/main/java/ru/dragonestia/msb3/api/util/StringUtil.java b/api/src/main/java/ru/dragonestia/msb3/api/util/StringUtil.java new file mode 100644 index 0000000..61a714c --- /dev/null +++ b/api/src/main/java/ru/dragonestia/msb3/api/util/StringUtil.java @@ -0,0 +1,271 @@ +package ru.dragonestia.msb3.api.util; + +import java.util.ArrayList; +import java.util.Deque; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.function.Function; + +import lombok.Getter; +import lombok.Setter; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.flattener.ComponentFlattener; +import net.kyori.adventure.text.flattener.FlattenerListener; +import net.kyori.adventure.text.format.Style; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.jetbrains.annotations.NotNull; + +public final class StringUtil { + + public static String[] splitIntoParts(String stringToSplit, int maxPartWidth, Function stringWidthCalculateFunction) { + var partsMap = new TreeMap(); + int beginIndex = 0; + int endIndex = 0; + + String lastLineWithTail; + for(int currentIndex = calcMinIndex(stringToSplit.indexOf(10), stringToSplit.indexOf(32)); currentIndex < stringToSplit.length() && currentIndex != -1; currentIndex = calcMinIndex(stringToSplit.indexOf(32, currentIndex + 1), stringToSplit.indexOf(10, currentIndex + 1))) { + lastLineWithTail = stringToSplit.substring(beginIndex, currentIndex); + if (!lastLineWithTail.contains("\n") && stringWidthCalculateFunction.apply(lastLineWithTail) <= maxPartWidth) { + endIndex = currentIndex + 1; + partsMap.put(beginIndex, lastLineWithTail); + } else { + if (beginIndex == endIndex) { + return null; + } + + beginIndex = endIndex; + --currentIndex; + } + } + + String tail = stringToSplit.substring(endIndex); + String var10000 = partsMap.containsKey(beginIndex) ? partsMap.get(beginIndex) + " " : ""; + lastLineWithTail = var10000 + tail; + if (stringWidthCalculateFunction.apply(lastLineWithTail) <= maxPartWidth) { + partsMap.put(beginIndex, lastLineWithTail); + } else { + if (stringWidthCalculateFunction.apply(tail) > maxPartWidth) { + return null; + } + + partsMap.put(endIndex, tail); + } + + return partsMap.values().toArray(String[]::new); + } + + public static Component[] splitIntoParts(Component componentToSplit, int maxPartWidth, Function stringWidthCalculateFunction) { + var flattenerListener = new StringSplitterFlattenerListener(maxPartWidth, stringWidthCalculateFunction); + ComponentFlattener.basic().flatten(componentToSplit, flattenerListener); + var components = flattenerListener.retrieveResult(); + return components.isEmpty()? null : components.toArray(Component[]::new); + } + + public static Component cutEnding(Component component, String endingSuffix) { + CutEndingFlattenerListener flattenerListener = new CutEndingFlattenerListener(endingSuffix); + ComponentFlattener.basic().flatten(component, flattenerListener); + return flattenerListener.retrieveResult(); + } + + private static int calcMinIndex(int... indexes) { + int min = Integer.MAX_VALUE; + boolean containNotMinusOne = false; + + for (int index : indexes) { + if (index != -1) { + containNotMinusOne = true; + if (index < min) { + min = index; + } + } + } + + return containNotMinusOne ? min : -1; + } + + private StringUtil() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + private static class StringSplitterFlattenerListener implements FlattenerListener { + + private final Deque colors = new LinkedList<>(); + private final List lines = new LinkedList<>(); + private final int maxPartWidth; + private final Function stringWidthCalculateFunction; + private Component currentComponent = Component.text(""); + private boolean retrieve = false; + private boolean broken = false; + + public StringSplitterFlattenerListener(int maxPartWidth, Function stringWidthCalculateFunction) { + this.maxPartWidth = maxPartWidth; + this.stringWidthCalculateFunction = stringWidthCalculateFunction; + } + + public void pushStyle(Style style) { + var color = style.color(); + if (color != null) colors.add(color); + } + + public void component(@NotNull String text) { + String currentColoredPart = PlainTextComponentSerializer.plainText().serialize(currentComponent); + String resultText = currentColoredPart + text; + String[] targetLines = StringUtil.splitIntoParts(resultText, maxPartWidth, stringWidthCalculateFunction); + if (targetLines == null) broken = true; + else { + TextColor currentColor = colors.peekLast(); + if (!resultText.isEmpty()) { + int startIndex = 1; + String firstLine = null; + + if (targetLines[0].length() > currentColoredPart.length()) { + firstLine = targetLines[0].substring(currentColoredPart.length()); + startIndex = 0; + } else { + lines.add(currentComponent); + currentComponent = Component.text(""); + } + + for (int i = startIndex; i < targetLines.length - 1; ++i) { + String targetLine = targetLines[i]; + if (i == 0) { + targetLine = firstLine; + } + + var targetLineComponent = currentColor == null ? Component.text(targetLine) : Component.text(targetLine, currentColor); + lines.add(i == 0 ? currentComponent.append(targetLineComponent) : targetLineComponent.applyFallbackStyle(Style.empty())); + } + + if (targetLines.length == 1) { + if (startIndex != 1) { + currentComponent = currentComponent.append(currentColor == null ? Component.text(firstLine).applyFallbackStyle(Style.empty()) : Component.text(firstLine, currentColor)); + } + } else { + currentComponent = Component.text("") + .append(currentColor == null ? Component.text(targetLines[targetLines.length - 1]) : Component.text(targetLines[targetLines.length - 1], currentColor)); + } + } + + } + } + + public void popStyle(Style style) { + var color = style.color(); + if (color != null) colors.removeLast(); + } + + public List retrieveResult() { + if (retrieve) { + throw new IllegalStateException("Result had been already retrieved"); + } else { + retrieve = true; + if (broken) return List.of(); + else { + if (!PlainTextComponentSerializer.plainText().serialize(currentComponent).isEmpty()) { + lines.add(currentComponent); + } + + lines.replaceAll(Component::compact); + return lines; + } + } + } + } + + private static class CutEndingFlattenerListener implements FlattenerListener { + + private final Deque colors = new LinkedList<>(); + private final List pairs = new ArrayList<>(); + private String endingSuffix; + private boolean retrieve = false; + + public CutEndingFlattenerListener(String endingSuffix) { + this.endingSuffix = endingSuffix; + } + + public void pushStyle(@NotNull Style style) { + TextColor color = style.color(); + if (color != null) colors.add(color); + } + + public void component(@NotNull String text) { + pairs.add(new TextColorPair(text, colors.peekLast())); + } + + public void popStyle(@NotNull Style style) { + TextColor color = style.color(); + if (color != null) colors.removeLast(); + } + + public Component retrieveResult() { + if (retrieve) { + throw new IllegalStateException("Result had been already retrieved"); + } else { + retrieve = true; + int removed = 0; + + for (int i = pairs.size() - 1; i >= 0; --i) { + TextColorPair pair = pairs.get(i); + if (pair.getColor() != null) { + pairs.remove(i); + removed += pair.getText().length(); + } else { + int suffixLength = endingSuffix.length(); + String var10001; + if (removed >= suffixLength) { + var10001 = pair.getText().replaceFirst(" $", ""); + pair.setText(var10001 + endingSuffix); + endingSuffix = ""; + break; + } + + int partLength = pair.getText().length(); + if (partLength >= suffixLength) { + var10001 = pair.getText().substring(0, partLength - suffixLength).replaceFirst(" $", ""); + pair.setText(var10001 + endingSuffix); + endingSuffix = ""; + break; + } + + pair.setText(endingSuffix.substring(endingSuffix.length() - partLength)); + endingSuffix = endingSuffix.substring(0, endingSuffix.length() - partLength); + } + } + + if (!endingSuffix.isEmpty()) { + pairs.clear(); + pairs.add(new TextColorPair(endingSuffix.trim(), null)); + } + + Component result = null; + + for (TextColorPair pair: pairs) { + Component part = Component.text(pair.getText(), pair.getColor()); + if (result == null) { + result = part; + } else { + result = result.append(part); + } + } + + return result; + } + } + + @Setter + @Getter + private static final class TextColorPair { + + private String text; + private TextColor color; + + public TextColorPair(String text, TextColor color) { + this.text = text; + this.color = color; + } + } + } +} diff --git a/api/src/main/resources/glyphs/monologue/avatar_frame.png b/api/src/main/resources/glyphs/monologue/avatar_frame.png new file mode 100644 index 0000000..760d17e Binary files /dev/null and b/api/src/main/resources/glyphs/monologue/avatar_frame.png differ diff --git a/api/src/main/resources/glyphs/monologue/default_avatar.png b/api/src/main/resources/glyphs/monologue/default_avatar.png new file mode 100644 index 0000000..ee05482 Binary files /dev/null and b/api/src/main/resources/glyphs/monologue/default_avatar.png differ diff --git a/api/src/main/resources/glyphs/monologue/speech_indicator.png b/api/src/main/resources/glyphs/monologue/speech_indicator.png new file mode 100644 index 0000000..8368fb3 Binary files /dev/null and b/api/src/main/resources/glyphs/monologue/speech_indicator.png differ