- Версия(и) Minecraft
- 1.12.2
Те, кто играл на различных сборках с модами знает, что во многих модах есть книжки с инфой о моде. Например, лексика из ботании, таумономиком из таумкрафта или "материалы и вы" из тинкерса.
Данный ресурс будет повествовать о процессе разработки подобной книги в особом формате периодически выходящих заметок. Что-то похожее блог.
По завершению разработки первого релиза будет некоторое summary.
Назову это тутор в раннем доступе.
Вы также можете поучаствовать в разработке, предложив свои идея о дизайне книжки.
Т.к. идея писать заметки мне пришла не сразу, первые несколько могут получиться довольно объемными.
Я начал писать эти заметки потому что задача оказалась неоднозначной. Раньше не думал, что разработка гуи для майна может быть такой увлекательной.
Каждая заметка метится порядковым номером.
Credits:
Спасибо @Dahaka за обсуждение некоторых вопросов
Спасибо @Agravaine :j за годную идею о реализации страничек текста, хотя она не используется
==== 1 ====
Для начал расскажу подробнее о задаче. Требуется создать апи и гуи для мода Ender'sMagic.
Книга делится на главы, открыть любую главу можно со страницы содержания. Каждая глава представляет из себя последовательность страниц.
Апи должно предоставлять удобный способ наполнения книги контентом. Например, если нужно добавить в главу текст, то разрезание на страницы и строки должно происходить автоматически.
Поэтому я решил при первом приближении сделать такую архитектуру:
Определившись с этим, я начал писать реализации для этих интерфейсов.
Первым пошел TextComponent.
Он создается из строки текста и предоставляет список страниц с текстом.
Функция, режущая цельный текст на линии и страницы была выполнена сначала при помощи stream#reduce(посмотреть можно в этом коммите) Это в принципе работало, но было пару неочевидных багов, из-за которых отказался от этого решения в пользу итератора.
С итератором концепция получилась довольно простой:
Имея текст в виде списка слов итератор может отдавать очередную линию текста пока еще впереди есть слова и каждая линия текста наполняется пока она по ширине влезает в страницу
С этим итератором первый раз за 4 года вылетело OutOfMemory, xD. Дело было в том, что я не учел кейс когда последняя линия текста уже влезает в строку и для нее не нужно убирать последнее слово назад(иначе это слово навсегда застревает в итераторе).
Решение:
Запустим тесты в консольке:
Использую для этого scala repl, потому что удобно для маленьких тестов
Результат: превышающих длину линий нет
Теперь юзаем итератор в TextComponent
Одним итератором группируем слова, другим группируем линии в страницы, в конце собираем это все в списки страниц
Применяем в конструкторе
==== 2 ====
Апи и отрисовка
Главный класс апи содержит статический метод, позволяющий добавлять главы
Единственное что он делает - подготавливает страницы и пихает их в мапу
Подготовка происходит таким образом:
Сначала компоненты flatMap-юся в страницы
Потом страницы по две объединяются в пары(потому то на одном развороте две страницы), в качестве пары используется PageContainer
Потом создаются связи между соседними разворотами
И возвращается первый контейнер
github/EnderMagic/BookApi#flatMapToPages
Чтобы листать страницы нужно просто переходить по ссылкам контейнеров влево и вправо
Гуи - github/EnderMagic/GuiScreenEMBook
Хранит текущий контейнер страниц
Основное действо происходит в
Элементы считают началом координат верхний левый угол страницы
Области страниц для отрисовки компонентов
Также, некоторые элементы могут быть кликабельны(например, ссылки на главы в содержании)
Очень хочется как-нить вынести (i + 20, j + 15) и (i + bookFullWidth / 2 + 4, j + 15), координаты верхних левых углов левой и правой страницы, но как это сделать по нормальному до сих пор придумать не удалось
Чтобы листать страницы сделал 3 кнопки навигации
При клике по кнопке происходит смена страницы
PageButton - базовый класс кнопки, которая имеет Supplier<Optional<PageContainer>>
В наследниках это свойство устанавливается через конструктор
Используется именно Supplier, потому что GuiScreenEMBook.instance.currentPage меняется при пролистывании
При изменении страницы вызывается updateButtons, который устанавливает видимость кнопок в зависимости от доступности переходов
==== 3 ====
Выравнивание
Потребность в выравнивании появилась при создании компонента картинки. У картинки есть подпись, которая должна быть под картинкой и отцентрована по ширине.
Формула центрирования такая:
Первая часть формулы означает, что элемент будет размещен в центре страницы. Вторая часть означает, что в качестве точки отсчета координат элемента(точки крепления) используется середина элемента.
Поэтому эту формулу можно обобщить до вида
Поэтому структурному элементу добавляю свойство fixPoint, определяющее точку крепления элемента и свойства width и height.
По дефолту в качестве fix point используется условно верхний левый угол(условно ,потому что метод render можно реализовать как угодно, в том числе рисовать че-то в отрицательных относительных координатах)
Также нужно обновить drawPage, чтобы применять fix point
Для удобства установки значения pageWidth * uv_like_x_координата сделал несколько методов:
min,max,center - универсальные(для любой координаты) функции смещения
остальные - реализации для конкретных осей
Графически можно изобразить так:
Теперь, чтобы сделать текст выровненный по x по центру, по y в самом внизу нужно написать:
Как мы видим, текст правильно позиционируется. Правда, картинка рендерится только куском, но это уже материал для одной из следующих заметок)
====4====
Рендер картинок
В этой заметке речь пойдет о странице
На момент третьей заметки ее рендер выглядел так:
На скрине из прошлой заметке вы могли видеть, что картинка рисуется только куском. Это означает, что неправильно выставлены u/v для вершин.
Воспользуемся
Последние два аргумента позволяют замостить картинкой. Нам нужна только одна копия картинки в прямоугольнике, поэтому передает 1,1
Это делает почти то, что нам нужно
Если соотношение сторон картинки не совпадает с соотношением прямоугольника, то нужно вписывать картинку в прямоугольник.
Для этого нам нужны размеры картинки.
Чтобы получить правильные размеры произвольной картинки, заданной
Делаем кастомный метод рисования вписанной текстуры
В нем нужно вычислить новые размеры прямоугольника, которые будут соответствовать вписанной текстуре
Вызов функции
Теперь осталось скорректировать позицию x,y
Теперь текстурка красиво вписывается в прямоугольник
После рендера текста остается его цвет.
Поэтому мыобнуляем устанавливаем цвет на белый.
Полный код
На этом заметка заканчивается, надеюсь, вам понравилось)
Данный ресурс будет повествовать о процессе разработки подобной книги в особом формате периодически выходящих заметок. Что-то похожее блог.
По завершению разработки первого релиза будет некоторое summary.
Назову это тутор в раннем доступе.
Вы также можете поучаствовать в разработке, предложив свои идея о дизайне книжки.
Т.к. идея писать заметки мне пришла не сразу, первые несколько могут получиться довольно объемными.
Я начал писать эти заметки потому что задача оказалась неоднозначной. Раньше не думал, что разработка гуи для майна может быть такой увлекательной.
Каждая заметка метится порядковым номером.
Credits:
Спасибо @Dahaka за обсуждение некоторых вопросов
Спасибо @Agravaine :j за годную идею о реализации страничек текста, хотя она не используется
==== 1 ====
Для начал расскажу подробнее о задаче. Требуется создать апи и гуи для мода Ender'sMagic.
Книга делится на главы, открыть любую главу можно со страницы содержания. Каждая глава представляет из себя последовательность страниц.
Апи должно предоставлять удобный способ наполнения книги контентом. Например, если нужно добавить в главу текст, то разрезание на страницы и строки должно происходить автоматически.
Поэтому я решил при первом приближении сделать такую архитектуру:
Java:
IChapterComponent
|
V
IPage
|
V
IStructuralGuiElement
- С точки зрения добавления контента глава представляет из себя список IChapterComponent - кусков главы, которые не обязательно соответствуют одной странице
- Каждая страница(IPage) предоставляет список структурных элементов
- IStructuralGuiElement - структурный элемент, примитив для построения страниц. Например, строка текст, или картинка
Java:
public interface IChapterComponent {
List<IPage> pages();
}
public interface IPage {
List<IStructuralGuiElement> elements();
}
public interface IStructuralGuiElement {
default Minecraft mc() {// вспомогательный метод, чтобы каждый раз не писать Minecraft.getMinecraft()
return Minecraft.getMinecraft();
}
void render(int mouseX, int mouseY);
}
Первым пошел TextComponent.
Он создается из строки текста и предоставляет список страниц с текстом.
Функция, режущая цельный текст на линии и страницы была выполнена сначала при помощи stream#reduce(посмотреть можно в этом коммите) Это в принципе работало, но было пару неочевидных багов, из-за которых отказался от этого решения в пользу итератора.
С итератором концепция получилась довольно простой:
Имея текст в виде списка слов итератор может отдавать очередную линию текста пока еще впереди есть слова и каждая линия текста наполняется пока она по ширине влезает в страницу
Java:
public class GroupIterator<A> implements ListIterator<List<A>> {
private final ListIterator<A> list;
private final Integer max;
private final Function<A, Integer> size;
public GroupIterator(ListIterator<A> list, Integer max, Function<A, Integer> size) {
this.list = list;
this.max = max;
this.size = size;
}
@Override
public boolean hasNext() {
return list.hasNext();
}
@Override
public List<A> next() {
List<A> acc = new ArrayList<>(10);
Integer accSize = 0;
while (accSize < max && list.hasNext()) {
A next = list.next();
acc.add(next);
accSize += size.apply(next);
}
if (list.hasPrevious()) {
acc.remove(acc.size() - 1);
list.previous();
}
return acc;
}
Решение:
Java:
if (accSize > max && list.hasPrevious()) {
acc.remove(acc.size() - 1);
list.previous();
}
Запустим тесты в консольке:
Использую для этого scala repl, потому что удобно для маленьких тестов
Результат: превышающих длину линий нет
Теперь юзаем итератор в TextComponent
Java:
private ImmutableList<IPage> buildPagesForFont(FontRenderer font) {
font.setUnicodeFlag(true);
String[] words = text.split("\\s+");
int spaceWidth = font.getStringWidth(" ");
GroupIterator<String> lines = new GroupIterator<>(Arrays.asList(words).listIterator(), lineSize, w -> font.getStringWidth(w) + spaceWidth);
GroupIterator<List<String>> pages = new GroupIterator<>(lines, pageSize, __ -> 1);
return StreamSupport.stream(Spliterators.spliteratorUnknownSize(pages, Spliterator.ORDERED), false)
.map(page ->
new TextPage(page
.stream()
.map(line -> String.join(" ", line))
.collect(ImmutableList.toImmutableList())))
.collect(ImmutableList.toImmutableList());
}
Применяем в конструкторе
Java:
public TextComponent(String unlocalizedText) {
text = I18n.format(unlocalizedText);
pages = buildPagesForFont(Minecraft.getMinecraft().fontRenderer);
}
==== 2 ====
Апи и отрисовка
Главный класс апи содержит статический метод, позволяющий добавлять главы
Java:
public static void addChapter(String name, List<IChapterComponent> content) {
book.put(name, flatMapToPages(content.stream()));
}
Подготовка происходит таким образом:
Сначала компоненты flatMap-юся в страницы
Потом страницы по две объединяются в пары(потому то на одном развороте две страницы), в качестве пары используется PageContainer
Потом создаются связи между соседними разворотами
И возвращается первый контейнер
github/EnderMagic/BookApi#flatMapToPages
Чтобы листать страницы нужно просто переходить по ссылкам контейнеров влево и вправо
Гуи - github/EnderMagic/GuiScreenEMBook
Хранит текущий контейнер страниц
Основное действо происходит в
drawScreen
Java:
private static int bookFullWidth = 256;
private static int bookFullHeight = 192;
public void drawScreen(int mouseX, int mouseY, float partialTicks) {
GlStateManager.color(1.0F, 1.0F, 1.0F, 1.0F);
mc.getTextureManager().bindTexture(BOOK_TEXTURES);
int i = (width - bookFullWidth) / 2;
int j = (height - bookFullHeight) / 2;
drawTexturedModalRect(i, j, 0, 0, bookFullWidth, bookFullHeight);//Сначала отрисовывается фон
drawPage(i + 20, j + 15, currentPage.page1, mouseX, mouseY);//Потом страницы
drawPage(i + bookFullWidth / 2 + 4, j + 15, currentPage.page2, mouseX, mouseY);
GlStateManager.disableRescaleNormal();
RenderHelper.disableStandardItemLighting();
GlStateManager.disableLighting();
GlStateManager.disableDepth();
super.drawScreen(mouseX, mouseY, partialTicks);//Потом кнопки
RenderHelper.enableGUIStandardItemLighting();
GlStateManager.enableRescaleNormal();
}
private void drawPage(int i, int j, IPage page, int mouseX, int mouseY) {
GlStateManager.pushMatrix();
GlStateManager.translate(i, j, 0);
page.elements().forEach(e -> e.render(mouseX - i, mouseY - j));//Отрисовываем все элементы
GlStateManager.popMatrix();
}
Области страниц для отрисовки компонентов
Также, некоторые элементы могут быть кликабельны(например, ссылки на главы в содержании)
Java:
protected void mouseClicked(int mouseX, int mouseY, int mouseButton) throws IOException {
super.mouseClicked(mouseX, mouseY, mouseButton);
int i = (width - bookFullWidth) / 2;
int j = (height - bookFullHeight) / 2;
if (mouseX < i + bookFullWidth / 2)
performClickOnPage(mouseX, mouseY, i + 20, j + 15, currentPage.page1);
else
performClickOnPage(mouseX, mouseY, i + bookFullWidth / 2 + 4, j + 15, currentPage.page2);
}
Чтобы листать страницы сделал 3 кнопки навигации
Java:
buttonBack = addButton(new BackButton(0, width / 2 - 11, j + 160));
buttonNextPage = addButton(new NextPageButton(1, i + 215, j + 160, true));
buttonPreviousPage = addButton(new NextPageButton(2, i + 18, j + 160, false));
При клике по кнопке происходит смена страницы
Java:
protected void actionPerformed(GuiButton button) {
if(button instanceof PageButton)
setCurrentPage(((PageButton) button).goToPage.get());
}
В наследниках это свойство устанавливается через конструктор
Java:
public NextPageButton(int button, int x, int y, boolean isForward) {
super(button, x, y, 0, isForward ? bookFullHeight : bookFullHeight + 13,
isForward ? () -> GuiScreenEMBook.instance.currentPage.right : () -> GuiScreenEMBook.instance.currentPage.left);
}
При изменении страницы вызывается updateButtons, который устанавливает видимость кнопок в зависимости от доступности переходов
Java:
private void updateButtons() {
buttonPreviousPage.visible = currentPage.left.isPresent();
buttonNextPage.visible = currentPage.right.isPresent();
buttonBack.visible = currentPage != BookApi.mainPage();
}
==== 3 ====
Выравнивание
Потребность в выравнивании появилась при создании компонента картинки. У картинки есть подпись, которая должна быть под картинкой и отцентрована по ширине.
Формула центрирования такая:
x = pageWidth/2 - elementWidth/2
Первая часть формулы означает, что элемент будет размещен в центре страницы. Вторая часть означает, что в качестве точки отсчета координат элемента(точки крепления) используется середина элемента.
Поэтому эту формулу можно обобщить до вида
x = pageWidth * uv_like_x_координата - точка[I]крепления[/I]элемента
Поэтому структурному элементу добавляю свойство fixPoint, определяющее точку крепления элемента и свойства width и height.
Java:
default Vec2i fixPoint() {
return new Vec2i(0, 0);
}
Также нужно обновить drawPage, чтобы применять fix point
Java:
private void drawPage(int i, int j, IPage page, int mouseX, int mouseY) {
GlStateManager.pushMatrix();
GlStateManager.translate(i, j, 0);
page.elements().forEach(e -> {
GlStateManager.translate(-e.fixPoint().x, -e.fixPoint().y, 0);
e.render(mouseX - i, mouseY - j);
GlStateManager.translate(e.fixPoint().x, e.fixPoint().y, 0);
});
GlStateManager.popMatrix();
}
Для удобства установки значения pageWidth * uv_like_x_координата сделал несколько методов:
Java:
public class Alignment {
public static int min(double v,int dimension){return (int) (dimension * v);}
public static int max(double v,int dimension){return (int) (dimension * (1 + v));}
public static int center(double v,int dimension){return (int) (dimension * (0.5 + v));}
public static int left(double v){return min(v, BookApi.pageWidth);}
public static int right(double v){return max(v, BookApi.pageWidth);}
public static int centerX(double v){return center(v, BookApi.pageWidth);}
public static int top(double v){return min(v, BookApi.pageHeight);}
public static int bottom(double v){return max(v, BookApi.pageHeight);}
public static int centerY(double v){return center(v, BookApi.pageHeight);}
}
остальные - реализации для конкретных осей
Графически можно изобразить так:
Теперь, чтобы сделать текст выровненный по x по центру, по y в самом внизу нужно написать:
Java:
new TextLine(label, centerX(0)/[I]по центру страницы[/I]/, bottom(0)) {
@Override
public Vec2i fixPoint() {
return new Vec2i(width() / 2, height());/[I]точка фиксации по x в центре элемента[/I]/
}
}
Как мы видим, текст правильно позиционируется. Правда, картинка рендерится только куском, но это уже материал для одной из следующих заметок)
====4====
Рендер картинок
В этой заметке речь пойдет о странице
ImagePage
. Страница состоит из ImageView
и TestLine
для подписи
Код:
private final ResourceLocation texture;
private final String label;
@Override
public List<IStructuralGuiElement> elements() {
return ImmutableList.of(
new ImageView(texture, new Rectangle(5, 5, BookApi.pageWidth - 10, BookApi.pageHeight - 20)),
new TextLine(label, centerX(0), bottom(0)) {
@Override
public Vec2i fixPoint() {
return new Vec2i(width() / 2, height());
}
}
);
}
ImageView
должна рисовать картинку texture в пределах прямоугольника - второго аргумента.На момент третьей заметки ее рендер выглядел так:
Код:
@Override
public void render(int mouseX, int mouseY) {
mc().getTextureManager().bindTexture(texture);
drawTexturedModalRect(rectangle.getX(), rectangle.getY(), 0, 0, rectangle.getWidth(), rectangle.getHeight());
}
Воспользуемся
drawScaledCustomSizeModalRect
:
Java:
drawScaledCustomSizeModalRect(rectangle.getX(), rectangle.getY(),
0, 0, // u,v
1, 1, // uw,vh
rectangle.getWidth(), rectangle.getHeight(), // w,h
1, 1);//tileSizes
Это делает почти то, что нам нужно
Если соотношение сторон картинки не совпадает с соотношением прямоугольника, то нужно вписывать картинку в прямоугольник.
Для этого нам нужны размеры картинки.
Чтобы получить правильные размеры произвольной картинки, заданной
ResourceLocation
, нам нужно получить ее AtlasSprite
:
Код:
this.atlas = Minecraft.getMinecraft().getTextureMapBlocks().getAtlasSprite(texture.toString());
Делаем кастомный метод рисования вписанной текстуры
public static void drawInscribedCustomSizeModalRect(int x, int y, int width, int height, float textureWidth, float textureHeight)
В нем нужно вычислить новые размеры прямоугольника, которые будут соответствовать вписанной текстуре
Java:
//Для начала определим отношение сторон текстуры
float ratio = textureWidth / textureHeight;
//После можем описать новую ширину
int newWidth = min(width, (int) (ratio * height));
//И новую высоту
int newHeight = min(height, (int) (1f / ratio * width));
Math#min
нужен чтобы ограничить новые размеры размерами прямоугольникаТеперь осталось скорректировать позицию x,y
Java:
int ny = y + (height - newHeight) / 2;
int nx = x + (width - newWidth) / 2;
Теперь текстурка красиво вписывается в прямоугольник
После рендера текста остается его цвет.
Поэтому мы
Полный код
Java:
@Override
public void render(int mouseX, int mouseY) {
GlStateManager.color(1, 1, 1, 1);
mc().getTextureManager().bindTexture(texture);
drawInscribedCustomSizeModalRect(rectangle.getX(), rectangle.getY(),
rectangle.getWidth(), rectangle.getHeight(),
atlas.getIconWidth(), atlas.getIconHeight());
}
public static void drawInscribedCustomSizeModalRect(int x, int y, int width, int height, float textureWidth, float textureHeight) {
float ratio = textureWidth / textureHeight;
int newWidth = min(width, (int) (ratio * height));
int newHeight = min(height, (int) (1f / ratio * width));
int ny = y + (height - newHeight) / 2;
int nx = x + (width - newWidth) / 2;
drawScaledCustomSizeModalRect(nx, ny, 0, 0,
1, 1,
newWidth, newHeight,
1, 1);
}
На этом заметка заканчивается, надеюсь, вам понравилось)