package dev.isxander.controlify.gui.screen; import dev.isxander.controlify.Controlify; import dev.isxander.controlify.api.bind.BindRenderer; import dev.isxander.controlify.api.bind.ControllerBinding; import dev.isxander.controlify.api.bind.RadialIcon; import dev.isxander.controlify.bindings.RadialIcons; import dev.isxander.controlify.controller.Controller; import dev.isxander.controlify.gui.guide.GuideAction; import dev.isxander.controlify.gui.guide.GuideActionRenderer; import dev.isxander.controlify.gui.layout.AnchorPoint; import dev.isxander.controlify.gui.layout.PositionedComponent; import dev.isxander.controlify.screenop.ComponentProcessor; import dev.isxander.controlify.screenop.ScreenControllerEventListener; import dev.isxander.controlify.screenop.ScreenProcessor; import dev.isxander.controlify.screenop.ScreenProcessorProvider; import dev.isxander.controlify.sound.ControlifySounds; import dev.isxander.controlify.utils.Animator; import dev.isxander.controlify.utils.Easings; import dev.isxander.controlify.utils.Log; import dev.isxander.controlify.virtualmouse.VirtualMouseBehaviour; import net.minecraft.client.gui.ComponentPath; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.components.MultiLineLabel; import net.minecraft.client.gui.components.Renderable; import net.minecraft.client.gui.components.events.ContainerEventHandler; import net.minecraft.client.gui.components.events.GuiEventListener; import net.minecraft.client.gui.narration.NarratableEntry; import net.minecraft.client.gui.narration.NarratedElementType; import net.minecraft.client.gui.narration.NarrationElementOutput; import net.minecraft.client.gui.navigation.FocusNavigationEvent; import net.minecraft.client.gui.navigation.ScreenRectangle; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.resources.sounds.SimpleSoundInstance; import net.minecraft.network.chat.CommonComponents; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; import net.minecraft.sounds.SoundEvents; import net.minecraft.util.Mth; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; public class RadialMenuScreen extends Screen implements ScreenControllerEventListener, ScreenProcessorProvider { public static final ResourceLocation EMPTY_ACTION = new ResourceLocation("controlify", "empty_action"); private final Controller controller; private final boolean editMode; private final Screen parent; private final RadialButton[] buttons = new RadialButton[8]; private int selectedButton = -1; private int idleTicks; private boolean isEditing; private ActionSelectList actionSelectList; private final Processor processor = new Processor(this); public RadialMenuScreen(Controller controller, boolean editMode, Screen parent) { super(Component.empty()); this.controller = controller; this.editMode = editMode; this.parent = parent; } @Override protected void init() { int centerX = this.width / 2; int centerY = this.height / 2; RadialButton button; addRenderableWidget(buttons[0] = button = new RadialButton(0, centerX - 16, centerY - 64 - 8)); addRenderableWidget(buttons[1] = button = new RadialButton(1, button.x + 32 + 8, button.y + 16)); addRenderableWidget(buttons[2] = button = new RadialButton(2, button.x + 16, button.y + 32 + 8)); addRenderableWidget(buttons[3] = button = new RadialButton(3, button.x - 16, button.y + 32 + 8)); addRenderableWidget(buttons[4] = button = new RadialButton(4, button.x - 32 - 8, button.y + 16)); addRenderableWidget(buttons[5] = button = new RadialButton(5, button.x - 32 - 8, button.y - 16)); addRenderableWidget(buttons[6] = button = new RadialButton(6, button.x - 16, button.y - 32 - 8)); addRenderableWidget(buttons[7] = new RadialButton(7, button.x + 16, button.y - 32 - 8)); Animator.AnimationInstance animation = new Animator.AnimationInstance(5, Easings::easeOutQuad); for (RadialButton radialButton : buttons) { animation.addConsumer(radialButton::setX, centerX - 16, (float) radialButton.getX()); animation.addConsumer(radialButton::setY, centerY - 16, (float) radialButton.getY()); } Animator.INSTANCE.play(animation); if (editMode) { var exitGuide = addRenderableWidget(new PositionedComponent<>( new GuideActionRenderer<>( new GuideAction<>( controller.bindings().GUI_BACK, obj -> Optional.of(CommonComponents.GUI_DONE) ), false, true ), AnchorPoint.BOTTOM_CENTER, 0, -10, AnchorPoint.BOTTOM_CENTER )); exitGuide.getComponent().updateName(null); exitGuide.updatePosition(width, height); } } @Override public void onControllerInput(Controller controller) { if (this.controller != controller) return; if (!editMode && !controller.bindings().RADIAL_MENU.held()) { if (selectedButton != -1 && buttons[selectedButton].invoke()) { playClickSound(); } onClose(); } if (editMode && controller.bindings().GUI_BACK.justPressed()) { playClickSound(); onClose(); } if (!isEditing) { float x = controller.bindings().RADIAL_AXIS_RIGHT.state() - controller.bindings().RADIAL_AXIS_LEFT.state(); float y = controller.bindings().RADIAL_AXIS_DOWN.state() - controller.bindings().RADIAL_AXIS_UP.state(); float threshold = controller.config().buttonActivationThreshold; if (Math.abs(x) >= threshold || Math.abs(y) >= threshold) { float angle = Mth.wrapDegrees(Mth.RAD_TO_DEG * (float) Mth.atan2(y, x) - 90f) + 180f; float each = 360f / buttons.length; int newSelected = Mth.floor((angle + each / 2f) / each) % buttons.length; if (newSelected != selectedButton) { selectedButton = newSelected; minecraft.getSoundManager().play(SimpleSoundInstance.forUI(ControlifySounds.SCREEN_FOCUS_CHANGE, 1f)); } for (int i = 0; i < buttons.length; i++) { boolean selected = i == selectedButton; buttons[i].setFocused(selected); if (selected) { this.setFocused(buttons[i]); } } idleTicks = 0; } else if (!editMode) { idleTicks++; if (idleTicks >= 20) { selectedButton = -1; for (RadialButton button : buttons) { button.setFocused(false); } } } } } @Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) { if (editMode) renderDirtBackground(graphics); super.render(graphics, mouseX, mouseY, delta); if (!editMode) { graphics.drawCenteredString( font, Component.translatable("controlify.radial_menu.configure_hint"), width / 2, height - 39, -1 ); } } private void playClickSound() { minecraft.getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1f)); } private void finishEditing() { isEditing = false; removeWidget(actionSelectList); this.setFocused(null); actionSelectList = null; } @Override public void onClose() { Controlify.instance().config().saveIfDirty(); minecraft.setScreen(parent); } @Override public boolean isPauseScreen() { return editMode; } @Override public ScreenProcessor screenProcessor() { return this.processor; } public class RadialButton implements Renderable, GuiEventListener, NarratableEntry, ComponentProcessor { public static final ResourceLocation TEXTURE = Controlify.id("textures/gui/radial-buttons.png"); private int x, y; private float translateX, translateY; private boolean focused; private ControllerBinding binding; private MultiLineLabel name; private RadialIcon icon; private RadialButton(int index, float x, float y) { this.setX(x); this.setY(y); ResourceLocation binding = controller.config().radialActions[index]; if (controller.bindings().get(binding) == null) { Log.LOGGER.warn("Binding {} does not exist!", binding); controller.config().radialActions[index] = EMPTY_ACTION; Controlify.instance().config().setDirty(); } this.setAction(binding); } @Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) { graphics.pose().pushPose(); graphics.pose().translate(x + translateX, y + translateY, 0); graphics.pose().pushPose(); graphics.pose().scale(2, 2, 1); graphics.blit(TEXTURE, 0, 0, focused ? 16 : 0, 0, 16, 16, 32, 16); graphics.pose().popPose(); if (!editMode || !focused) { graphics.pose().pushPose(); graphics.pose().translate(4, 4, 0); graphics.pose().scale(1.5f, 1.5f, 1); this.icon.draw(graphics, 0, 0, delta); graphics.pose().popPose(); } else { BindRenderer renderer = controller.bindings().GUI_PRESS.renderer(); renderer.render(graphics, 16 - renderer.size().width() / 2, 16); } graphics.pose().popPose(); if (focused) name.renderCentered(graphics, width / 2, height / 2 - font.lineHeight / 2 - ((name.getLineCount() - 1) * font.lineHeight / 2)); } public boolean invoke() { if (binding != null) { binding.fakePress(); return true; } return false; } public void setAction(ResourceLocation binding) { if (!EMPTY_ACTION.equals(binding) && controller.bindings().get(binding) != null) { this.binding = controller.bindings().get(binding); this.icon = RadialIcons.getIcons().get(this.binding.radialIcon().orElseThrow()); this.name = MultiLineLabel.create(font, this.binding.name(), 76); } else { this.binding = null; this.name = MultiLineLabel.EMPTY; this.icon = RadialIcons.getIcons().get(RadialIcons.EMPTY); } } public int getX() { return x; } public int getY() { return y; } public void setX(float x) { this.x = (int) x; this.translateX = x - this.x; } public void setY(float y) { this.y = (int) y; this.translateY = y - this.y; } @Override public boolean isFocused() { return focused; } @Override public void setFocused(boolean focused) { this.focused = focused; } @Override public boolean overrideControllerButtons(ScreenProcessor screen, Controller controller) { if (editMode && controller == RadialMenuScreen.this.controller && controller.bindings().GUI_PRESS.justPressed()) { RadialButton button = buttons[selectedButton]; int x = button.x < width / 2 ? button.x - 110 : button.x + 42; actionSelectList = new ActionSelectList(selectedButton, x, button.y, 100, 80); addRenderableWidget(actionSelectList); RadialMenuScreen.this.setFocused(actionSelectList); isEditing = true; return true; } return false; } @Override public NarrationPriority narrationPriority() { return isFocused() ? NarrationPriority.FOCUSED : NarrationPriority.NONE; } @Override public void updateNarration(NarrationElementOutput builder) { if (binding != null) builder.add(NarratedElementType.TITLE, binding.name()); } @Override public ScreenRectangle getRectangle() { return new ScreenRectangle(x, y, 32, 32); } } public class ActionSelectList implements Renderable, ContainerEventHandler, NarratableEntry, ComponentProcessor { private final int radialIndex; private int x, y; private int width, height; private final int itemHeight = 10; private int scrollOffset; private boolean focused; private ActionEntry focusedEntry; private final List children = new ArrayList<>(); public ActionSelectList(int index, int x, int y, int width, int height) { this.radialIndex = index; this.x = x; this.y = y; this.width = width; this.height = height; controller.bindings().registry().entrySet().stream() .filter(entry -> entry.getValue().radialIcon().isPresent()) .map(Map.Entry::getKey) .forEach(id -> children.add(new ActionEntry(id))); var selectedBind = controller.config().radialActions[radialIndex]; children.stream() .filter(action -> action.binding.equals(selectedBind)) .findAny() .ifPresent(this::setFocused); } @Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) { graphics.fill(x, y, x + width, y + height, 0x80000000); graphics.enableScissor(x, y, x + width, y + height); int y = this.y - scrollOffset; for (ActionEntry child : children) { child.render(graphics, x, y, width, itemHeight, mouseX, mouseY, delta); y += itemHeight; } graphics.disableScissor(); graphics.renderOutline(x - 1, this.y - 1, width + 2, height + 2, 0x80ffffff); } @Override public boolean overrideControllerButtons(ScreenProcessor screen, Controller controller) { if (controller == RadialMenuScreen.this.controller) { if (controller.bindings().GUI_BACK.justPressed()) { finishEditing(); return true; } } return false; } @Override public List children() { return children; } @Override public boolean isDragging() { return false; } @Override public void setDragging(boolean dragging) { } @Nullable @Override public ActionEntry getFocused() { return focusedEntry; } @Override public void setFocused(@Nullable GuiEventListener child) { ActionEntry focus = (ActionEntry) child; this.focusedEntry = focus; if (focus != null) { int index = children().indexOf(child); if (index != -1) { int focusY = index * itemHeight - scrollOffset; if (focusY < 0) scrollOffset = Mth.clamp(index * itemHeight, 0, children().size() * itemHeight - height); else if (focusY + itemHeight > height) scrollOffset = Mth.clamp(index * itemHeight + itemHeight - height, 0, children().size() * itemHeight - height); } } } @Override public void setFocused(boolean focused) { this.focused = focused; } @Override public boolean isFocused() { return focused; } @Override public NarrationPriority narrationPriority() { return focused ? NarrationPriority.FOCUSED : NarrationPriority.NONE; } @Override public void updateNarration(NarrationElementOutput builder) { if (getFocused() != null) { builder.add(NarratedElementType.TITLE, getFocused().name); } } public class ActionEntry implements GuiEventListener, ComponentProcessor { private int x, y; private boolean focused; private final ResourceLocation binding; private final Component name; public ActionEntry(ResourceLocation binding) { this.binding = binding; this.name = controller.bindings().get(binding).name(); } public void render(GuiGraphics graphics, int x, int y, int width, int itemHeight, int mouseX, int mouseY, float delta) { this.x = x; this.y = y; if (focused) graphics.fill(x, y, x + width, y + itemHeight, 0xff000000); graphics.drawString(RadialMenuScreen.this.font, name, x + 2, y + 1, focused ? -1 : 0xffa6a6a6); } @Override public void setFocused(boolean focused) { this.focused = focused; } @Override public boolean isFocused() { return focused; } @Nullable @Override public ComponentPath nextFocusPath(FocusNavigationEvent event) { return !focused ? ComponentPath.leaf(this) : null; } @Override public ScreenRectangle getRectangle() { return new ScreenRectangle(x, y, width, itemHeight); } @Override public boolean overrideControllerButtons(ScreenProcessor screen, Controller controller) { if (controller == RadialMenuScreen.this.controller) { if (controller.bindings().GUI_PRESS.justPressed()) { controller.config().radialActions[radialIndex] = binding; Controlify.instance().config().setDirty(); buttons[radialIndex].setAction(binding); playClickSound(); finishEditing(); return true; } } return false; } } } public static class Processor extends ScreenProcessor { public Processor(RadialMenuScreen screen) { super(screen); } @Override public VirtualMouseBehaviour virtualMouseBehaviour() { return VirtualMouseBehaviour.DISABLED; } } }