From 4831309a68dcb89da3a3002cef2a6de861cf9d5c Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Wed, 4 Nov 2026 20:33:29 +0200 Subject: [PATCH 1/4] Add mod-defined interactive GUI widgets --- docs/gui-screens.md | 194 ++++++++++++++ docs/machine-companion.md | 24 +- .../moddev/mc/companion/CompanionMod.java | 1 + .../moddev/mc/companion/CompanionWidgets.java | 117 +++++++++ .../moddev/mc/companion/ProcessorEntity.java | 41 ++- .../client/CompanionWidgetsClient.java | 45 ++++ .../mc/companion/client/LaneSelector.java | 87 +++++++ .../mc/companion/client/NameEditor.java | 176 +++++++++++++ .../mc/companion/client/ReserveSlider.java | 120 +++++++++ .../mc/companion/client/WidgetDrawing.java | 29 +++ .../assets/mmdlib_companion/lang/en_us.json | 17 ++ .../resources/mmdlib/screen/crusher.xml | 29 ++- .../companion/test/CompanionClientSmoke.java | 18 +- .../mc/companion/test/CompanionGameTests.java | 246 +++++++++++++++++- .../companion/test/CompanionWidgetSmoke.java | 165 ++++++++++++ .../zone/moddev/mc/lib/gui/WidgetAction.java | 75 ++++++ .../zone/moddev/mc/lib/gui/WidgetType.java | 38 +++ .../zone/moddev/mc/lib/gui/WidgetTypes.java | 38 +++ .../mc/lib/gui/client/MachineScreen.java | 142 +++++++++- .../mc/lib/gui/client/WidgetAccess.java | 21 ++ .../mc/lib/gui/client/WidgetFactory.java | 7 + .../moddev/mc/lib/gui/client/WidgetInput.java | 216 +++++++++++++++ .../mc/lib/gui/client/WidgetInstance.java | 62 +++++ .../mc/lib/gui/client/WidgetRenderer.java | 41 +++ .../mc/lib/gui/client/WidgetRenderers.java | 86 ++++++ .../mc/lib/gui/layout/ScreenLayout.java | 1 + .../moddev/mc/lib/gui/load/ScreenBinder.java | 17 +- .../moddev/mc/lib/gui/load/ScreenLoader.java | 1 + .../moddev/mc/lib/gui/load/ScreenReader.java | 40 ++- .../zone/moddev/mc/lib/gui/model/Element.java | 40 +++ .../zone/moddev/mc/lib/gui/model/Screen.java | 14 + .../mc/lib/gui/runtime/MachineMenu.java | 119 ++++++++- .../mc/lib/gui/runtime/ScreenNetwork.java | 100 ++++++- .../mc/lib/gui/runtime/ScreenRuntime.java | 25 +- .../mc/lib/gui/runtime/ScreenSnapshots.java | 4 + src/main/resources/mmdlib/schema/screen.xsd | 19 ++ .../moddev/mc/lib/gui/CustomWidgetsTest.java | 198 ++++++++++++++ .../moddev/mc/lib/gui/ScreenLoaderTest.java | 88 +++++-- .../moddev/mc/lib/gui/WidgetActionTest.java | 89 +++++++ .../moddev/mc/lib/gui/WidgetInputTest.java | 189 ++++++++++++++ 40 files changed, 2912 insertions(+), 67 deletions(-) create mode 100644 src/companion/java/zone/moddev/mc/companion/CompanionWidgets.java create mode 100644 src/companion/java/zone/moddev/mc/companion/client/CompanionWidgetsClient.java create mode 100644 src/companion/java/zone/moddev/mc/companion/client/LaneSelector.java create mode 100644 src/companion/java/zone/moddev/mc/companion/client/NameEditor.java create mode 100644 src/companion/java/zone/moddev/mc/companion/client/ReserveSlider.java create mode 100644 src/companion/java/zone/moddev/mc/companion/client/WidgetDrawing.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/CompanionWidgetSmoke.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/WidgetAction.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/WidgetType.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/WidgetTypes.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/WidgetAccess.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/WidgetFactory.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/WidgetInput.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/WidgetInstance.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/WidgetRenderer.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/WidgetRenderers.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/CustomWidgetsTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/WidgetActionTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/WidgetInputTest.java diff --git a/docs/gui-screens.md b/docs/gui-screens.md index 8e27ab39..a8ce0d3e 100644 --- a/docs/gui-screens.md +++ b/docs/gui-screens.md @@ -132,6 +132,200 @@ under `build/gui-smoke*`, checks server actions and full codecs, opens the actua synced values, and saves `screenshots/gui-smoke.png`. Run a normal build afterward to remove injected smoke classes from main output; release verification rejects any leaked smoke classes. +## Registering custom widget types + +A consuming mod can add a leaf widget without replacing MMDLib's screen or menu. Register its +common-side contract in the mod constructor, before screen discovery: + +```java +import java.util.Set; +import net.minecraft.resources.ResourceLocation; +import zone.moddev.mc.lib.gui.WidgetType; +import zone.moddev.mc.lib.gui.WidgetTypes; +import zone.moddev.mc.lib.gui.load.BoundValue.Kind; + +WidgetTypes.GLOBAL.register(ResourceLocation.parse("example:temperature"), + new WidgetType(Set.of(Kind.WHOLE, Kind.DECIMAL, Kind.NUMBER), widget -> { + if (widget.common().value().isEmpty() || widget.common().max().isEmpty()) + throw new IllegalArgumentException("Temperature needs value and max"); + if (!widget.properties().isEmpty()) + throw new IllegalArgumentException("Temperature has no custom properties"); + })); +``` + +`valueKinds` lists the declared getter return kinds the widget accepts. Use an empty set for a +decoration without a value; `max`, when present, is always numeric and requires `value`. The +validator checks required bindings, dimensions and properties. Throw `IllegalArgumentException` +with an author-facing explanation; MMDLib adds the screen file, line and widget type. Validators +run on both sides at startup and must not use client classes or a live world. IDs cannot be +replaced, and type registration closes when startup screen discovery begins. + +Use the registered type in any ordinary widget container: + +```xml + +``` + +Width and height are required, from 1 to 1024 GUI pixels; the existing `s` slot unit is supported. +The element receives normal box placement and supports all common attributes, including offsets, +tooltips, `on-click`, `on-right-click`, shifted clicks, `on-hover`, `hover-shows`, and `jei-recipes`. +An interactive instance handles input first. If it returns `PASS`, JEI recipe actions take +priority over ordinary click handlers; use a separate recipes control when needed. + +Types that need configuration can accept named properties: + +```xml + + + +``` + +The type validator owns the allowed property names and value formats. Duplicate names fail +loading. `widget.properties()` is an immutable, sorted map of strings, included in the screen +fingerprint. Property nodes do not create menu elements or slots. Unknown XML attributes still +fail schema validation. The generic XSD can describe property structure; per-type rules are +checked by Java at startup. + +Register drawing separately, **only on the client**, during client mod construction or +`FMLClientSetupEvent`: + +```java +import zone.moddev.mc.lib.gui.client.WidgetRenderers; + +WidgetRenderers.GLOBAL.register(ResourceLocation.parse("example:temperature"), context -> { + var g = context.graphics(); + var r = context.bounds(); + g.fill(r.x(), r.y(), r.right(), r.bottom(), 0xff353535); + int filled = (int) Math.round(r.height() * context.ratio()); + g.fill(r.x(), r.bottom() - filled, r.right(), r.bottom(), 0xffdd6644); +}); +``` + +The context supplies the custom element, absolute GUI bounds and mouse coordinates, partial +tick, and the server-synced `value()` / `maximum()`. Values can be null before sync or when a +getter fails. Whole numbers arrive as `BigInteger`, decimals as `Double`; boolean, fluid and +text kinds use the existing protocol. `ratio()` clamps numeric values to 0..1 and returns zero +when unavailable. `hovered()` tests the widget's rectangle. Do not mutate or retain synced +objects, and never call machine getters from the renderer. + +MMDLib clips drawing to the widget rectangle and restores its pose and tint afterward. Renderers +must balance any additional pose/scissor pushes and restore any other render state they change. +Renderer IDs must have a common-side type, and every custom widget used in a loaded screen must +have a renderer. These checks run at client load completion, when renderer registration closes. +Rendering and this registry are never loaded on a dedicated server. + +This API supports leaf widgets. It does not provide custom child containers or additional getter +channels beyond `value` and `max`. Stateful input and typed actions are described below. Ordinary +click handlers continue to use menu ownership, distance, visibility and action validation. + +The companion Crusher demonstrates a clickable progress dial in +`CompanionWidgets`, `client.CompanionWidgetsClient`, and `mmdlib/screen/crusher.xml`. Its custom +click pauses/resumes the machine through the same server callback as the pause button. The +companion game tests exercise it on a dedicated server, and the client smoke checks its synced +values and click path. For tools that own loading, `ScreenReader.read` and `ScreenBinder.bind` +also accept an explicit `WidgetTypes` registry; use the same registry for both. + +### Interactive instances + +Use `WidgetRenderers.GLOBAL.registerInteractive(typeId, MyWidget::new)` to register a +`WidgetFactory`. It receives a `WidgetAccess` and returns a **fresh `WidgetInstance`** per element +per screen. Keep draft text, cursor/selection, drag previews and other local state in that instance. +Do not keep editing state in a static renderer. The existing `register` method remains available +for stateless drawing. + +`WidgetAccess` exposes the element, live absolute bounds, server-confirmed value and maximum, +`pending()`, and `submit(action, payload)`. `WidgetInstance` implements rendering plus optional +mouse, scroll, key, character, tick, focus and lifecycle methods. Its click result is: + +| Result | Meaning | +|---|---| +| `PASS` | Try the element's ordinary recipe/click behavior and underlying screen handling | +| `CONSUME` | Handle this click; focus the instance if `focusable()` is true | +| `CAPTURE` | Also receive dragging and release for this mouse button outside the widget bounds | + +Tab and Shift+Tab move between visible focusable custom widgets in document order. While one is +focused, inventory shortcuts are consumed even when its `keyPressed` returns false, so typing E, +Q or a number cannot close the screen or move a slot. `charTyped` is separate from `keyPressed`. +Escape first cancels capture; otherwise it is offered to the focused instance. If unhandled, it +clears focus. With no focused instance, the screen's normal back/close behavior applies. + +Hiding a widget cancels capture, removes focus and calls `visibilityChanged(false)`. A cancelled +drag does not receive a committing release, and that release cannot leak into an inventory slot. +Resize calls `cancelInteraction()` on a captured instance and `resized()` on all instances; the +instances and keyboard focus survive. Keep text drafts in `resized()` and use `visibilityChanged` +to decide whether to discard drafts on a view change. `removed()` runs once per instance when +the screen is removed. Balance any widget-owned resources there. +If another screen (such as a recipe browser) returns to the same `MachineScreen` object, fresh +widget instances are created on reinitialization. Ordinary resizing keeps the existing instances. + +The companion `NameEditor` adapts Minecraft's `EditBox`, including clipboard shortcuts, cursor +movement, selection, drag selection, a 32-character limit, and Apply/Cancel. It retains a dirty +draft while server values change, and only replaces it on successful submission or explicit +cancellation. `ReserveSlider` previews while dragging and sends one update on release; it also +supports scroll, arrow keys, Shift for ten-point steps, Home and End. + +### Typed actions and acknowledgments + +Define an action on the common side and add it to the third argument of `WidgetType`. Only actions +in that type's list can be invoked by its elements. A handler receives the authenticated server +player, menu and custom element; return false to reject. Check permissions, numeric bounds, +allowed options and any element-specific property **before** mutating the machine. + +```java +var setLimit = new WidgetAction(ResourceLocation.parse("example:set_limit"), + StreamCodec.of((buffer, value) -> buffer.writeVarInt(value), buffer -> buffer.readVarInt()), + 5, (context, value) -> { + if (value < 0 || value > 100) return false; + if (!(context.menu().blockEntity instanceof PressBlockEntity press)) return false; + press.setLimit(value); + return true; + }); + +WidgetTypes.GLOBAL.register(ResourceLocation.parse("example:limit_slider"), + new WidgetType(Set.of(Kind.WHOLE), widget -> { + if (widget.common().value().isEmpty()) + throw new IllegalArgumentException("Limit slider needs value"); + }, List.of(setLimit))); +``` + +The relevant imports are `gui.WidgetAction`, `gui.WidgetType`, `gui.WidgetTypes`, +`gui.load.BoundValue.Kind`, `net.minecraft.network.codec.StreamCodec`, and the ordinary Java +collections. Codecs use `RegistryFriendlyByteBuf`, so structured records and registry-aware +values are supported. Register matching action IDs and codecs on both sides. Each action declares +a maximum encoded size of 1–4096 bytes. The envelope is bounded before the mod codec runs, the +encoder uses a bounded buffer, and trailing bytes are refused. **Codecs must additionally bound +their own string and collection lengths before allocating.** + +The client instance calls `access.submit(setLimit, previewValue)`. False means the widget is +hidden, another request is pending, or an earlier result still needs delivery. Do not submit +every render frame. For sliders, commit on release; throttle continuous controls yourself. + +The server checks menu ownership, interaction distance, visibility, element type, allowed action, +menu-session token and increasing request number before dispatch. At most eight typed actions +per menu per server tick are processed; excess and replayed requests are dropped. Handler +exceptions are logged and rejected, but MMDLib cannot undo a handler's partial mutations. + +The response includes accept/reject and fresh authoritative `value`/`max` snapshots after the +handler. They are applied before `WidgetInstance.actionResult(boolean)` runs. Ordinary snapshot +sync continues for every viewer, including changes to other elements. Preserve a rejected text +draft and show feedback so the player can correct it. Requests are limited to one in flight per +widget. After 100 client ticks without a matching response, the control is unlocked and receives +`actionResult(false)` to report an **unconfirmed** edit; that does not prove the server rolled back +or never applied the edit. Responses for old sessions or obsolete requests are ignored. + +The companion Settings view demonstrates all three cases: a named machine, an energy-reserve +slider that holds processing below the chosen charge percentage, and a Run/Pause selector whose +payload contains both lane index and state. The server restricts that selector to the lane named +in its XML property. These settings persist with the machine. The graphical smoke run checks +focus, typing, cancellation, resizing, view changes, release outside bounds and server rejection. + +The expanded menu protocol is version **2**, including a session token in menu-opening data. +Client and server must use matching MMDLib builds. + ## Files and IDs Screens live in the same root `mmdlib/` folders as other definitions, under `screen/`. As with diff --git a/docs/machine-companion.md b/docs/machine-companion.md index 9ce72012..438bc211 100644 --- a/docs/machine-companion.md +++ b/docs/machine-companion.md @@ -37,9 +37,27 @@ inside to read longer text. All four screens place a vertical battery on the rig reusable `` XML widget. Its red charge bars fill upwards inside a slim casing. The energy and fluid gauges share matching edges and medium-grey empty backgrounds, darker than slots. Energy bars are thin with a soft reflection; cylindrical glass shading covers only the fluid. -The Crusher centers its input, progress arrow and output alongside it. -Hover the energy display -for stored and maximum FE; click the first progress arrow to see recipes when JEI is installed. +The Crusher centers its input, a circular progress dial and output alongside it. The dial is a +custom widget registered by the companion through MMDLib's [widget API](gui-screens.md#registering-custom-widget-types). +Click the dial to pause or resume; its tooltip shows progress in ticks. The Crusher's Recipes +button opens crushing recipes when JEI is installed. The other processors retain their progress +arrows, which open recipes when clicked with JEI installed. Hover the energy display for stored +and maximum FE. + +The Crusher's **Settings** button opens three examples of mod-defined interactive widgets: + +- **Machine name:** type up to 32 characters and choose Apply, or Cancel to restore the last + confirmed value. Empty names are rejected. The draft survives resizing; leaving Settings + discards an unapplied draft. +- **Energy reserve:** drag to preview a percentage and release to save. The processor holds + while stored energy is below that percentage. Scroll or use arrow keys for one-point changes, + Shift+arrow for ten points, or Home/End for 0/100. The default is zero. +- **Lane A:** choose Run or Pause. The action sends the lane and desired state together; the + server validates both. + +Tab cycles through the editors; Escape cancels an edit or focus before returning to the machine +view. Name and reserve survive save/load. The companion's `client` package contains the input +implementations, while `CompanionWidgets` owns their common-side validation and typed actions. The Crusher also has a **4,000 mB (four-bucket) tank** on the left. Right-click the block with a water or lava bucket to fill it, or an empty bucket to drain it; open the screen with an empty hand diff --git a/src/companion/java/zone/moddev/mc/companion/CompanionMod.java b/src/companion/java/zone/moddev/mc/companion/CompanionMod.java index c78ded40..9c798c46 100644 --- a/src/companion/java/zone/moddev/mc/companion/CompanionMod.java +++ b/src/companion/java/zone/moddev/mc/companion/CompanionMod.java @@ -69,6 +69,7 @@ public final class CompanionMod { } public CompanionMod(IEventBus bus) { + CompanionWidgets.register(); BLOCKS.register(bus); ITEMS.register(bus); ENTITIES.register(bus); diff --git a/src/companion/java/zone/moddev/mc/companion/CompanionWidgets.java b/src/companion/java/zone/moddev/mc/companion/CompanionWidgets.java new file mode 100644 index 00000000..c5527dbf --- /dev/null +++ b/src/companion/java/zone/moddev/mc/companion/CompanionWidgets.java @@ -0,0 +1,117 @@ +package zone.moddev.mc.companion; + +import java.util.Set; + +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.gui.WidgetAction; +import zone.moddev.mc.lib.gui.WidgetType; +import zone.moddev.mc.lib.gui.WidgetTypes; +import zone.moddev.mc.lib.gui.load.BoundValue.Kind; + +/** A consuming mod owns the widget contract; no client class is referenced here. */ +public final class CompanionWidgets { + public static final ResourceLocation PROGRESS_DIAL = CompanionMod.id("progress_dial"); + public static final ResourceLocation NAME_EDITOR = CompanionMod.id("name_editor"); + public static final ResourceLocation RESERVE_SLIDER = CompanionMod.id("reserve_slider"); + public static final ResourceLocation LANE_SELECTOR = CompanionMod.id("lane_selector"); + + public static final WidgetAction RENAME = + new WidgetAction<>( + CompanionMod.id("rename"), + net.minecraft.network.codec.StreamCodec.of( + (b, value) -> b.writeUtf(value, 32), b -> b.readUtf(32)), + 132, + (context, value) -> + context.menu().blockEntity instanceof ProcessorEntity machine + && machine.rename(value)); + + public static final WidgetAction RESERVE = + new WidgetAction<>( + CompanionMod.id("reserve"), + net.minecraft.network.codec.StreamCodec.of( + (b, value) -> b.writeVarInt(value), b -> b.readVarInt()), + 5, + (context, value) -> + context.menu().blockEntity instanceof ProcessorEntity machine + && machine.setReservePercent(value)); + + public record LaneSetting(int lane, boolean paused) {} + + public static final WidgetAction SET_LANE = + new WidgetAction<>( + CompanionMod.id("set_lane"), + net.minecraft.network.codec.StreamCodec.of( + (b, value) -> { + b.writeVarInt(value.lane()); + b.writeBoolean(value.paused()); + }, + b -> new LaneSetting(b.readVarInt(), b.readBoolean())), + 6, + (context, value) -> { + if (!(context.menu().blockEntity instanceof ProcessorEntity machine) + || value.lane() != Integer.parseInt(context.widget().properties().get("lane")) + || value.lane() < 0 + || value.lane() >= machine.lanes.size()) return false; + if (machine.lanes.get(value.lane()).paused() != value.paused()) + machine.toggle(value.lane()); + return true; + }); + + private CompanionWidgets() {} + + public static void register() { + WidgetTypes.GLOBAL.register( + NAME_EDITOR, + new WidgetType( + Set.of(Kind.TEXT), + widget -> validateEditor(widget, 140, 42, Set.of()), + java.util.List.of(RENAME))); + WidgetTypes.GLOBAL.register( + RESERVE_SLIDER, + new WidgetType( + Set.of(Kind.WHOLE), + widget -> validateEditor(widget, 100, 30, Set.of()), + java.util.List.of(RESERVE))); + WidgetTypes.GLOBAL.register( + LANE_SELECTOR, + new WidgetType( + Set.of(Kind.BOOLEAN), + widget -> { + validateEditor(widget, 100, 30, Set.of("lane")); + String lane = widget.properties().get("lane"); + if (!"0".equals(lane) && !"1".equals(lane)) + throw new IllegalArgumentException("lane must be 0 or 1"); + }, + java.util.List.of(SET_LANE))); + WidgetTypes.GLOBAL.register( + PROGRESS_DIAL, + new WidgetType( + Set.of(Kind.WHOLE, Kind.DECIMAL, Kind.NUMBER), + widget -> { + if (widget.common().value().isEmpty() || widget.common().max().isEmpty()) + throw new IllegalArgumentException("A progress dial needs value and max getters"); + if (widget.width() < 16 || widget.height() < 16) + throw new IllegalArgumentException("A progress dial needs at least 16 x 16 px"); + for (var name : widget.properties().keySet()) + if (!name.equals("color")) + throw new IllegalArgumentException("Unknown property: " + name); + String color = widget.properties().getOrDefault("color", "#55CC88"); + if (!color.matches("#[0-9a-fA-F]{6}")) + throw new IllegalArgumentException("color must be #RRGGBB"); + })); + } + + private static void validateEditor( + zone.moddev.mc.lib.gui.model.Element.Custom widget, + int width, + int height, + Set properties) { + if (widget.common().value().isEmpty()) + throw new IllegalArgumentException("Editor needs a value getter"); + if (widget.width() < width || widget.height() < height) + throw new IllegalArgumentException("Editor needs at least " + width + " x " + height + " px"); + if (!properties.containsAll(widget.properties().keySet())) + throw new IllegalArgumentException("Unknown editor property"); + } +} diff --git a/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java b/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java index cff95ce2..4d18816d 100644 --- a/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java +++ b/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java @@ -48,6 +48,31 @@ public enum Kind { private final Kind kind; // 0 ignores redstone, 1 requires a signal, 2 requires no signal. Manual pause is separate. private int redstoneMode; + private String machineName = "Crusher"; + private int reservePercent; + + public Component getMachineName() { + return Component.literal(machineName); + } + + public int getReservePercent() { + return reservePercent; + } + + public boolean rename(String name) { + if (name.isBlank() || name.length() > 32 || name.codePoints().anyMatch(Character::isISOControl)) + return false; + machineName = name.strip(); + setChanged(); + return true; + } + + public boolean setReservePercent(int value) { + if (value < 0 || value > 100) return false; + reservePercent = value; + setChanged(); + return true; + } public ProcessorEntity(BlockEntityType type, BlockPos pos, BlockState state, Kind kind) { super(type, pos, state); @@ -78,7 +103,8 @@ public ProcessorEntity(BlockEntityType type, BlockPos pos, BlockState state, @Override public void serverTick() { - if (redstoneAllowsRun()) features.tick(level); + if (redstoneAllowsRun() && energy.stored() >= energy.capacity() * reservePercent / 100) + features.tick(level); } public int getRedstoneMode() { @@ -143,6 +169,9 @@ public boolean getSecondPaused() { public List getWarnings() { var warnings = new ArrayList(); + if (energy.stored() < energy.capacity() * reservePercent / 100) + warnings.add( + Component.translatable("screen.mmdlib_companion.reserve.waiting", reservePercent)); if (!redstoneAllowsRun()) warnings.add(Component.translatable("screen.mmdlib_companion.redstone.waiting")); for (int i = 0; i < lanes.size(); i++) { @@ -189,6 +218,8 @@ protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) super.saveAdditional(tag, registries); tag.put("items", items.serializeNBT(registries)); tag.putInt("redstone_mode", redstoneMode); + tag.putString("machine_name", machineName); + tag.putInt("reserve_percent", reservePercent); if (hasTank()) tag.put("fluids", fluids.serializeNBT(registries)); tag.put("features", features.save(registries.createSerializationContext(NbtOps.INSTANCE))); } @@ -199,6 +230,14 @@ protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) super.loadAdditional(tag, registries); int savedMode = tag.getInt("redstone_mode"); redstoneMode = savedMode >= 0 && savedMode <= 2 ? savedMode : 0; + String savedName = tag.getString("machine_name"); + machineName = + !savedName.isBlank() + && savedName.length() <= 32 + && savedName.codePoints().noneMatch(Character::isISOControl) + ? savedName.strip() + : "Crusher"; + reservePercent = Math.clamp(tag.getInt("reserve_percent"), 0, 100); items.deserializeNBT(registries, tag.getCompound("items")); if (hasTank() && tag.contains("fluids")) fluids.deserializeNBT(registries, tag.getCompound("fluids")); features.load(registries.createSerializationContext(NbtOps.INSTANCE), tag.get("features")); diff --git a/src/companion/java/zone/moddev/mc/companion/client/CompanionWidgetsClient.java b/src/companion/java/zone/moddev/mc/companion/client/CompanionWidgetsClient.java new file mode 100644 index 00000000..0212392c --- /dev/null +++ b/src/companion/java/zone/moddev/mc/companion/client/CompanionWidgetsClient.java @@ -0,0 +1,45 @@ +package zone.moddev.mc.companion.client; + +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent; + +import zone.moddev.mc.companion.CompanionMod; +import zone.moddev.mc.companion.CompanionWidgets; +import zone.moddev.mc.lib.gui.client.WidgetRenderer; +import zone.moddev.mc.lib.gui.client.WidgetRenderers; + +/** Client-only registration and drawing, using only MMDLib's public widget API. */ +@EventBusSubscriber(modid = CompanionMod.ID, bus = EventBusSubscriber.Bus.MOD, value = Dist.CLIENT) +public final class CompanionWidgetsClient { + private CompanionWidgetsClient() {} + + @SubscribeEvent + public static void setup(FMLClientSetupEvent event) { + WidgetRenderers.GLOBAL.registerInteractive(CompanionWidgets.NAME_EDITOR, NameEditor::new); + WidgetRenderers.GLOBAL.registerInteractive(CompanionWidgets.RESERVE_SLIDER, ReserveSlider::new); + WidgetRenderers.GLOBAL.registerInteractive(CompanionWidgets.LANE_SELECTOR, LaneSelector::new); + WidgetRenderers.GLOBAL.register( + CompanionWidgets.PROGRESS_DIAL, CompanionWidgetsClient::drawDial); + } + + private static void drawDial(WidgetRenderer.Context context) { + var g = context.graphics(); + var r = context.bounds(); + int color = + 0xff000000 + | Integer.parseInt( + context.widget().properties().getOrDefault("color", "#55CC88").substring(1), 16); + int cx = r.x() + r.width() / 2, cy = r.y() + r.height() / 2; + double radius = Math.min(r.width(), r.height()) / 2.0 - 2; + int lit = (int) Math.round(context.ratio() * 24); + for (int i = 0; i < 24; i++) { + double angle = i * Math.PI * 2 / 24 - Math.PI / 2; + int x = cx + (int) Math.round(Math.cos(angle) * radius); + int y = cy + (int) Math.round(Math.sin(angle) * radius); + g.fill(x - 1, y - 1, x + 1, y + 1, i < lit ? color : 0xff454545); + } + g.fill(cx - 1, cy - 1, cx + 1, cy + 1, context.hovered() ? 0xffffffff : color); + } +} diff --git a/src/companion/java/zone/moddev/mc/companion/client/LaneSelector.java b/src/companion/java/zone/moddev/mc/companion/client/LaneSelector.java new file mode 100644 index 00000000..292dbca4 --- /dev/null +++ b/src/companion/java/zone/moddev/mc/companion/client/LaneSelector.java @@ -0,0 +1,87 @@ +package zone.moddev.mc.companion.client; + +import net.minecraft.client.Minecraft; + +import zone.moddev.mc.companion.CompanionWidgets; +import zone.moddev.mc.lib.gui.client.WidgetAccess; +import zone.moddev.mc.lib.gui.client.WidgetInstance; +import zone.moddev.mc.lib.gui.layout.Rect; + +/** Sends a structured lane-and-state action, validated against this element's configured lane. */ +final class LaneSelector implements WidgetInstance { + private final WidgetAccess access; + private boolean focused, unconfirmed; + + LaneSelector(WidgetAccess access) { + this.access = access; + } + + private Rect option(boolean paused) { + var r = access.bounds(); + int w = (r.width() - 4) / 2; + return new Rect(r.x() + (paused ? w + 4 : 0), r.y() + 12, w, 18); + } + + private void choose(boolean paused) { + if (!access.pending()) + access.submit( + CompanionWidgets.SET_LANE, + new CompanionWidgets.LaneSetting( + Integer.parseInt(access.element().properties().get("lane")), paused)); + } + + @Override + public void render(Context context) { + var r = context.bounds(); + context + .graphics() + .drawString( + Minecraft.getInstance().font, + WidgetDrawing.text(unconfirmed ? "unconfirmed" : "lane_state"), + r.x(), + r.y(), + 0xff404040, + false); + boolean paused = Boolean.TRUE.equals(access.value()); + WidgetDrawing.button(context.graphics(), option(false), "run", !paused, !access.pending()); + WidgetDrawing.button(context.graphics(), option(true), "pause", paused, !access.pending()); + if (focused) context.graphics().fill(r.x(), r.bottom() - 1, r.right(), r.bottom(), 0xfff5d477); + } + + @Override + public boolean focusable() { + return true; + } + + @Override + public void setFocused(boolean value) { + focused = value; + } + + @Override + public Click mouseClicked(double x, double y, int button) { + if (button == 0) { + if (option(false).contains(x, y)) choose(false); + else if (option(true).contains(x, y)) choose(true); + } + return Click.CONSUME; + } + + @Override + public boolean keyPressed(int key, int scan, int modifiers) { + if (key == 263 || key == 262) { + choose(key == 262); + return true; + } + if (key == 32 || key == 257) { + choose(!Boolean.TRUE.equals(access.value())); + return true; + } + return false; + } + + @Override + public void actionResult(boolean accepted) { + unconfirmed = !accepted; + } +} diff --git a/src/companion/java/zone/moddev/mc/companion/client/NameEditor.java b/src/companion/java/zone/moddev/mc/companion/client/NameEditor.java new file mode 100644 index 00000000..8fcd25dd --- /dev/null +++ b/src/companion/java/zone/moddev/mc/companion/client/NameEditor.java @@ -0,0 +1,176 @@ +package zone.moddev.mc.companion.client; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.network.chat.Component; + +import zone.moddev.mc.companion.CompanionWidgets; +import zone.moddev.mc.lib.gui.client.WidgetAccess; +import zone.moddev.mc.lib.gui.client.WidgetInstance; +import zone.moddev.mc.lib.gui.layout.Rect; + +/** Vanilla text editing supplies selection, cursor movement and clipboard shortcuts. */ +final class NameEditor implements WidgetInstance { + private final WidgetAccess access; + private final EditBox field; + private boolean dirty, syncing; + private String status = ""; + private int selectionAnchor; + private boolean selecting; + + NameEditor(WidgetAccess access) { + this.access = access; + field = + new EditBox( + Minecraft.getInstance().font, access.bounds().width(), 18, WidgetDrawing.text("name")); + field.setMaxLength(32); + field.setResponder( + value -> { + if (!syncing) { + dirty = true; + status = ""; + } + }); + } + + private void position() { + var r = access.bounds(); + field.setX(r.x()); + field.setY(r.y()); + field.setWidth(r.width()); + } + + private Rect applyBounds() { + var r = access.bounds(); + return new Rect(r.x(), r.y() + 22, 44, 18); + } + + private Rect cancelBounds() { + var r = access.bounds(); + return new Rect(r.x() + 48, r.y() + 22, 44, 18); + } + + private void reset() { + if (access.value() instanceof Component value) { + syncing = true; + if (!field.getValue().equals(value.getString())) field.setValue(value.getString()); + syncing = false; + dirty = false; + } + status = ""; + } + + @Override + public void render(Context context) { + position(); + if (!dirty && !access.pending()) reset(); + field.setEditable(!access.pending()); + field.render(context.graphics(), context.mouseX(), context.mouseY(), context.partialTick()); + WidgetDrawing.button(context.graphics(), applyBounds(), "apply", false, !access.pending()); + WidgetDrawing.button(context.graphics(), cancelBounds(), "cancel", false, !access.pending()); + String message = access.pending() ? "waiting" : status; + if (!message.isEmpty()) + context + .graphics() + .drawString( + Minecraft.getInstance().font, + WidgetDrawing.text(message), + access.bounds().x() + 96, + access.bounds().y() + 27, + 0xff883333, + false); + } + + private void apply() { + if (access.pending()) return; + if (field.getValue().isBlank()) { + status = "name_required"; + return; + } + access.submit(CompanionWidgets.RENAME, field.getValue()); + } + + @Override + public boolean focusable() { + return true; + } + + @Override + public void setFocused(boolean focused) { + field.setFocused(focused); + } + + @Override + public Click mouseClicked(double x, double y, int button) { + position(); + if (button != 0) return Click.CONSUME; + if (applyBounds().contains(x, y)) apply(); + else if (cancelBounds().contains(x, y)) { + if (!access.pending()) reset(); + } else if (!access.pending() && field.mouseClicked(x, y, button)) { + selectionAnchor = field.getCursorPosition(); + selecting = true; + return Click.CAPTURE; + } + return Click.CONSUME; + } + + @Override + public boolean keyPressed(int key, int scan, int modifiers) { + if (key == 256) { + if (!dirty || access.pending()) return false; + reset(); + return true; + } + if (key == 257 || key == 335) { + apply(); + return true; + } + return !access.pending() && field.keyPressed(key, scan, modifiers); + } + + @Override + public boolean charTyped(char character, int modifiers) { + return !access.pending() && field.charTyped(character, modifiers); + } + + @Override + public void resized() { + position(); + } + + @Override + public boolean mouseDragged(double x, double y, int button, double dx, double dy) { + if (selecting) { + field.onClick(x, y); + field.setHighlightPos(selectionAnchor); + } + return true; + } + + @Override + public boolean mouseReleased(double x, double y, int button) { + boolean handled = selecting; + selecting = false; + return handled; + } + + @Override + public void cancelInteraction() { + selecting = false; + } + + @Override + public void visibilityChanged(boolean visible) { + if (!visible) reset(); + } + + @Override + public void actionResult(boolean accepted) { + if (accepted) reset(); + else { + dirty = true; + status = "unconfirmed"; + } + } +} diff --git a/src/companion/java/zone/moddev/mc/companion/client/ReserveSlider.java b/src/companion/java/zone/moddev/mc/companion/client/ReserveSlider.java new file mode 100644 index 00000000..3b152b8a --- /dev/null +++ b/src/companion/java/zone/moddev/mc/companion/client/ReserveSlider.java @@ -0,0 +1,120 @@ +package zone.moddev.mc.companion.client; + +import net.minecraft.client.Minecraft; + +import zone.moddev.mc.companion.CompanionWidgets; +import zone.moddev.mc.lib.gui.client.WidgetAccess; +import zone.moddev.mc.lib.gui.client.WidgetInstance; + +/** Drag previews stay local; only release commits. Arrow keys and scroll commit one step. */ +final class ReserveSlider implements WidgetInstance { + private final WidgetAccess access; + private int preview; + private boolean dragging, focused, unconfirmed; + + ReserveSlider(WidgetAccess access) { + this.access = access; + } + + private int confirmed() { + return access.value() instanceof Number value ? value.intValue() : 0; + } + + private void at(double x) { + var r = access.bounds(); + preview = (int) Math.clamp(Math.round((x - r.x() - 3) * 100 / (r.width() - 6)), 0, 100); + } + + private void commit() { + access.submit(CompanionWidgets.RESERVE, preview); + } + + @Override + public void render(Context context) { + if (!dragging && !access.pending()) preview = confirmed(); + var r = context.bounds(); + var g = context.graphics(); + g.drawString( + Minecraft.getInstance().font, + WidgetDrawing.text(unconfirmed ? "reserve.unconfirmed" : "reserve", preview), + r.x(), + r.y(), + 0xff404040, + false); + g.fill(r.x(), r.y() + 15, r.right(), r.y() + 28, focused ? 0xfff5d477 : 0xff444444); + g.fill(r.x() + 1, r.y() + 16, r.right() - 1, r.y() + 27, 0xff727272); + int knob = r.x() + 3 + (r.width() - 6) * preview / 100; + g.fill(knob - 2, r.y() + 16, knob + 2, r.y() + 27, access.pending() ? 0xffaaaaaa : 0xff66cc88); + } + + @Override + public boolean focusable() { + return true; + } + + @Override + public void setFocused(boolean value) { + focused = value; + } + + @Override + public Click mouseClicked(double x, double y, int button) { + if (button != 0 || access.pending()) return Click.CONSUME; + unconfirmed = false; + dragging = true; + at(x); + return Click.CAPTURE; + } + + @Override + public boolean mouseDragged(double x, double y, int button, double dx, double dy) { + at(x); + return true; + } + + @Override + public boolean mouseReleased(double x, double y, int button) { + if (dragging) { + at(x); + dragging = false; + commit(); + } + return true; + } + + @Override + public void cancelInteraction() { + dragging = false; + preview = confirmed(); + } + + @Override + public boolean keyPressed(int key, int scan, int modifiers) { + if (key != 263 && key != 262 && key != 268 && key != 269) return false; + if (access.pending()) return true; + preview = + key == 268 + ? 0 + : key == 269 + ? 100 + : Math.clamp( + confirmed() + (key == 262 ? 1 : -1) * ((modifiers & 1) != 0 ? 10 : 1), 0, 100); + commit(); + return true; + } + + @Override + public boolean mouseScrolled(double x, double y, double dx, double dy) { + if (!access.pending() && dy != 0) { + preview = Math.clamp(confirmed() + (dy > 0 ? 1 : -1), 0, 100); + commit(); + } + return true; + } + + @Override + public void actionResult(boolean accepted) { + unconfirmed = !accepted; + preview = confirmed(); + } +} diff --git a/src/companion/java/zone/moddev/mc/companion/client/WidgetDrawing.java b/src/companion/java/zone/moddev/mc/companion/client/WidgetDrawing.java new file mode 100644 index 00000000..a2b55ae2 --- /dev/null +++ b/src/companion/java/zone/moddev/mc/companion/client/WidgetDrawing.java @@ -0,0 +1,29 @@ +package zone.moddev.mc.companion.client; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; + +import zone.moddev.mc.lib.gui.layout.Rect; + +final class WidgetDrawing { + private WidgetDrawing() {} + + static Component text(String name, Object... arguments) { + return Component.translatable("screen.mmdlib_companion." + name, arguments); + } + + static void button(GuiGraphics g, Rect r, String label, boolean selected, boolean enabled) { + g.fill(r.x(), r.y(), r.right(), r.bottom(), selected ? 0xff66bb88 : 0xff444444); + g.fill(r.x() + 1, r.y() + 1, r.right() - 1, r.bottom() - 1, enabled ? 0xff727272 : 0xff505050); + var font = Minecraft.getInstance().font; + var text = text(label); + g.drawString( + font, + text, + r.x() + (r.width() - font.width(text)) / 2, + r.y() + 5, + enabled ? 0xffffffff : 0xffaaaaaa, + false); + } +} diff --git a/src/companion/resources/assets/mmdlib_companion/lang/en_us.json b/src/companion/resources/assets/mmdlib_companion/lang/en_us.json index d4077812..49849e3d 100644 --- a/src/companion/resources/assets/mmdlib_companion/lang/en_us.json +++ b/src/companion/resources/assets/mmdlib_companion/lang/en_us.json @@ -27,6 +27,23 @@ "block.mmdlib_companion.dual_crusher": "Dual Crusher", "block.mmdlib_companion.creative_power": "Creative Power Source", "screen.mmdlib_companion.energy.tip": "{value} / {max} FE", + "screen.mmdlib_companion.settings": "Settings", + "screen.mmdlib_companion.settings.tip": "Edit the machine name, energy reserve and lane state.", + "screen.mmdlib_companion.name": "Machine name", + "screen.mmdlib_companion.apply": "Apply", + "screen.mmdlib_companion.cancel": "Cancel", + "screen.mmdlib_companion.waiting": "Waiting...", + "screen.mmdlib_companion.name_required": "Enter name", + "screen.mmdlib_companion.unconfirmed": "Retry edit", + "screen.mmdlib_companion.reserve": "Energy reserve: %s%%", + "screen.mmdlib_companion.reserve.unconfirmed": "Reserve: %s%% (retry)", + "screen.mmdlib_companion.reserve.waiting": "Energy below %s%% reserve: supply power or lower the reserve.", + "screen.mmdlib_companion.lane_state": "Lane A", + "screen.mmdlib_companion.run": "Run", + "screen.mmdlib_companion.pause": "Pause", + "screen.mmdlib_companion.progress_dial": "Progress: {value} / {max} ticks. Click to pause or resume.", + "screen.mmdlib_companion.recipes": "Recipes", + "screen.mmdlib_companion.recipes.tip": "Show crushing recipes in JEI, when installed.", "screen.mmdlib_companion.tank.tip": "Water or lava: {value} / {max} mB. Click with a fluid container to transfer.", "screen.mmdlib_companion.tank.refused": "Cannot transfer fluid. The tank holds water or lava, one type at a time, up to 4 buckets.", "screen.mmdlib_companion.info.crusher": "*Crusher*\n\nCrush ore into raw materials, or stone into gravel and sand.\n\nPlace a power source beside the machine. Pause to release reserved items.\n\nThe left tank holds 4 buckets of water or lava for display. Click the gauge with a fluid container on the cursor to fill or drain it, or right-click the block with a bucket. Empty the tank before changing fluids. Crushing does not use the fluid.", diff --git a/src/companion/resources/mmdlib/screen/crusher.xml b/src/companion/resources/mmdlib/screen/crusher.xml index 3f7d7369..d535146e 100644 --- a/src/companion/resources/mmdlib/screen/crusher.xml +++ b/src/companion/resources/mmdlib/screen/crusher.xml @@ -1,18 +1,32 @@ + + - + + + +