Skip to content
Open
3 changes: 3 additions & 0 deletions crates/window-core/src/codegen/naming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ pub(super) fn is_valid_identifier(name: &str) -> bool {
/// Members `HudView` declares beyond the shared set.
pub(super) const HUD_RESERVED_MEMBERS: &[&str] = &["render", "channel"];

/// Members `FullscreenView` generated classes declare beyond the shared set.
pub(super) const FULLSCREEN_RESERVED_MEMBERS: &[&str] = &["onScroll", "onRightClick"];

const RESERVED_MEMBERS: &[&str] = &[
"player",
"host",
Expand Down
4 changes: 4 additions & 0 deletions crates/window-core/src/codegen/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -494,4 +494,8 @@ fn fullscreen_views_take_a_fullscreen_host_and_receive_fullscreen_clicks() {
assert!(content.contains("button(\"buy[$index]\") { click ->\n"), "{content}");
assert!(content.contains("onBuy(index, click)\n"), "{content}");
assert!(content.contains("button(\"play\", ::onPlay)"), "{content}");
assert!(content.contains("protected open fun onScroll(steps: Int) {}"), "{content}");
assert!(content.contains("protected open fun onRightClick(click: FullscreenClick) {}"), "{content}");
assert!(content.contains("scroll(::onScroll)\n"), "{content}");
assert!(content.contains("rightClick(::onRightClick)\n"), "{content}");
}
16 changes: 15 additions & 1 deletion crates/window-core/src/codegen/view/class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,24 @@ pub(super) fn render(
for handle in handles {
handle.declare(&mut w, item);
}
if handles.iter().all(|handle| handle.handle.uses.is_empty()) {
let fullscreen = base.host == ViewHost::Fullscreen;
if fullscreen {
w.doc("Handle a scroll of the wheel, as the signed steps the hotbar selection moved.");
w.line("protected open fun onScroll(steps: Int) {}");
w.blank();
w.doc("Handle a right click anywhere on the canvas.");
w.line("protected open fun onRightClick(click: FullscreenClick) {}");
w.blank();
}
if !fullscreen && handles.iter().all(|handle| handle.handle.uses.is_empty()) {
w.line(format_args!("final override fun {prefix}Scope{type_args}.bind() {{}}"));
} else {
w.open(format_args!("final override fun {prefix}Scope{type_args}.bind() {{"));
handles::bind(&mut w, handles);
if fullscreen {
w.line("scroll(::onScroll)");
w.line("rightClick(::onRightClick)");
}
w.close("}");
}
handles::constants(&mut w, handles);
Expand All @@ -86,6 +99,7 @@ fn imports(base: &ViewBase, target: KotlinTarget, handles: &[HandleMember]) -> V
let mut imports: Vec<String> = [
(base.host == ViewHost::Window && host.is_none(), "com.chunkzero.window.host.WindowHost"),
(base.host == ViewHost::Fullscreen && host.is_none(), "com.chunkzero.window.host.FullscreenHost"),
(base.host == ViewHost::Fullscreen, "com.chunkzero.window.FullscreenClick"),
(host.is_some(), "net.minestom.server.entity.Player"),
(host.is_some() && base.host == ViewHost::Window, "net.minestom.server.item.ItemStack"),
]
Expand Down
8 changes: 7 additions & 1 deletion crates/window-core/src/codegen/view/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,13 @@ pub(super) fn generate_fullscreen(
target: KotlinTarget,
) -> Result<OutputFile> {
let class_name = naming::fullscreen_class_name(name);
let handles = HandleMember::all(&class_name, &[], ClickKind::Fullscreen, &fullscreen.handles, |_| None)?;
let handles = HandleMember::all(
&class_name,
naming::FULLSCREEN_RESERVED_MEMBERS,
ClickKind::Fullscreen,
&fullscreen.handles,
|_| None,
)?;
Ok(OutputFile::text(
format!("{class_name}.kt"),
class::render(package_name, &FULLSCREEN, target, name, &class_name, &handles),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ import net.minestom.server.entity.GameMode
import net.minestom.server.entity.Metadata
import net.minestom.server.entity.MetadataDef
import net.minestom.server.entity.Player
import net.minestom.server.entity.PlayerHand
import net.minestom.server.entity.attribute.Attribute
import net.minestom.server.entity.metadata.display.AbstractDisplayMeta
import net.minestom.server.entity.metadata.display.TextDisplayMeta
import net.minestom.server.entity.metadata.other.InteractionMeta
import net.minestom.server.event.EventFilter
import net.minestom.server.event.EventNode
import net.minestom.server.event.player.PlayerCustomClickEvent
import net.minestom.server.event.player.PlayerDisconnectEvent
import net.minestom.server.event.player.PlayerMoveEvent
import net.minestom.server.event.player.PlayerPacketEvent
Expand All @@ -28,17 +30,21 @@ import net.minestom.server.item.Material
import net.minestom.server.network.packet.client.play.ClientAnimationPacket
import net.minestom.server.network.packet.client.play.ClientAttackPacket
import net.minestom.server.network.packet.client.play.ClientCloseWindowPacket
import net.minestom.server.network.packet.client.play.ClientHeldItemChangePacket
import net.minestom.server.network.packet.client.play.ClientInputPacket
import net.minestom.server.network.packet.client.play.ClientInteractEntityPacket
import net.minestom.server.network.packet.client.play.ClientPlayerActionPacket
import net.minestom.server.network.packet.client.play.ClientPlayerPositionAndRotationPacket
import net.minestom.server.network.packet.client.play.ClientPlayerRotationPacket
import net.minestom.server.network.packet.server.ServerPacket
import net.minestom.server.network.packet.server.common.ClearDialogPacket
import net.minestom.server.network.packet.server.common.ShowDialogPacket
import net.minestom.server.network.packet.server.play.ChangeGameStatePacket
import net.minestom.server.network.packet.server.play.CloseWindowPacket
import net.minestom.server.network.packet.server.play.DestroyEntitiesPacket
import net.minestom.server.network.packet.server.play.EntityAttributesPacket
import net.minestom.server.network.packet.server.play.EntityMetaDataPacket
import net.minestom.server.network.packet.server.play.HeldItemChangePacket
import net.minestom.server.network.packet.server.play.OpenWindowPacket
import net.minestom.server.network.packet.server.play.PlayerAbilitiesPacket
import net.minestom.server.network.packet.server.play.PlayerInfoUpdatePacket
Expand All @@ -58,13 +64,15 @@ import net.minestom.server.world.clock.WorldClock
* child of their event node.
*
* The server keeps the player in spectator mode, pinned at their position facing yaw and pitch 0. Their client alone is
* told it is in survival, flying at zero speed, so the scroll wheel and number keys change the held slot. A client-only
* interaction entity encloses the eye, so every left click attacks it without a miss cooldown.
* told it is in survival, flying at zero speed, so the scroll wheel and number keys change the held slot, which is
* reported as steps and never applied to the server's player. A client-only interaction entity encloses the eye, so
* every left click attacks it without a miss cooldown, and every right click interacts with it once, with the main hand.
*
* The HUD is hidden: the hotbar holds blank items, which also hide the hand, and the client's world age is held
* negative, which the pack's GUI shader reads as "clip the bottom-centre HUD strip". So the hearts fit that strip, the
* client is told its max health is at most the canvas's `hudMaxHealth` and its absorption is 0. While the server has a
* container open, the client gets the real world age back and its real hotbar so the strip and slots show.
* container open, the client gets the real world age back and its real hotbar so the strip and slots show. The text
* prompt's dialog is a client screen rather than a server container, so it keeps the HUD hidden and the hotbar blank.
*
* While the window is open, a server update to the game mode, abilities, hotbar, time, max health, or absorption passes
* through unchanged and is followed by a packet that puts the hidden state back. Replacing the update instead would
Expand All @@ -75,6 +83,10 @@ import net.minestom.server.world.clock.WorldClock
* The client advances every world clock by the world age delta of each time packet before applying the packet's clock
* snapshots, so every packet that changes the world age carries the real clock values, or the hidden age would move
* the clocks with it.
*
* A prompt's callback runs on the player's thread after the state it reports has changed, never under [lock]: a server
* container opening over the dialog cancels it on the next tick, and a window ending cancels it once the player is
* restored, just before [FullscreenListener.onClose].
*/
internal class MinestomFullscreen private constructor(
private val player: Player,
Expand Down Expand Up @@ -105,6 +117,10 @@ internal class MinestomFullscreen private constructor(
private val lock = Any()
private var open = false

/** The held slot the client has selected, which held-slot changes are measured from. Requires [lock]. */
private var clientSlot = heldSlot.toInt()
private val textPrompt = MinestomTextPrompt(player)

/** The window id of the server container the client has open, or [NO_CONTAINER]. */
private var containerWindowId = NO_CONTAINER
private val containerOpen get() = containerWindowId != NO_CONTAINER
Expand All @@ -127,8 +143,24 @@ internal class MinestomFullscreen private constructor(
player.sendPacket(PlayerRotationPacket(yaw, true, pitch, true))
}

override val promptOpen: Boolean
get() = textPrompt.current != null

override fun promptText(
title: Component,
initial: String,
onDone: (String?) -> Unit,
) {
if (isOpen()) textPrompt.open(title, initial, onDone) else onDone(null)
}

override fun close() {
if (stop()) restore()
if (!stop()) return
try {
restore()
} finally {
textPrompt.cancel()
}
}

private fun start(content: Component) {
Expand Down Expand Up @@ -246,6 +278,7 @@ internal class MinestomFullscreen private constructor(
val next = event.newPosition
if (!next.samePoint(anchor)) event.newPosition = anchor.withView(next.yaw(), next.pitch())
}
node.addListener(PlayerCustomClickEvent::class.java, textPrompt::onClick)
node.addListener(PlayerDisconnectEvent::class.java) { end(restore = false) }
player.eventNode().addChild(node)
}
Expand All @@ -266,8 +299,21 @@ internal class MinestomFullscreen private constructor(
player.scheduler().scheduleEndOfTick { if (isOpen()) listener.onClick() }
}

is ClientInteractEntityPacket -> {
event.isCancelled = true
// The interaction entity consumes a main-hand use, so the client never tries the off hand.
if (packet.hand() == PlayerHand.MAIN) {
player.scheduler().scheduleEndOfTick { if (isOpen()) listener.onRightClick() }
}
}

is ClientHeldItemChangePacket -> {
event.isCancelled = true
scroll(packet.slot().toInt())
}

// Dropping or swapping hands would resync the real hotbar over the blank items.
is ClientAnimationPacket, is ClientInteractEntityPacket, is ClientPlayerActionPacket -> {
is ClientAnimationPacket, is ClientPlayerActionPacket -> {
event.isCancelled = true
}

Expand All @@ -286,6 +332,18 @@ internal class MinestomFullscreen private constructor(
}
}

/** Reports the client selecting [slot] as steps the shortest way around the hotbar from the slot it had. */
private fun scroll(slot: Int) {
if (slot !in 0 until HOTBAR_SIZE) return
val steps =
synchronized(lock) {
val from = clientSlot
clientSlot = slot
Math.floorMod(slot - from + HOTBAR_SIZE / 2, HOTBAR_SIZE) - HOTBAR_SIZE / 2
}
if (steps != 0) listener.onScroll(steps)
}

/** Keeps the HUD hidden and the client in fake survival after each of the server's own updates. */
private fun onPacketOut(event: PlayerPacketOutEvent) {
whileOpen {
Expand Down Expand Up @@ -324,13 +382,23 @@ internal class MinestomFullscreen private constructor(
if (packet != abilities) player.sendPacket(abilities)
}

is HeldItemChangePacket -> {
clientSlot = packet.slot().toInt()
}

is SetPlayerInventorySlotPacket, is SetSlotPacket, is WindowItemsPacket -> {
if (!containerOpen) blank(realHotbarSlots(packet))
}

is ShowDialogPacket, is ClearDialogPacket -> {
textPrompt.onWritten(packet)
}

is OpenWindowPacket -> {
containerWindowId = packet.windowId()
containerGeneration++
// The container replaces the client's dialog without any answer.
textPrompt.coverByContainer()
player.sendPacket(realTime())
// Opening a container does not resync the player inventory, whose hotbar still holds the blanks.
player.inventory.update(player)
Expand Down Expand Up @@ -398,8 +466,15 @@ internal class MinestomFullscreen private constructor(
/** Ends the window after the player left it, restoring them unless they disconnected. */
private fun end(restore: Boolean) {
if (!stop()) return
if (restore) restore()
listener.onClose()
try {
if (restore) restore()
} finally {
try {
textPrompt.cancel()
} finally {
listener.onClose()
}
}
}

/** Stops listening; false when already stopped. */
Expand All @@ -413,6 +488,7 @@ internal class MinestomFullscreen private constructor(
}

private fun restore() {
textPrompt.clear()
player.sendPacket(DestroyEntitiesPacket(listOf(display.entityId, clickTarget.entityId)))
// A mode the server set while the window was open replaces the one it began with.
val mode = player.gameMode.takeUnless { it == GameMode.SPECTATOR } ?: gameMode
Expand Down
Loading
Loading