Client Screens
The mod's client screens all live under com.iafenvoy.mxt.screen: container menus in screen.menu, their screens in screen.gui, purely client-side information screens in screen.information, the item picker in screen.picker, directional selection (the twelve-sector wheel) in screen.wheel, and the HUD elements in screen.hud (the framework), screen.resourcebar (resource bars) and screen.wheel (the wheel grid). A new screen should reuse the existing Screen base classes and vanilla components first rather than writing its own scrolling and text input.
The Draggable HUD Framework screen.hud
A HUD element that a module draws itself becomes movable and persistent by plugging into this framework. The framework does four things: it keeps a register of elements, draws them every frame, gives the editor hit testing and placeholders, and writes positions into the client config.
An element neither computes screen coordinates nor decides where it is drawn. The sub-area (the two resource-bar columns, say) only supplies objects: it describes what it currently has to show as a list of RenderBlocks (a size plus a way to draw each one), and the framework's single renderer HudRenderer stacks them inside the element's rectangle - the vertical position has exactly one implementation, so a container's size and its contents can never disagree; ask for a gap with RenderBlock.spacer rather than adding it to a block's height. An element that has to draw itself as one piece takes the other door: renderBlocks() returns an empty list and the framework calls render() instead.
public final class MyBar extends AbstractHudEntry {
public MyBar() {
// The layout key is both the storage key in the config and the duplicate-registration check:
// one per element, and never renamed across versions.
super("my_bar", WIDTH, HEIGHT);
}
@Override public String displayName() { return Component.translatable("hud.mxt.my_bar").getString(); }
@Override public int layoutWidth() { return WIDTH; }
@Override public int layoutHeight() { return HEIGHT; }
@Override public int defaultOffsetX() { return 4; }
@Override public int defaultOffsetY() { return 4; }
// Description only: a backdrop and a value. Where they land is the framework's business.
@Override
public List<RenderBlock> renderBlocks() {
return List.of(
RenderBlock.fill(WIDTH, 6, 0xFF10131D),
RenderBlock.label(Component.literal("42 / 100"), 0xFFFFFFFF));
}
}
// Register once from client setup (FMLClientSetupEvent); the instance comes back for keeping a reference.
MyBar bar = HudManager.register(new MyBar());Worth knowing:
- A position is stored as a window anchor plus a pixel offset.
config/mxt/mxt-hud.jsonholds aversionand alayouttable, and each entry is an object:{"anchor": {"horizontal": "center", "vertical": "bottom"}, "offset_x": -55, "offset_y": -47, "visible": true}. The anchor is one of the window's eight points (the four corners and the four edge midpoints, written as an object of two axes rather than as one assembled string) and the offset is the distance in pixels from that window point to the same point of the element, so a layout survives a change of resolution or GUI scale and a drag never loses a pixel to ratio rounding. What is drawn is clamped into the window on every read, so an element can never be dragged out of sight, and clamping does not rewrite what is stored: shrinking the window pins the element to the edge and growing it back returns it. Theversionis3: the oldhud.layout_v2form ofx,y,visiblestrings is no longer read and those layouts fall back to their defaults. - The HUD layout stays out of the config framework: it is a JSON file of the mod's own (
config/mxt/mxt-hud.json, read and written byHudLayoutFile), one object per element, written on every drag step; the rest of the config still lives underconfig/mxt/(mxt-client.json,mxt-server.json). - The anchor (
HudAnchor) is one of the window's eight points (four corners and four edge midpoints) and answers two questions at once: which window point the offset is measured from, and which point of the element stays still when it resizes. The default is the top-left corner; something that grows upward (a resource-bar column) usesCENTER_BOTTOM, and the wheel's grid usesLEFT_CENTER. Dropping an element on an anchor square binds it to that anchor: binding does not move the element, it only recomputes the offset against the new point, so nothing jumps as the drag crosses one and the element then follows that point when the window changes. - A change is saved immediately. Dragging, arrow-key nudging and toggling visibility all write the layout file at once. Nothing is flushed when a screen closes, so a crash cannot lose a layout - and equally, editing the JSON by hand needs a client restart, because an element reads its position once, when it is constructed.
- A position has exactly one source: if the file holds this layout key, its anchor and pixel offsets are used (nothing needs re-deriving when the window changes: both numbers are absolute); if it does not, the element's own default is used -
defaultAnchor()withdefaultOffsetX()/defaultOffsetY(), re-read every frame so it follows the window.resetToDefault()deletes the key from the file - a reset that only lasted until the next launch would read as "the layout was not saved". (An earlier version kept a second boolean meaning "the position in use is the stored one"; it started outfalseand only a drag ever set it, so the value in the file was never applied and every restart fell back to the default. One fact, one field.) moveableEntries()hands back everymoveable()element, hidden ones included: the editor draws those faintly and their checkbox in the top-right corner is what brings them back, so filtering them out here would make them unreachable. An element that computes its own position and should not be dragged (a centred hotbar, say) overridesmoveable() { return false; }and is still drawn by the framework; if such an element also sizes itself from world state, it must overriderefreshPlacement()and call the baseplaceAtDefault()so the default position is actually applied - computing a size without placing anything leaves the anchor at(0,0)and draws the element in the top-left corner of the window.- The element owns its size: one whose width follows its content calls
setSize(w, h)when that changes. An element with nothing to draw still needs a non-zero size, or the player cannot see it in the editor and therefore cannot place it. - Register from client setup, not from the first rendered frame: the framework's GUI layer only draws inside a world, so an editor opened from the main menu would otherwise show an empty register.
- The editor is opened by
HudManager.openEditor()(thekey.mxt.hud_layoutkeybind, rightShiftby default, or the client command/hud open). Drag with the left button; dropping an element on one of the eight anchor squares along the window border binds it there and draws a line from that anchor to the element (the line belongs to the selected or dragged element alone, while all eight squares are always drawn); the checkbox inside an element's top-right corner toggles whether it is drawn (a hidden element stays faint in the editor and its checkbox stays opaque); the arrow keys nudge by one pixel (Shift: ten),Escapedrops the selection and clicking empty space clears it. The selection survives releasing the button, so the arrow keys and the connecting line stay usable. There is no scaling yet: this port only moves and binds elements, so KronHUD's corner-drag resizing and snapping guides are not part of it. - Elements keep drawing for real while the editor is open; the editor only adds a translucent rectangle and a name label, so what is being dragged is the actual element.
- The client command
/hudprints each element's layout key, the anchor it is bound to, position, size, block count and visible/movable flags. "Nothing is registered" and "registered but it has nothing to draw this frame" look identical on screen, and this is the only thing that tells them apart.
Five elements are registered today: four resource bars - the two movables (resource_bars.left / resource_bars.right) and two fixed rows (resource_bars.target / resource_bars.boss) - plus the wheel's grid (wheel.selection, screen/wheel/WheelSelectionEntry, which returns no blocks and draws its own four-column grid, one row per three cells of a page, growing downward: 94 wide, as tall as its contents, cell numbers in reading order, the cell the chosen number stands for drawn with the gold selected frame, and a cell on cooldown carrying the vanilla item-cooldown sheet (a 50% white sheet over the remaining fraction of the icon box, draining downward; the remainder comes from the entry's cooldownTicks and the whole length from its cooldownLength), while a cell that is unusable for any other reason is washed over. It is the first example of the "draws itself" branch, and also the first whose size follows its contents (layoutWidth / layoutHeight are computed per frame and refreshPlacement reports the change with setSize, so a block that grew past the window is pulled back in); its default place is against the left edge of the window, halfway down; it draws the whole wheel - every cell of every page - and writes no label of its own). The resource bars are a good template — ResourceBarOverlay.column(anchor) / row(target, layout) answer only "which bars, in what order", and an entry turns them into blocks with ResourceBarEntry.blocksWithGaps(...), which also inserts a spacer between bars. No position field exists on the data-pack side: anchor in resource.bars only chooses which column a bar belongs to, and where those columns sit is the player's own setting in the client config; the two fixed rows are pinned to the entity under the crosshair, computed every frame and never stored. The resource bars' own mxt:resource_bars GUI layer has been deleted - everything goes through the framework's layer. The framework's own trade-offs are recorded in the repo's research/26_可拖动HUD框架设计.md, and the changes to the wheel grid in research/27_轮盘选择系统设计.md §8 and research/31_多轮盘与轮盘来源设计.md §10.4.
The Wheel Menu screen.wheel
Hold the bound key (key.mxt.wheel, R by default) and a twelve-sector wheel appears: the direction the pointer is in picks the sector, the pointed-at sector darkens and grows a little, and that sector's name and tooltip are drawn in the middle of the wheel. It is the only way an ability or a spirit power is triggered - artifact abilities are abilities - and the two hotbars and their separate editors are gone (research/28_技能与灵气归一化设计.md).
The wheel is a main wheel plus pages read from what is carried, strung together by one continuous cell numbering (redone to the player's own wording - see research/31_多轮盘与轮盘来源设计.md §10): the main wheel is the twelve cells the player arranges (numbers 0..11), and the pages behind it (main hand / off hand / artifacts / contract beast) are generated from the gear and the Beast Taming Bell in hand and never stored (their contents are the active abilities that gear grants right now, plus the switches it declares as an artifact; the contract beast page holds the orders the tuned beast takes), numbered from 12 on. A page is twelve cells, so a source takes ceil(entries / 12) pages and none at all while it holds nothing - "one is not enough, open another". Turning pages is two keys (key.mxt.wheel_previous / key.mxt.wheel_next, the left / right arrow keys by default, wrapping at both ends by default - Client Settings → Wheel Menu → Wrap Pages decides whether they come back around or park at either end), and R always opens the main wheel (the first page). A page is a view and the number is the choice: the ring draws the current page, the HUD grid draws the whole wheel, the twelve slot keys act on the current page, and the use key while the wheel is down acts on whatever cell the number stands for now (a number addressing a cell that does not exist falls back to the last cell holding anything, a number landing on an empty cell falls back to the first cell holding anything, and is never rewritten).
The framework (geometry, open/close state machine, selection semantics) is screen.wheel; the ring and its animations are drawn by an ApricityUI page (assets/apricityui/apricity/mxt/wheel/, shipped inside the mod jar and read straight from the resource pack); the contents (how an ability or an aura becomes an entry, what each source contributes) are screen.wheel/content, and the editor is WheelConfigurationScreen. The contract, the stored layout, the numbering and paging, and the trigger dispatch are on the Wheel Entries page; what belongs here is the framework's own rules:
- The framework does not decide what is on the wheel.
WheelMenuProvideranswers "what does this source contribute right now", and it is registered per source id (WheelMenuContent.register(source, provider), withWheelContentregistering one shared instance for all five built-in pages from client setup, so a content mod adds a page of its own instead of replacing them). It is given the player and anapi/WheelSource(an instance registered inruntime/wheel/WheelSourceTypes, no longer an enum; a source answersid()/displayName()/configured()/grantSources(entity)/equipment(entity)/offers(entity, kind, id)), and its answer may be longer than a page, whichWheelMenuContentpages. The entry contract isWheelMenuEntry(kind/id/title/icon/tooltip/cooldown/usable/onSelected). This replaced the framework's first design, a static registry of twelve slots: the contents are the player's own layout now, and a second way in would only compete with it for cells. - The layout lives on the server: the
wheel_layoutplayer attachment holds oneWheelLayoutof twelveWheelSlots (a kind plus an id, where the kind is stored and read by id too, with anEMPTYsentinel for an empty cell, the main wheel only) plus anarmedfield holding the chosen cell number (Optional<Integer>). Closing the editor sends aWheelLayoutC2SPayload, andWheelService.sanitizeforces the size to twelve and asks each cell's kind whether the idexistsbefore storing it. The number travels onWheelSelectionC2SPayload, restored at login and sent when it changes byscreen/wheel/content/WheelSelectionSync- see Wheel Entries for the details. - One fact, one place: the pointer-direction-to-sector mapping lives only in
WheelGeometry(sectorStart/sectorCentre/sectorAtare derived from the same constants, sosectorAt(sectorCentre(k)) == kalways holds). Drawing, hit testing and icon placement all ask it. MineMenu recomputed that mapping in three places, and one of the three used a different angle convention from the other two. - Direction decides, distance never does. A pointer still inside the inner hole counts, which is what lets the middle of the wheel name the pointed-at sector the whole time.
- The sector count is
WheelLayout.SLOTS(twelve) and the geometry takes it from there, so there is no way to end up with twelve sectors of geometry and ten slots of content. - Empty cells are drawn as faint placeholders that never react to the pointer and never put text in the middle; with the whole wheel empty the key says "Nothing is on the wheel yet" instead of opening.
- Choosing and using are two keys:
key.mxt.wheel(Rby default) only chooses - hold to open, the pointer picks the cell, and letting go (mode = HOLD) or pressing again (TOGGLE) only closes, triggering nothing;key.mxt.wheel_use(Vby default) does the using - while the wheel is up it spends the pointed cell and leaves the wheel standing, and while it is down it spends the cell the number stands for now (client-sideWheelSelectionState, which holdspageplusnumber;LoggingInclears it and thearmednumber the server stored is then put back, see Keeping the selection). A left click is the same.WheelSelection.MethodisKEY/CLICKand only says whether the request came from the keyboard or the mouse. The "Wheel Menu" client settings now hold three entries:mode(hold / toggle),scroll_switch(scroll turns pages, on by default) andwrap_pages(pages wrap around, on by default);release_to_selectwas deleted with this revision. - Using does not close the wheel, so one hold can spend several cells; what takes the wheel down is the wheel key alone.
- The keys cannot be read through
KeyMapping#isDown():Minecraft#setScreencallsKeyMapping.releaseAll()as soon as a screen opens, so the release that closes the wheel would be seen on the very frame it appeared. There is a second reason, about the use key: releasing the wheel key while still holdingVmakesMouseHandler#grabMouse()callKeyMapping.setAll(), which setsVdown again from the physical key and would report one press twice. The controller therefore reads the wheel key, the use key and both switch keys raw throughInputConstants/GLFW, with one edge detector per key, and theKeyMappings exist so the keys show up in the controls screen. The cost is one client tick of resolution. - It is a
Screen, not a GUI layer: opening a screen is what makes vanilla release the mouse (so the pointer can pick a direction, and the angle is scale invariant), and closing it is what grabs the cursor back. It is not a pause screen, and it draws no background - the default background blurs every stratum extracted before it, which is the whole HUD. - The radii and the fit-to-window scale are constants (
WheelGeometry; the ring shrinks as a whole in a small window), while the colours and the animation timings live in the page's CSS: the whole ring is greyscale and the pointed sector is a shade darker; the HUD grid's selected frame is still a gold sprite. - Holding the wheel stops you walking: vanilla calls
KeyMapping.releaseAll()for anyScreen, so the movement keys are released as well (letting go callssetScreen(null)→grabMouse(), which syncs the physical key state back, so nothing has to be pressed again). Opening a wheel while running would mean a GUI layer that handles the pointer itself.
The Artifact Storage Window MxtMenus.ARTIFACT_STORAGE
This is what the "Storage" cell on the wheel opens (see research/32_法器开关与轮盘接线设计.md): it has no menu class and no screen class of its own - it is the vanilla chest, so the menu type registers a plain ChestMenu and the client registers ContainerScreen (the generic_54.png sheet already draws one to six rows). The width is the usual 176, the row count is getRowCount(), and the title travels with the open packet (screen.mxt.artifact_storage, "Storage · artifact name"). The row count is the extra data of an IMenuTypeExtension menu type (the server writes capacity / 9), and the client rebuilds a SimpleContainer mirror of the same size; the slots themselves arrive through the menu sync as usual.
The contents are runtime/artifact/ArtifactStorageContainer (a SimpleContainer subclass): it loads them out of the carrier's mxt:storage component when the screen opens, where they are one mxt:container entry addressed by that storage ability's own id (the mxt:artifact_storage component is gone), and writes the whole list back from setChanged() on every change (ArtifactStorageService#replace - one component update rather than one per slot). It deliberately holds no stack: an artifact moves, and writing into a stack nobody carries is the classic way items disappear - so every read and write re-resolves "is this artifact still on the player" (ArtifactService#carried walks both hands, the inventory and the Curios slots), and once stillValid is false the server's per-tick menu check closes the window. The slot count comes from the definition: rounded up to whole rows of nine, at most six rows (54 slots), so the capacity and the window are always the same number.
The Furnace's Four Screens
Four screens, opened by the core, the main bin, the auxiliary bin and the output bin; a wall opens nothing. The one open path is AlchemyFurnaceMenus.open(ServerPlayer, BlockPos): the block hands over the position it was used at, and the call looks at the block there to pick the screen (core = monitor, main bin = main, auxiliary bin = auxiliary, output bin = output); any other block opens nothing.
The layout is not in Java: each screen has an HTML page shipped with the mod plus a shared stylesheet - monitor.html, main_input.html, auxiliary_input.html, output.html and alchemy.css, drawn by ApricityUI and skinned by a flat translucent dark stylesheet shipped with the mod, with text drawn in the game's own font (a page that sets no font-family gets it). Java only takes nodes by their stable ids and then binds state and events (the slots are not its business - see the next sentence); it draws no layout tree and has no fallback tree. All four pages must have panel, title, inventory_label and player_inventory; the three store pages also have machine, and the monitor page has no machine (the heat comes from a cell in the world) but adds temperature, limit, status, progress (the bar itself is progress_fill), target, apply, start and abort. machine declares that page's machine slot count (2 on main, 3 on auxiliary, 4 on output) and player_inventory carries the 36 player slots. When a required node is missing, that screen drops its page bindings and draws nothing at all, leaving a single WARN in the log: Page <name> does not match its contract: <missing piece> (the slots are bound by ApricityUI from the menu's layout, so they stay clickable regardless) - rearranging the visuals is free, these ids and the two containers are not. The slots belong to ApricityUI: a page <slot> is bound to its menu slot through its ancestor <container id> and its group-local slot-index, Slot.x/y is written back every frame, and the items, the hover highlight, dragging and the slot tooltips are all its work; all that is left on this side is reading the panel's live rectangle, which feeds the screen's own hit tests and hasClickedOutside. Do not put an aui-mouse-events meta on a page - the page would then swallow clicks and its slots could no longer be clicked. To fill the viewport and center a panel yourself, use viewport units (height: 100vh): a percentage height on body does not apply in ApricityUI, and height: 100% leaves the panel stuck to the top. A host screen draws the vanilla translucent grey plate first in extractBackground (AuiStyles.extract, the same gradient the vanilla container screens use) and only then submits the page; do not call super.extractBackground - a plain Screen host answers false to isInGameUi(), so the super call takes the blurred menu-background branch and blurs the HUD along with the world. The wheel menu is the one exception: it is read while playing, so its extractBackground stays empty and both the world and the HUD keep their full brightness.
The pages ship inside the mod jar at assets/apricityui/apricity/mxt/alchemy/, and every open reads them straight from the resource pack. Editing a page therefore means editing it in the jar (or the resource pack): with hot reload on, saving is enough; otherwise press END in game to rescan resources and reopen the furnace screen. <gameDir>/apricity/ is only the player's override layer - a file of the same name there wins over the jar.
The other container screens
The forge table, the spirit crafting table, the economy group (the trade station's customer and owner pages, the exchange station, the cheque table and the player trade screen) and the player information panel work the same way: the page draws the panel, the text, the selector cells and the meter (assets/apricityui/apricity/mxt/forging/, assets/apricityui/apricity/mxt/spirit_crafting/, assets/apricityui/apricity/mxt/economy/ and assets/apricityui/apricity/mxt/information/, sharing assets/apricityui/apricity/mxt/common/theme.css), while the slots are bound by ApricityUI - a <slot> in the page sits where the recessed cell is drawn, and ApricityUI paints the item one pixel inside it (the offset comes from slot > item in its global.css). These pages ship inside the jar and are read straight from the resource pack, exactly like the furnace's, and nothing is written into the game directory. The player trade screen can be checked by one person in a development build: point /trade at yourself and it opens a trade with yourself straight away (one player on both sides, one confirm counts for both); a production build still refuses that.
Three rules if you edit these pages: do not put border on the panel (a page positions its children against the panel's content box, so a border shifts every coordinate in the page; draw the frame with box-shadow: inset instead); do not clip with overflow: hidden (in game it paints a dark block); and give a container primary="true", or repeated cells are not expanded and the slot numbering is not normalised. The information panel shrinks with the window, so its position and size are computed each time the screen opens rather than written into the page; the two tabs at its top left (Character Info and Learned Techniques) switch between its two pages, which share one panel and carry no separate title line.
One more kind of screen makes the whole window the page: the structure preview behind the formation /formation show (assets/apricityui/apricity/mxt/multiblock/). The page draws both overlay bars, the title, the layer line, the hint, the playback controls and the timeline, while the 3D scene is still submitted by Java as a picture-in-picture state into the rectangle the page holds open - so that rectangle has to stay transparent. The page carries no coordinates at all: Java writes the boxes for the scene, the keys and the timeline as the window resizes (and rewrites them all when it does), the keys take their hover and pressed states from CSS, and only the two things that need the pointer's position - clicking the timeline to jump to a layer, and dragging inside the scene to turn the view - are still handled by Java.
The monitor screen paints the server-sent read-only reading (AlchemyStateS2CPayload, see Network Protocol and Server Authority), so the screen never evaluates a recipe and shows neither a recipe name nor recipe buttons; the temperature field, start and abort only send those three things back to the server. The player_inventory and machine slots on the pages are real slots, and the machine slots live in the same menu as the player inventory; the monitor page has no machine slot.
The Item Picker
The picker is not in this mod: the screen, the /picker command, the open packet and the catalogue reading all belong to the Picker mod (bundled here through jarJar). This mod only registers "which registry maps to which rows", in data/PickerCategories, once from the mod constructor. The minecraft:item and minecraft:block categories are the Picker's own built-ins, not ours.
A row is a PickerRow(stack, names): the stack to draw, plus the names that row can be found by. The stack itself is left alone - nothing is written onto it (no custom name, no suffix) - so when one stand-in item represents several definitions, the search tells them apart rather than a mark on the name.
- An item or block registry's entry is the item itself, so the stack already says what it is: the names are "what it displays as" plus "its registry id";
- A data-driven definition has no item of its own and the stack says nothing about which definition it stands for, so the name comes from
DefinitionTextbuilding a translation key out of itsHolder/ResourceKey-mxt:fireinmxt:auralooks upmxt.aura.mxt.fire- followed by its id. Definitions have noname/descriptionfield: names and descriptions are always computed from the registry id (the description is the name key plus.description); - A row expanded from a tag entry carries both the item's own name and the name and id of the definition it belongs to.
It is a list rather than one name because a row can be known by more than one: both the definition's name and its id should be findable.
A category is one registry, and /picker <category id> lists just that one (/picker mxt:aura, /picker mxt:artifact, /picker mxt:currency, /picker mxt:item_binding), or every registered category when the argument is omitted. The order is the registration order: the Picker's two built-ins first, then everything this mod registers in the table above; within a category the rows follow the registry's own order.
Another mod that wants the same rows (to fill its own creative tab, say) uses the Picker's PickerContents: every query takes the registry access from the caller (itemsOf(Provider, key)), the itemsOf* family gives one row per definition without de-duplicating, and the stacksOf* family de-duplicates by the vanilla creative tab's rule (same item and components is one stack) and returns copies.