Repository navigation
Screens
What this page covers: the screen framework in br.com.finalcraft.evernifecore.screen (:common) - a
Screen described once and drawn by whichever renderer serves the player, its elements (Button, Label,
PagedList, InputField, ProgressBar, Choice), click rules over Gate and effects, reactivity, the
ScreenRenderer SPI, the four override layers of Screens.open(player, key, request), the core's default
screens, and ScreenWorld for tests. The last part, The chest renderer, is the Bukkit
side (minecraft.screen, :minecraft): ChestKind, editable areas, ItemStore and StoredInventory.
A Screen is a description: a grid, a title, elements on slots. It owns no window. Each player who opens it
gets a ScreenView - their own copy of every state, cycle position and page - drawn by a ScreenRenderer
chosen for that player and that screen. On Bukkit that renderer is the chest (evernifecore:chest); another
platform, or a client mod, plugs in its own without the screen changing.
flowchart LR
S["Screen<br/>(description, :common)"] -->|"open(player)"| R{"highest priority renderer<br/>that supports the player<br/>and covers the capabilities"}
R --> V["ScreenView<br/>(one per viewer)"]
V -->|"ElementPatch list, once per tick"| SF["ScreenSurface<br/>(chest, mod, page...)"]
What a slot looks like is an Icon (an ItemSpec, fallback candidates, raw item-data lines, named states,
per-language text). What a slot does is an element with a stable id. Icon carries no click and no
visibility rule; the element does.
import br.com.finalcraft.evernifecore.screen.Icon;
import br.com.finalcraft.evernifecore.screen.Layout;
import br.com.finalcraft.evernifecore.screen.Screen;
Screen<Layout> screen = Screen.of(3).title("§8Shop"); // three rows of nine
screen.icon(13, Icon.of("minecraft:diamond").displayName("§bBuy a diamond"))
.onClick(click -> click.getViewer().sendMessage("bought"));
screen.open(fPlayer); // CompletableFuture<ScreenView>-
Screen.of(int rows),Screen.of(Grid),Screen.of(MyLayout.class)/Screen.of(layoutInstance). -
icon(slot | SlotSet, Icon)answers aButton(idslot#<n>unless.id("buy")), so declare the screen in a variable and chain on the button.Slots.of/at/box/row/column/border/allbuild slot sets. -
title(String)ortitle(Supplier<String>)(re-read on every pass; only a renderer withTITLE_CHANGEshows the change). -
open(FPlayer viewer, Object... args)- the args fill{0}..{n}in every text. - Also on
Screen:key(ScreenKey),owner(ECPluginData)(the caller logic runs as),addReplacer(...),subject(IPlayerData),policy(ClickPolicy),debounce(millis)(200 ms by default),onClose(Consumer<CloseContext<L>>),onMissingCapability(ScreenFallback),addComponent(...).
The viewer is always an FPlayer: ClickContext.getViewer() and CloseContext.getViewer() answer one.
| Element | Declared by | What it is |
|---|---|---|
Button |
icon(slot, icon), icon(l -> l.FIELD)
|
a click (onClick, rule(...)), a state (state, states, cycle), visibleWhen(Predicate<ScreenView>) (or a Supplier<Boolean>), every(ticks)/render(icon -> ...), dependsOn(...)
|
Label |
label(l -> l.FIELD), label(slots, icon)
|
text only; a renderer that draws by element shows it as a label |
PagedList<T> |
list(source) |
one entry per slot of a region, paginated by the region's size; entries are <id>#<n>, arrows <id>#previous/<id>#next
|
InputField |
input(l -> l.FIELD) or input(slots, icon), then .onInput(spec, (view, value) -> ...)
|
a value typed by the viewer: every renderer asks through PromptRegistry and gives the screen back |
ProgressBar |
progress(l -> l.FIELD, State<Double>), progress(slots, icon, State<Double>)
|
a ratio 0..1; the patch carries it, and the icon text reads {progress} as a whole percentage |
Choice |
choice(l -> l.FIELD, text, gate, effects), choice(slots, icon, text, gate, effects)
|
a dialogue option: each redraw checks its Gate once - a pass draws its costs as the last lore line, a failure draws it disabled with every reason, one per line (or not at all after hiddenUntilPassed()); a name with no colour of its own reads green or grey to match. Picking it pays the gate and runs the effects - or, after pickedBy(click -> ...), hands the pick to an engine that pays it (Dialogues) |
Element ids are checked when a view opens: two elements under one id refuse the open and name both.
PagedList<Kit, MyLayout> kits = screen.list(() -> kitService.all())
.id("kits")
.into(l -> l.ENTRY) // or into(Slots.box(2, 2, 4, 8))
.pagedBy(l -> l.PREVIOUS, l -> l.NEXT) // or pagedBy(Slots.of(36), Slots.of(44)).arrows(prev, next)
.render((kit, icon) -> icon.displayName("§e" + kit.getName()))
.onClick((click, kit) -> kit.give(click.getViewer()));list(...) also takes a List<T> or a PagedList.PageSource<T> (a page read on demand, total(...) for the
count). A Pager handed to pager(pager) reads and turns the page from outside. Arrows placed on raw slots by
pagedBy(SlotSet, SlotSet) carry no icon of their own: arrows(Icon previous, Icon next) gives them one (a null
side keeps the framework's arrow; called after the layout form, it replaces the layout icons).
A click runs the button's ClickRules first, then its plain onClick. A rule is a Gate (conditions + costs,
see Logic) plus the effects of success and of denial, for the gestures it names:
screen.icon(l -> l.BUY).rule(ClickRule.on(ClickGesture.PRIMARY)
.gate(Gate.builder().require(hasLevel10).cost(fiveCoins).build())
.onSuccess(giveDiamond)
.onDeny(tellWhy));ClickGesture is PRIMARY, SECONDARY, SHIFT_PRIMARY, SHIFT_SECONDARY, MIDDLE, DOUBLE, DROP,
CONTROL_DROP, NUMBER_KEY, UNKNOWN. ClickRule.missingOf(result) is every failed condition as one line.
There is no sound(...) on the context: the stock sound effect does it.
ClickContext gives getViewer(), getGesture(), getKind(), getCursor() (ItemSpec, nullable),
getElement()/getElementId(), getSlot(), getView(), newExecutionContext(), and the moves:
| Call | Does |
|---|---|
open(screen) / open(screen, Boolean.class)
|
pushes a screen; the future answers what it hands back, and is cancelled when nobody answers |
back() / back(value)
|
pops, handing value to the opener |
replace(screen) |
swaps the top without growing the stack |
ask(PromptSpec) / ask(question, parser, onAnswer)
|
sets the screen aside, asks through the channel PromptRegistry picks, brings it back |
close(), refresh(), allowMove()
|
close, redraw, let the item move this click would make go through |
ctx.ask(PromptSpec.of("§eHow many?")
.parse(text -> {
if (!text.matches("[0-9]+")) throw new IllegalArgumentException("Not a number: '" + text + "'. Type digits only, like 250.");
return Integer.valueOf(text);
})
.prefer(PromptKind.ANVIL) // optional; null = the richest channel that serves the player
.initialText("1") // anvil/page field; chat ignores it
.icon(ItemSpec.of("minecraft:paper", 1)) // the anvil's input slot; default paper
.timeout(Duration.ofSeconds(30)) // > 0, default 60 s
.cancelWord("cancel")) // chat; closing the anvil or the page cancels too
.thenAccept(amount -> ...); // cancelled (not failed) when no value camePromptRegistry.get() keeps the question, whatever window it goes through: one per screen, the screen set aside
before the channel opens and given back (without an onClose) before the future completes, the timeout on the
screen's clock (ScreenScheduler.ticksOf), a refusal that never restarts the wait, a second question that ends the
first CANCELLED and takes the screen over, and QUIT (with onQuit, nothing given back) when the screen is
released mid-question - the player left, the chain was discarded, the server shuts down. The whole PromptResult
(VALUE, CANCELLED, TIMEOUT, QUIT) comes from PromptRegistry.get().ask(view, spec); ctx.ask hands the value.
Which channel asks: prefer(kind) when it is registered and serves the player, otherwise the first registered in
PromptKind order - PAGE, ANVIL, CHAT. None serves them: the question ends CANCELLED and the core logs how to
register a channel. Bukkit registers ANVIL (BukkitAnvilPromptChannel) and CHAT (BukkitChatPromptChannel),
Hytale PAGE (HyPagePrompt).
-
Anvil (ECNmsUtils' virtual anvil): the question is the title from 1.14 on and the icon's lore before that
(1.7.10 and 1.12.2 have no title). Clicking the result answers; a refused answer keeps the anvil open with the
parser's text as the result's name; closing it ends
CANCELLED. Repair cost is 0 and no item reaches the player's inventory. WhereNMSUtils.isSupported()is false, or the library's anvil classes do not link on that server, it does not serve the player and the question falls back to chat. - Chat: the window goes, the question is sent; a refused line never reaches public chat - the player reads why and the question again.
- Page (Hytale): a field with OK and Cancel; a refusal shows under the field; escape cancels.
A channel is dumb: getKind(), isSupported(FPlayer), open(PromptSession), dismiss(PromptSession). It reports
session.answer(typed) (a PromptReply: accepted() or refused(message)) and session.cancel() from any thread;
a session that already ended ignores both. One channel per kind - PromptRegistry.get().register(owner, channel)
refuses a second, naming both; the owner's shutdown takes it out.
MutableState<T> (get, set, update) is a value a screen draws; dependsOn(state) on a button or a list
redraws it when the value changes. Changes reach the renderer batched: two invalidations in one tick are one
ScreenSurface.apply(List<ElementPatch>), carrying only the slots that changed.
Button.dependsOn(Condition...) makes a button follow game events: while the view is open, every
EventSource the conditions declare (Condition.getNeeds()) is held needed through EventSourceUsage - so
the platform's native listener for it is switched on - and an action of that source by the viewer redraws the
button. Closing the view releases the holds. A condition with no source is asked again every sampleEvery(ticks)
(20 by default).
screen.icon(l -> l.CLAIM).dependsOn(hasTheItem) // a condition that needs item-pickup
.visibleWhen(view -> view.test(hasTheItem));visibleWhen(Predicate<ScreenView>[, ticks]) receives the view being drawn, so one screen shows an icon to one viewer
and hides it from another (view.getViewer(), view.test(condition)). It is asked once a tick, every ticks, or
only on a redraw with 0; the Supplier<Boolean> forms answer the same for every viewer.
ScreenNavigation.replace(current, next) (what ClickContext.replace runs) of a view set aside under another
screen swaps it in place: next waits set aside where current was, inheriting what it owed its opener, and the
screen on top keeps its window. A refused replacement drops the old screen from the chain and cancels its answer.
ScreenOpenEvent and ScreenCloseEvent (with its CloseReason) are posted on the core bus when someone listens.
A layout class names icons by field; the first read seeds plugins/<Plugin>/guis/<LayoutClass>.yml, which the
admin then restyles (item, name, lore, slots, per-language text) without touching code:
@ScreenLayout(title = "§8Kits", rows = 5)
public class KitsLayout extends Layout {
@IconData(slot = {10, 11, 12, 13, 14, 15, 16})
public Icon ENTRY = Icon.of("minecraft:chest");
@IconData(slot = {36})
public Icon PREVIOUS = DefaultIcons.previousPage();
@IconData(slot = {44})
public Icon NEXT = DefaultIcons.nextPage();
@IconData(slot = {0, 8, 36, 44}, background = true)
public Icon FRAME = Icon.of(DefaultIcons.pane(DefaultIcons.Color.GRAY));
}
Screen<KitsLayout> screen = Screen.of(KitsLayout.class);
screen.icon(l -> l.ENTRY).onClick(...); // checked by the compiler, not by a string@ScreenLayout: title, rows (default 6), integrateToPAPI, locale. @IconData: slot, permission,
background, order, group, locale. A DisplayItem is read as material:/type:/id:, amount:,
name:, lore: (material: reads the vanilla minecraft: prefix in any case, MINECRAFT:STONE included); any other item-data line is kept raw on the Icon and applied by the renderer, which warns
once per (layout, field) when it cannot. name:, lore:, the Locale blocks and Settings.title accept & colour
codes: &a is read as §a (an & that starts no code, as in Tom & Jerry, stays text), a § written in the file
is kept, and a seeded file writes its colours with &. Text written in code follows the same rule: Screen.title
(and so ConfirmRequest.of, RankingRequest.title, Menu.Builder.title), Icon.displayName and Icon.lore read
&a and &#RRGGBB as colour; a value put in later ({0}, a replacer, a placeholder) is never coloured. An icon's
per-language text goes in addLocale(lang, name, lore...); a later displayName/lore replaces that block, so
DefaultIcons.info().displayName("x") draws "x". A layout field that names its text both in @IconData(locale = ...)
and in .displayName() is refused as "the same icon in two places".
Text resolves in one fixed order, whatever order the screen was declared in: the viewer's language block, the
open's {0}..{n}, the icon's scopes (icon.addScope(replacer, object)), the screen's replacers, its subject,
then PlaceholderAPI when the layout asks for it.
/ecore screen reload re-reads the files, /ecore screen diff shows what the files change against the code,
/ecore screen which <key> [plugin] names the override layer that answers a key (permission
evernifecore.command.screen).
DefaultIcons carries the shared ones (previousPage(), nextPage(), confirm(), deny(),
wool(Color), pane(Color)), with mod items first and vanilla as the last candidate.
public interface ScreenRenderer {
String getId();
int getPriority();
Set<RendererCapability> getCapabilities();
boolean supports(FPlayer viewer, Screen<?> screen);
ScreenSurface open(ScreenView view);
ScreenScheduler getScheduler();
}Screens.registerRenderer(owner, renderer) puts it in the renderer TypeRegistry
(Screens.registry()); the owner's shutdown takes it out. On open the core picks the highest priority
renderer that supports the viewer and the screen and covers the screen's capabilities:
RendererCapability |
Needed when |
|---|---|
STORAGE_SLOTS |
the screen declares an editable area (storage(...)) |
TEXTURES |
a custom texture is drawn |
TITLE_CHANGE |
the title changes while the window is open |
No renderer covering the screen refuses the open naming the capability and the renderer - or opens
onMissingCapability(ScreenFallback.open(otherScreen | otherKey)).
ScreenSurface.apply(List<ElementPatch>) is the single write point. An ElementPatch carries the element id,
its ElementKind, the slot, the resolved RenderedIcon, the progress ratio and whether it is enabled (and why
not) - enough for a renderer that draws by element. A grid renderer extends GridSurface, which turns patches
into writeSlot calls; the diff lives in the ScreenView, so both kinds only ever receive what changed.
ScreenSurface.released(CloseReason) is where a window gives back what it held, before onClose.
ScreenSurface.suspended() (a no-op by default, renderer thread) says the view was set aside - a prompt asks, or a
screen opened on top: the window goes, the view stays and comes back on a new surface, and nothing reports this
window closed. The chest closes its inventory there a tick later, only if the player still has it open (a screen
or an anvil opened on top replaces the window by itself); a click on it in that tick is cancelled.
A renderer reports a window that went away with Screens.windowClosed(view, reason) /
Screens.viewerGone(uuid, reason); ScreenView.click(slot, gesture, kind, cursor) and
click(elementId, gesture) route a click (both answer whether the item move may go through, not whether
something handled the click).
A ScreenKey (evernifecore:confirm, ScreenKey.of(owner, name), ScreenKey.parse) names a screen others
may replace. Screens.open(player, key, ScreenRequest) resolves it through four layers, most specific first:
- the caller's replacement -
ScreenRequest.of(owner).withScreen(otherKey); - the content owner's override -
ScreenScope.ownedBy(plugin), matched on the request's owner; - the server's override -
ScreenScope.server(), one plugin per key: a second one throws naming both; - the core's default.
Cancellable undo = Screens.override(myPlugin, ScreenKey.CONFIRM, ScreenScope.server(),
ctx -> MyConfirmScreen.of(ctx.getRequest(ConfirmRequest.class)));
Screens.open(player, ScreenKey.RANKING, ScreenRequest.of(myPlugin)
.payload(RankingRequest.of(killsRanking).title("§8Top killers")));The factory is a Function<ViewContext, Screen<?>>: ViewContext gives the viewer, key, owner, args, locale,
an ExecutionContext and getRequest(Class<R>) - the payload, or a refusal that teaches
ScreenRequest.of(plugin).payload(...). Cancellable.cancel() hands the key back to the layer below; a plugin's
shutdown removes its overrides and closes its views (Screens.closeOwnedBy). Screens.closeAll() and
Screens.closeOwnedBy(owner) run from any thread: each screen closes (and each one set aside is released) on its
renderer's thread, inline when the caller is already there, and the call waits up to 5 s, naming in a SEVERE line
the viewers whose screens did not close.
The core ships four keys, each a working example with its layout seeded under guis/:
| Key | Screen | Request |
|---|---|---|
ScreenKey.CONFIRM |
ConfirmScreen<L extends ConfirmLayout> |
ConfirmRequest.of(question[, gate, effects...]), .display(item), .dangerous() (shift-click to confirm), .owner(plugin); {cost} in the lore. Screens.confirm(player, request) answers CompletableFuture<Boolean>, cancelled when the player walks away |
ScreenKey.DELIVERIES |
DeliveriesScreen |
none: the viewer's pending deliveries, claimed one by one or all at once; the list fills a moment after the window opens |
ScreenKey.RANKING |
RankingScreen |
RankingRequest.of(rankedList).title(...) over a RankedList: the viewer's row marked, a bar to the next position |
ScreenKey.DIALOGUE |
DialogueScreen |
the DialogueSession as payload, opened by the session itself: one node of a dialogue, a Choice per option |
ConfirmScreen.of(request) answers a ConfirmScreen<ConfirmLayout>. A plugin with a confirmation of its own extends
it (protected constructor) over a layout that extends ConfirmLayout, and keeps the shift-click of a dangerous
request, the paid gate and {cost}:
@ScreenLayout(title = "§8Claim it?", rows = 3)
public class ClaimLayout extends ConfirmScreen.ConfirmLayout {
@IconData(slot = {4})
public Icon REWARD = Icon.of(ItemSpec.of("minecraft:chest", 1));
}
public class ClaimScreen extends ConfirmScreen<ClaimLayout> {
public ClaimScreen(ConfirmRequest request) {
super(Layouts.of(ClaimLayout.class), request); // CONFIRM and DENY already wired
icon(l -> l.REWARD).id("reward");
}
}The stock effect open-screen (screen, args) opens a key from logic; a context with no player is audited
and opens nothing.
evernifecore-common-tests drives screens without a server (Testing Toolkit):
try (ScreenWorld world = ScreenWorld.open(tempDir)) {
ScreenView view = world.open(screen, world.viewer("Steve"));
world.click(view, "buy", ClickGesture.PRIMARY);
world.advanceTicks(1);
RenderedIcon shown = world.getRenderer().surfaceOf(view).getSlot(13);
view.close();
world.advanceTicks(2);
}The RecordingRenderer keeps every patch (RecordingSurface.getApplies(), getSlot, getLastPatch);
world.recorded(ScreenOpenEvent.class) reads the bus; close() fails when a view is left open or a source is
still held. world.click(...) answers whether the item move the gesture would make may go through, never whether a
handler ran - assert the click's effect. RendererConformance.check(renderer, world) answers the list of failed
scenarios, and every renderer passes it, the chest included: open-registers-view, open-posts-event,
click-routes-by-id-and-gesture, patch-batches-per-tick, patch-touches-only-dirty-elements,
close-runs-onclose-once, close-posts-event-with-reason, prompt-sets-aside-and-restores,
nav-push-pop-hands-value, capability-refusal-teaches, title-change-when-declared, and one per element kind -
label-draws-its-text, choice-disabled-carries-reason, progress-reports-ratio, input-asks-through-a-prompt,
list-pages-with-arrows. check(renderer, world, Supplier<FPlayer> viewers) takes the player each scenario opens
for (each call answers one with no screen open), for a renderer whose supports needs a per-player handshake.
To read what the Bukkit chest itself drew (an icon's name and lore), evernifecore-minecraft-tests ships
BukkitScreenWorld.install(dir) (testing.minecraft.screen): the real ChestScreenRenderer over a
described server (Testing Toolkit).
On Bukkit, ChestScreenRenderer (evernifecore:chest, priority 0, in minecraft.screen) draws a screen as a
vanilla container. It is the fallback every Bukkit player gets, and the only renderer with STORAGE_SLOTS
(plus TITLE_CHANGE). The window follows the screen's grid: Screen.of(rows) is a chest of 1-6 rows,
ChestKind.HOPPER.newScreen() (or Screen.of(ChestKind.X.getGrid())) a hopper, dispenser, brewing stand or
workbench.
- An icon id is a modern one (
minecraft:red_wool); before 1.13 coloured wool and glass map to their data value. An icon none of whose candidates the server has is drawn as a barrier (stone before 1.8), warned once per element. - A title longer than 32 characters is cut up to 1.8, where the protocol cannot hold it.
-
FCItemBuilder.asIcon()turns a built stack into ascreen.Icon. -
ChestItems.specOf(stack)carries the stack's own name and lore, so a player's item is drawn with the name it has. - Questions go through the anvil (
BukkitAnvilPromptChannel), or chat where the anvil does not serve the player. - A disabled element (a
Choicewhose gate fails) shows its name grey, unless it has a colour of its own, and its reason as the last lore lines in red, one failure per line. - A spec's data value (
ItemSpec.withData) is drawn before 1.13; from 1.13 the colour is in the id. - An icon re-read from
guis/<Layout>.ymlkeeps the fallbacks its field declared (the file holds only the primary item), soCONFIRM/DENYstill fall back to wool on a server without the mod items (Icon.setFallbacks,Icon.getStateFallbacks(name)).
This is the one place where a click is not cancelled, so nothing moves until the declaration names the gestures that may:
screen.storage(l -> l.AREA) // or storage(SlotSet)
.backedBy(backpack) // an ItemStore or a GenericInventory
.policy(ClickPolicy.EDIT_ALL)
.denyPlace(ChestItems.stacks(stack -> Blacklist.contains(stack)))
.onChange(change -> owner.markDirty());| Call | Meaning |
|---|---|
backedBy(Object) |
the store filled from when the area opens and written back after every change: an ItemStore or a GenericInventory
|
policy(ClickPolicy) |
which gestures go through; whatever is not named is cancelled |
denyPlace(Predicate<ItemSpec>) / allowPlace(...)
|
which items may enter; filters add up. ChestItems.stacks(Predicate<ItemStack>) writes one over the Bukkit stack |
onChange(Consumer<StorageChange>) |
after every accepted change, on the next tick, and once more when the screen goes away for any reason (StorageChange.isLast()); contents as ItemSpecs carrying the stack |
ClickPolicy.EDIT_ALL is everything a player needs to move an item, minus the two that would duplicate one;
ClickPolicy.builder() is the granular form. A double click (COLLECT_TO_CURSOR) is refused on the whole
screen; a shift-move is TAKE leaving the area and PLACE arriving from the viewer's inventory. onChange
never saves on its own.
The invariant: over any sequence of clicks the sum of the area, the viewer's inventory and their cursor does not change. Nothing paints over an editable slot, every gesture the renderer takes apart adds exactly what it takes, and the store is only written from what the container holds. Not covered: two screens open on the same store at once - the last to close wins, and the log says so.
GenericInventory |
StoredInventory |
|
|---|---|---|
| Capacity | unbounded | declared, and enforced |
| Maximum per slot | whatever the item says | per slot, overridable |
| Asked before a change | no |
onPreUpdate, which may refuse it |
| Told after a change | no | onPostUpdate |
| Stored as | a bare slot map | a versioned envelope |
StoredInventory backpack = new StoredInventory(27);
backpack.setMaxStackSize(0, 1);
backpack.onPreUpdate(event -> event.setCancelled(locked));
backpack.onPostUpdate(event -> owner.markDirty());setItem(UpdateCause, int, ItemStack) asks onPreUpdate, writes when it agrees and tells onPostUpdate;
setItemSilently does neither; reads answer copies. UpdateCause is PLAYER (a gesture in an open screen) or
PLUGIN. A declared maximum is what a shift-click fills up to; an item already in the slot is never cut down.
It persists as a versioned envelope (version, size, items, maxStackSizes); a file with no version is
version 1, the old bare slot map. StoredInventorySchema.registerMigration(n, stored -> ...) chains upgrades,
and a missing, unknown or newer version is refused rather than read as an empty chest. A decoder rebuilds one
through StoredInventory.restoring(size) ... build(), the one door that skips the thread rule while the
inventory is nobody's yet.
Moving an item happens on the server main thread: setItem, setItemSilently and setCapacity throw from
any other, naming the thread. Describing an inventory (setMaxStackSize, the handlers) and reading it are
allowed anywhere. See Scheduler and Threading.
On Hytale, HyPageRenderer (evernifecore:page, priority 0, in hytale.screen) draws a screen as a server page,
an InteractiveCustomUIPage: a frame sized to the grid, the title, one cell per slot. Details and threads are in
Screens on Hytale; what differs from the chest:
Chest (evernifecore:chest) |
Page (evernifecore:page) |
|
|---|---|---|
| Capabilities |
STORAGE_SLOTS, TITLE_CHANGE
|
TITLE_CHANGE |
| Icon | the item id, or a barrier when the server has none | the hytale: asset id (or a Hytale fallback), else Rock_Stone
|
| Name and lore | the item's own name and lore | the cell's tooltip |
| Gestures | every Bukkit click |
PRIMARY, SECONDARY, DOUBLE
|
| Close reasons |
PLAYER_CLOSED (the inventory close comes first) |
PLAYER_CLOSED (escape), REQUESTED (close(), another screen), DISCONNECTED (quit) |
| Prompt channels |
ANVIL (BukkitAnvilPromptChannel), CHAT (BukkitChatPromptChannel) |
PAGE (HyPagePrompt) |
| Thread | the main thread | its own EverNifeCore Screens thread; the page on the viewer's world thread |
Nothing above is chest-specific: a plugin can ship its own ScreenRenderer, and every screen of every plugin is
drawn by it for the players it serves, with no change to those plugins. The reference case is a renderer that draws
screens through a client mod over a plugin-message channel. How such a renderer fits the SPI:
-
Priority above the chest (the chest and the page are 0; the mod renderer uses 100), and
supports(viewer, screen)answers per player: true only after that player's client said a compatible hello on the channel. A player without the mod - or with a mod of another protocol version - is never offered it, so the chest stays the fallback and nothing breaks. -
open(view)sends the window,apply(patches)sends the changes. The core callsrender()and commits right afteropen, so the first patch carries every slot: the renderer serializes, it never decides. Text is already resolved for the viewer, icons arrive asRenderedIcon(item, fallbacks, data lines). -
The client is untrusted. A click from the mod is revalidated on the server before it reaches
ScreenView.click(slot, gesture, kind, cursor): the view is open and belongs to that player, the element is on that slot and enabled, the sequence number grows. From there the screen'sClickRule, debounce and gates run in the core exactly as for a chest click - a modified client can only send what a chest could. -
Closing has two directions.
close(reason)(the core asked) tells the client and reportsScreens.windowClosed;released(reason)(the player left, changed world) only tells the client; a close the client reports comes back asScreens.windowClosed(view, PLAYER_CLOSED). -
Capabilities are honest. It declares
TITLE_CHANGEandTEXTURES; notSTORAGE_SLOTS(no real items move through a mod window, so a screen withstorage(...)falls to the chest). AnInputFieldasks throughPromptRegistryon every renderer. -
Textures ride on the icon fallbacks. An
ItemSpecwhose id is not a game item (Icon.of(ItemSpec.of("srv:coins/gold", 1), ItemSpec.of("minecraft:gold_ingot", 1))) is drawn as a picture by a renderer that knows it, and every other renderer draws the first fallback it has - the chest shows the gold ingot. -
Test it with the same suite.
RendererConformance.check(renderer, world, viewers)runs against the mod renderer with its channel replaced by a queue;viewersanswers players that already made the handshake.
-
Sign input. Free text is the anvil or chat (
PromptRegistry); another channel plugs in under aPromptKind. - Item identity by UUID or NBT as a default. Only inside an editable area.
-
A layout grammar other than slots.
Slots,NamedRegionand the yml are the only ways to place.
- Menus - a screen with offers, registered by name: prices, buy and sell, the offer button
-
Logic -
Gate,Condition,Effect, the pieces aClickRuleis made of -
Items and NBT -
ItemSpecand the item-data lines aDisplayItemis written in -
Localization -
@FCLocale,LocaleMessageand per-player language - Placeholders - the replacer engine a screen's text is resolved through
-
Testing Toolkit -
ScreenWorld,RendererConformance -
In-Server Conformance - the
screensuite, on every Bukkit flavor - Gotchas and Pitfalls - what a screen close can lose, and why
EverNifeCore · Home · made by Petrus Pradella
Getting Started
Commands & Text
Player Data & Storage
- PlayerData & PDSections
- Accounts
- Storage Backends
- Inline Backends for Plugins
- Block Data (SVWorldDataManager)
- Legacy Data Migration
- Cooldowns
Config & Minecraft Systems
- Configuration
- Logging & Debug Modules
- Events
- Regions
- Groups
- Notifications
- NPCs
- Logic
- Quests
- Provenance
- Scheduler & Threading
- Items & NBT
- Screens
- Dialogues
- Menus
- Integrations
- Economy
- Boosters
- Deliveries
- Audit Log
- Dynamic Values
- Player Ignore
- Version Compatibility
Architecture & Reference