diff --git a/crates/window-core/src/codegen/naming.rs b/crates/window-core/src/codegen/naming.rs index f36137e7..e79dc8cf 100644 --- a/crates/window-core/src/codegen/naming.rs +++ b/crates/window-core/src/codegen/naming.rs @@ -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", diff --git a/crates/window-core/src/codegen/tests.rs b/crates/window-core/src/codegen/tests.rs index fdb54764..99e1d5a4 100644 --- a/crates/window-core/src/codegen/tests.rs +++ b/crates/window-core/src/codegen/tests.rs @@ -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}"); } diff --git a/crates/window-core/src/codegen/view/class.rs b/crates/window-core/src/codegen/view/class.rs index cfdf62af..0005e068 100644 --- a/crates/window-core/src/codegen/view/class.rs +++ b/crates/window-core/src/codegen/view/class.rs @@ -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); @@ -86,6 +99,7 @@ fn imports(base: &ViewBase, target: KotlinTarget, handles: &[HandleMember]) -> V let mut imports: Vec = [ (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"), ] diff --git a/crates/window-core/src/codegen/view/mod.rs b/crates/window-core/src/codegen/view/mod.rs index e95b0dde..f55c914d 100644 --- a/crates/window-core/src/codegen/view/mod.rs +++ b/crates/window-core/src/codegen/view/mod.rs @@ -58,7 +58,13 @@ pub(super) fn generate_fullscreen( target: KotlinTarget, ) -> Result { 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), diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt index 48713d37..54638a8e 100644 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt @@ -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 @@ -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 @@ -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 @@ -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, @@ -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 @@ -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) { @@ -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) } @@ -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 } @@ -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 { @@ -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) @@ -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. */ @@ -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 diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt new file mode 100644 index 00000000..c3789ae4 --- /dev/null +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt @@ -0,0 +1,152 @@ +package com.chunkzero.window.minestom.internal + +import net.kyori.adventure.key.Key +import net.kyori.adventure.nbt.CompoundBinaryTag +import net.kyori.adventure.nbt.StringBinaryTag +import net.kyori.adventure.text.Component +import net.minestom.server.dialog.Dialog +import net.minestom.server.dialog.DialogAction +import net.minestom.server.dialog.DialogActionButton +import net.minestom.server.dialog.DialogAfterAction +import net.minestom.server.dialog.DialogInput +import net.minestom.server.dialog.DialogMetadata +import net.minestom.server.entity.Player +import net.minestom.server.event.player.PlayerCustomClickEvent +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 java.util.concurrent.atomic.AtomicLong + +/** + * The dialog a full-screen window shows [player] to type into: a text field over a Confirm and a Cancel button, which + * report through custom click actions whose ids are unique to the prompt, so responses to any other dialog are ignored. + * + * The client closes the dialog when a button is pressed, and Escape does nothing, so a prompt ends with a button, with + * another prompt replacing it, or with [cancel]. Each of those reports to its callback once, after the prompt has left + * [current], so a callback may open the next prompt. The buttons sit in the dialog's body, clear of the footer, which + * falls inside the strip the pack's shader clips while the HUD is hidden. + */ +internal class MinestomTextPrompt( + private val player: Player, +) { + @Volatile + private var pending: Pending? = null + + /** The prompt whose dialog the writer has sent most recently and not cleared, which may no longer be [current]. */ + @Volatile + private var shown: Pending? = null + + /** The prompt awaiting an answer, if any. */ + val current: Pending? get() = pending + + /** Shows the dialog, first reporting `null` to the prompt it replaces. */ + fun open( + title: Component, + initial: String, + onDone: (String?) -> Unit, + ) { + val id = PROMPTS.getAndIncrement() + val confirm = Key.key("window", "prompt/$id/confirm") + val cancel = Key.key("window", "prompt/$id/cancel") + val show = ShowDialogPacket(dialog(title, initial.take(MAX_LENGTH), confirm, cancel)) + val next = Pending(confirm, cancel, show, onDone) + val replaced = pending + pending = next + player.sendPacket(next.show) + replaced?.onDone?.invoke(null) + } + + /** Reports a button press of the open prompt, and nothing else. */ + fun onClick(event: PlayerCustomClickEvent) { + val current = pending ?: return + val text = + when (event.key) { + current.confirm -> { + ((event.payload as? CompoundBinaryTag)?.get(FIELD) as? StringBinaryTag)?.value()?.take(MAX_LENGTH) + ?: return + } + + current.cancel -> { + null + } + + else -> { + return + } + } + pending = null + current.onDone(text) + } + + /** Takes the open prompt's dialog, if any, off the client's screen; the prompt itself ends with [cancel]. */ + fun clear() { + if (pending != null) player.sendPacket(ClearDialogPacket()) + } + + /** Records, from the packets as the writer sends them, which prompt's dialog the client is showing. */ + fun onWritten(packet: ServerPacket) { + when (packet) { + is ShowDialogPacket -> shown = pending?.takeIf { it.show == packet } + is ClearDialogPacket -> shown = null + else -> Unit + } + } + + /** + * Cancels the open prompt on the player's next tick when a container replaces the dialog the client is showing. + * A prompt whose dialog has not been sent yet is unaffected, as its dialog arrives after the container. + */ + fun coverByContainer() { + val covered = shown?.takeIf { it === pending } ?: return + shown = null + player.scheduler().scheduleNextTick { cancel(covered) } + } + + /** Reports `null` to the open prompt, if any; with [only], just when that prompt is still the open one. */ + fun cancel(only: Pending? = null) { + val current = pending ?: return + if (only != null && only !== current) return + pending = null + current.onDone(null) + } + + private fun dialog( + title: Component, + initial: String, + confirm: Key, + cancel: Key, + ): Dialog = + Dialog.MultiAction( + DialogMetadata( + title, + null, + false, + false, + DialogAfterAction.CLOSE, + emptyList(), + listOf(DialogInput.Text(FIELD, DialogInput.DEFAULT_WIDTH, title, false, initial, MAX_LENGTH, null)), + ), + listOf(button("Confirm", confirm), button("Cancel", cancel)), + null, + 2, + ) + + private fun button( + label: String, + id: Key, + ) = DialogActionButton(Component.text(label), null, BUTTON_WIDTH, DialogAction.DynamicCustom(id, null)) + + class Pending internal constructor( + internal val confirm: Key, + internal val cancel: Key, + internal val show: ShowDialogPacket, + internal val onDone: (String?) -> Unit, + ) + + private companion object { + const val FIELD = "text" + const val MAX_LENGTH = 256 + const val BUTTON_WIDTH = 100 + val PROMPTS = AtomicLong() + } +} diff --git a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt index cb5f7b2b..d7b8d750 100644 --- a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt +++ b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt @@ -15,17 +15,26 @@ import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.shouldBe +import net.kyori.adventure.key.Key +import net.kyori.adventure.nbt.CompoundBinaryTag import net.kyori.adventure.text.Component import net.minestom.server.MinecraftServer import net.minestom.server.component.DataComponents import net.minestom.server.coordinate.Pos +import net.minestom.server.coordinate.Vec +import net.minestom.server.dialog.Dialog +import net.minestom.server.dialog.DialogAction +import net.minestom.server.dialog.DialogInput import net.minestom.server.entity.EntityType 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.event.EventDispatcher +import net.minestom.server.event.player.PlayerCustomClickEvent +import net.minestom.server.event.player.PlayerDisconnectEvent import net.minestom.server.event.player.PlayerPacketEvent import net.minestom.server.event.player.PlayerPacketOutEvent import net.minestom.server.inventory.Inventory @@ -36,15 +45,20 @@ import net.minestom.server.network.ConnectionState import net.minestom.server.network.packet.client.ClientPacket 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.ClientPlayerRotationPacket import net.minestom.server.network.packet.server.SendablePacket 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 @@ -136,6 +150,166 @@ class MinestomFullscreenTest : menu.clicks shouldContainExactly listOf(FullscreenClick(5, 5)) } + "held-slot changes are steps the shortest way around the hotbar, from the slot the client last had" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + + fixture.receive(ClientHeldItemChangePacket(1)).isCancelled shouldBe true + fixture.receive(ClientHeldItemChangePacket(8)) + fixture.send(HeldItemChangePacket(5)) + fixture.receive(ClientHeldItemChangePacket(6)) + + menu.scrolls shouldContainExactly listOf(1, -2, 1) + fixture.player.heldSlot shouldBe 0.toByte() + } + + "a main-hand right click is reported at the end of the player's tick" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + + fixture.receive(ClientInteractEntityPacket(0, PlayerHand.MAIN, Vec.ZERO, false)).isCancelled shouldBe true + fixture.receive(ClientInteractEntityPacket(0, PlayerHand.OFF, Vec.ZERO, false)) + fixture.receive(ClientPlayerRotationPacket(-44f, -24.375f, 0.toByte())) + fixture.player.scheduler().processTickEnd() + + menu.rightClicks shouldContainExactly listOf(FullscreenClick(5, 5)) + } + + "a prompt shows a dialog that keeps the HUD hidden, and Confirm delivers the field's text" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + fixture.tick() + fixture.clear() + + menu.prompt("hi") + val dialog = fixture.sent().single().dialog() as Dialog.MultiAction + (dialog.metadata().inputs().single() as DialogInput.Text).initial() shouldBe "hi" + dialog.metadata().canCloseWithEscape() shouldBe false + fixture.sent() shouldBe emptyList() + + fixture.click(dialog, 0, "text" to "hello") + menu.prompts shouldContainExactly listOf("hello") + fixture.click(dialog, 0, "text" to "again") + menu.prompts shouldContainExactly listOf("hello") + } + + "Cancel delivers null" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + menu.prompt("hi") + val dialog = fixture.sent().single().dialog() as Dialog.MultiAction + + fixture.click(dialog, 1) + + menu.prompts shouldContainExactly listOf(null) + } + + "a response to another prompt's dialog or a foreign action is ignored" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + menu.prompt("first") + val first = fixture.sent().single().dialog() as Dialog.MultiAction + menu.prompt("second") + menu.prompts shouldContainExactly listOf(null) + val second = fixture.sent().last().dialog() as Dialog.MultiAction + + fixture.click(first, 0, "text" to "stale") + fixture.respond(Key.key("other", "confirm"), "text" to "foreign") + menu.prompts shouldContainExactly listOf(null) + fixture.click(second, 0, "text" to "now") + + menu.prompts shouldContainExactly listOf(null, "now") + } + + "a container opening over the dialog cancels the prompt on the next tick and re-enables the canvas" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + menu.prompt("hi") + val dialog = fixture.sent().single().dialog() as Dialog.MultiAction + + fixture.clickCanvas() + menu.clicks shouldBe emptyList() + fixture.send(OpenWindowPacket(1, InventoryType.CHEST_1_ROW.ordinal, Component.empty())) + menu.prompts shouldBe emptyList() + fixture.tick() + fixture.click(dialog, 0, "text" to "late") + fixture.clickCanvas() + + menu.prompts shouldContainExactly listOf(null) + menu.clicks shouldContainExactly listOf(FullscreenClick(5, 5)) + } + + "a container opened before a prompt's dialog is written leaves that prompt pending" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + fixture.tick() + fixture.clear() + + fixture.player.openInventory(Inventory(InventoryType.CHEST_1_ROW, Component.empty())) + menu.prompt("hi") + val dialog = fixture.sent().single().dialog() as Dialog.MultiAction + fixture.tick() + fixture.click(dialog, 0, "text" to "kept") + + menu.prompts shouldContainExactly listOf("kept") + } + + "disconnecting with a prompt open reports null and closes once" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + menu.prompt("hi") + + EventDispatcher.call(PlayerDisconnectEvent(fixture.player)) + EventDispatcher.call(PlayerDisconnectEvent(fixture.player)) + + menu.prompts shouldContainExactly listOf(null) + menu.closeCount shouldBe 1 + } + + "closing with a prompt open restores first, then reports null and onClose, even if the callback fails" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + val session = menu.open() + menu.prompt("hi") + val dialog = fixture.sent().single().dialog() as Dialog.MultiAction + fixture.clear() + + session.close() + fixture.sent().size shouldBe 1 + fixture.click(dialog, 0, "text" to "late") + menu.prompts shouldContainExactly listOf(null) + + val sneaking = FullscreenFixture() + val other = Menu(sneaking.player) + other.open() + var mode: GameMode? = null + other.prompt("hi") { + mode = sneaking.player.gameMode + other.prompt("again") + } + sneaking.receive(ClientInputPacket(false, false, false, false, false, true, false)) + mode shouldBe GameMode.SURVIVAL + other.prompts shouldContainExactly listOf(null, null) + other.closed shouldBe true + sneaking.sent().size shouldBe 1 + + val failing = FullscreenFixture() + val broken = Menu(failing.player) + val failed = broken.open() + broken.prompt("hi") { error("boom") } + shouldThrow { failed.close() } + failing.player.gameMode shouldBe GameMode.SURVIVAL + broken.closed shouldBe true + } + "sneaking leaves the window and restores the player" { val fixture = FullscreenFixture() val menu = Menu(fixture.player) @@ -312,14 +486,29 @@ private class Menu( player: Player, ) : FullscreenView(FullscreenDefinition(manifest(), "menu"), MinestomFullscreenHost(player)) { val clicks = mutableListOf() + val rightClicks = mutableListOf() + val scrolls = mutableListOf() + val prompts = mutableListOf() var closed = false + var closeCount = 0 override fun FullscreenScope.bind() { button("press") { clicks += it } + rightClick { rightClicks += it } + scroll { scrolls += it } + } + + fun prompt( + initial: String, + then: () -> Unit = {}, + ) = promptText(initial = initial) { + prompts += it + then() } override fun onClose() { closed = true + closeCount++ } } @@ -356,7 +545,30 @@ private class FullscreenFixture { fun tick() = player.scheduler().processTick() - fun receive(packet: ClientPacket) = EventDispatcher.call(PlayerPacketEvent(player, packet)) + /** Clicks the menu's button, at canvas pixel (5, 5), and runs the end of the player's tick. */ + fun clickCanvas() { + receive(ClientAttackPacket(0)) + receive(ClientPlayerRotationPacket(-44f, -24.375f, 0.toByte())) + player.scheduler().processTickEnd() + } + + fun receive(packet: ClientPacket) = PlayerPacketEvent(player, packet).also(EventDispatcher::call) + + /** Presses [dialog]'s button [index], answering with [fields]. */ + fun click( + dialog: Dialog.MultiAction, + index: Int, + vararg fields: Pair, + ) = respond((dialog.actions()[index].action() as DialogAction.DynamicCustom).key(), *fields) + + fun respond( + key: Key, + vararg fields: Pair, + ) { + val payload = CompoundBinaryTag.builder() + for ((name, value) in fields) payload.putString(name, value) + EventDispatcher.call(PlayerCustomClickEvent(player, key, payload.build())) + } /** Queues [packets] back to back, then writes them out the way the connection's writer thread does. */ fun send(vararg packets: ServerPacket) { diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenScope.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenScope.kt index 32fd2f58..a7680595 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenScope.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenScope.kt @@ -49,6 +49,23 @@ public interface FullscreenScope { name: String, handler: (FullscreenClick) -> Unit, ) + + /** + * Binds [handler] to right clicks anywhere on the canvas, at the cursor. Holding the use key repeats a right click + * every 4 ticks. + */ + public fun rightClick(handler: (FullscreenClick) -> Unit) + + /** + * Binds [handler] to the scroll wheel, which reaches the server as changes to the client's held hotbar slot. Each + * change becomes a signed step count, taken the shortest way around the 9-slot hotbar: positive when the selection + * moves right, which scrolling down does by default. + * + * The client sends at most one held-slot change per tick, so notches scrolled within one tick arrive as one call, + * and more than 4 of them read as a scroll the other way. The number keys also select a slot, and the server cannot + * tell them apart from scrolling. + */ + public fun scroll(handler: (steps: Int) -> Unit) } /** diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt index 1c28e8b4..879d09c3 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt @@ -59,6 +59,16 @@ internal class FullscreenWindowSession( override fun onClick() = click() + override fun onRightClick() { + if (!listening) return + val point = cursor() + bindings.rightClick?.invoke(FullscreenClick(point.x, point.y)) + } + + override fun onScroll(steps: Int) { + if (listening) bindings.scroll?.invoke(steps) + } + override fun onClose() { if (closed) return closed = true @@ -66,6 +76,9 @@ internal class FullscreenWindowSession( } } + /** Whether canvas input counts: the window is open and no prompt covers it. */ + private val listening get() = !closed && !screen.promptOpen + fun open() { try { check(entry.surface.mode == FullscreenMode.UNLOCKED) { @@ -89,8 +102,19 @@ internal class FullscreenWindowSession( override fun close() { if (closed) return closed = true - view.invokeOnClose() - screen.close() + try { + screen.close() + } finally { + view.invokeOnClose() + } + } + + fun promptText( + title: Component, + initial: String, + onDone: (String?) -> Unit, + ) { + if (closed) onDone(null) else screen.promptText(title, initial, onDone) } fun refreshAll() { @@ -108,7 +132,7 @@ internal class FullscreenWindowSession( yaw: Float, pitch: Float, ) { - if (closed) return + if (!listening) return this.yaw = yaw this.pitch = pitch mapping.pullBack(yaw, pitch)?.let { turn -> @@ -126,7 +150,7 @@ internal class FullscreenWindowSession( } private fun click() { - if (closed) return + if (!listening) return val point = cursor() val current = layers.switches.current() val actions = @@ -175,6 +199,8 @@ internal class FullscreenWindowSession( private inner class Bindings : FullscreenScope { var sealed = false val buttons = HashMap Unit>() + var rightClick: ((FullscreenClick) -> Unit)? = null + var scroll: ((Int) -> Unit)? = null /** The action ids controls name that a handler can bind; runtime actions are run by the runtime. */ private val actions: Set = @@ -225,6 +251,18 @@ internal class FullscreenWindowSession( require(buttons.put(name, handler) == null) { "Button '$name' bound more than once" } } + override fun rightClick(handler: (FullscreenClick) -> Unit) { + requireBindable("rightClick") + require(rightClick == null) { "rightClick bound more than once" } + rightClick = handler + } + + override fun scroll(handler: (steps: Int) -> Unit) { + requireBindable("scroll") + require(scroll == null) { "scroll bound more than once" } + scroll = handler + } + fun validate() { layers.validate() val controls = entry.controls.values diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt index 271bfb10..80936e7c 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt @@ -12,8 +12,8 @@ import kotlin.properties.ReadWriteProperty * reacts to lifecycle events. * * A view shows [definition] to the player of [host] and is single-use: [open] it once. The player aims a cursor by - * turning, clicks with the attack key, and leaves by sneaking. Reactive [state] cells read inside render lambdas - * re-render the window when written. + * turning, clicks with the attack and use keys, scrolls, answers a [promptText] dialog, and leaves by sneaking. + * Reactive [state] cells read inside render lambdas re-render the window when written. */ public abstract class FullscreenView( private val definition: FullscreenDefinition, @@ -74,6 +74,34 @@ public abstract class FullscreenView( requireSession().refreshAll() } + /** + * Opens a dialog titled [title] over the canvas for the player to type into, starting from [initial], and calls + * [onDone] once with the text when they confirm, or `null` when they cancel. The dialog closes with the button and + * the canvas is back. The text holds at most 256 characters, and [initial] is cut to fit. Escape does not dismiss + * the dialog. While it is open the HUD stays hidden and canvas input is ignored, so the cursor stays where it was. + * + * Opening a prompt while one is open replaces it, and the replaced prompt's [onDone] receives `null`. When a server + * container opens over the dialog, the open prompt's [onDone] receives `null` as well, on the next tick, and the + * window stays open. When the window closes, it receives `null` once the player is restored. [onDone] runs after its + * prompt has ended, so it may open another one; on a closed window it receives `null` at once. + * + * To prompt on a click, call this from the control's click handler: + * ``` + * override fun onRename(click: FullscreenClick) { + * promptText(Component.text("Rename"), name) { it?.let { name = it } } + * } + * ``` + * + * @throws IllegalStateException if the view was never opened. + */ + protected fun promptText( + title: Component = Component.empty(), + initial: String = "", + onDone: (String?) -> Unit, + ) { + requireSession().promptText(title, initial, onDone) + } + /** * [value] shortened with an ellipsis so that it, followed by [suffix], fits the width of slot [slot] when drawn in * its font and style; followed by [suffix], which is kept even when it alone is wider. Content that already fits is diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt index 6e6837bf..604d60b2 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt @@ -40,9 +40,29 @@ public interface OpenFullscreen { pitch: Float, ) + /** Whether a [promptText] dialog is open and unanswered. */ + public val promptOpen: Boolean + + /** + * Shows a dialog titled [title] over the canvas with a text field holding [initial] and a Confirm and a Cancel + * button, and reports the outcome to [onDone] exactly once, on the player's thread: the field's text on Confirm, + * `null` on Cancel, and `null` when another prompt replaces this one, a server container opens over the dialog, or + * the window ends. The dialog closes with the button, back to the canvas, and cannot be dismissed with Escape. The + * text holds at most 256 characters; [initial] is cut to fit. The HUD stays hidden while the dialog is open. + * + * The prompt has left [promptOpen] by the time [onDone] runs, so [onDone] may open the next prompt. On a closed + * window this reports `null` at once. + */ + public fun promptText( + title: Component, + initial: String, + onDone: (String?) -> Unit, + ) + /** - * Server-initiated close: stops listening, removes the display, and restores the player's game mode, abilities, - * position, held slot, and HUD. Does not call [FullscreenListener.onClose]. + * Server-initiated close: stops listening, clears an open prompt's dialog, removes the display, restores the player's + * game mode, abilities, position, held slot, and HUD, and only then reports `null` to the open prompt. Does not call + * [FullscreenListener.onClose]. */ public fun close() } @@ -58,9 +78,18 @@ public interface FullscreenListener { /** A left click, reported after every rotation the client sent in the same tick. */ public fun onClick() + /** A right click, reported after every rotation the client sent in the same tick. */ + public fun onRightClick() + + /** + * The client's held slot moved by [steps], from -4 to 4 and never 0, taken the shortest way around the hotbar; + * positive steps move right. The scroll wheel and the number keys both move it, at most once per tick. + */ + public fun onScroll(steps: Int) + /** - * The player left the window, by sneaking or disconnecting. Listening has stopped, and the player is restored - * unless they disconnected. + * The player left the window, by sneaking or disconnecting. Listening has stopped, the player is restored unless + * they disconnected, and an open prompt has been reported `null`. */ public fun onClose() } diff --git a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt index 8d64a48c..6ee66707 100644 --- a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt +++ b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt @@ -130,6 +130,76 @@ class FullscreenSessionTest : host.turns shouldContainExactly listOf(Turn(-5f, 0f), Turn(-5f, 0f)) } + "right clicks and scroll steps reach their handlers, right clicks at the cursor" { + val host = FakeFullscreenHost() + val menu = Menu(host).also { it.open() } + + host.look(200, 200) + host.listener.onRightClick() + host.listener.onScroll(1) + host.listener.onScroll(-3) + + menu.rightClicks shouldContainExactly listOf(FullscreenClick(200, 200)) + menu.scrolls shouldContainExactly listOf(1, -3) + } + + "a prompt ignores canvas input until it ends, including when a container covers it" { + val host = FakeFullscreenHost() + val menu = Menu(host).also { it.open() } + + menu.mode = "on" + host.look(110, 170) + menu.prompt("a") + host.listener.onClick() + host.listener.onRightClick() + host.listener.onScroll(1) + menu.secrets shouldBe 0 + menu.rightClicks shouldBe emptyList() + menu.scrolls shouldBe emptyList() + + host.answer(null) + menu.prompts shouldContainExactly listOf(null) + host.listener.onClick() + host.listener.onScroll(1) + menu.secrets shouldBe 1 + menu.scrolls shouldContainExactly listOf(1) + } + + "a callback that opens a prompt while another replaces it leaves that prompt pending" { + val host = FakeFullscreenHost() + val menu = Menu(host).also { it.open() } + + menu.mode = "on" + host.look(110, 170) + menu.prompt("a") { menu.prompt("c") } + menu.prompt("b") + menu.prompts shouldContainExactly listOf(null, null) + host.listener.onClick() + menu.secrets shouldBe 0 + + host.answer("text") + menu.prompts shouldContainExactly listOf(null, null, "text") + host.listener.onClick() + menu.secrets shouldBe 1 + } + + "closing with a prompt open reports null and runs onClose even when the callback prompts again or throws" { + val host = FakeFullscreenHost() + val menu = Menu(host) + val session = menu.open() + + menu.prompt("a") { + menu.prompt("b") + error("boom") + } + shouldThrow { session.close() } + + menu.prompts shouldContainExactly listOf(null, null) + menu.closes shouldBe 1 + host.closes shouldBe 1 + host.pending shouldBe null + } + "horse mode is refused" { val host = FakeFullscreenHost() shouldThrow { Menu(host, FullscreenMode.HORSE).open() } @@ -146,6 +216,9 @@ private class Menu( var plays = 0 var secrets = 0 var closes = 0 + val rightClicks = mutableListOf() + val scrolls = mutableListOf() + val prompts = mutableListOf() override fun FullscreenScope.bind() { slot("count") { Component.text("N$count") } @@ -155,6 +228,16 @@ private class Menu( count++ } button("secret") { secrets++ } + rightClick { rightClicks += it } + scroll { scrolls += it } + } + + fun prompt( + initial: String, + then: () -> Unit = {}, + ) = promptText(initial = initial) { + prompts += it + then() } override fun onClose() { @@ -168,7 +251,9 @@ private class FakeFullscreenHost : val scheduler = ManualScheduler() val shown = mutableListOf() val turns = mutableListOf() + var pending: ((String?) -> Unit)? = null var closes = 0 + private var open = true lateinit var listener: FullscreenListener override val latencyMillis = 50 @@ -196,8 +281,30 @@ private class FakeFullscreenHost : turns += Turn(yaw, pitch) } + override val promptOpen get() = pending != null + + override fun promptText( + title: Component, + initial: String, + onDone: (String?) -> Unit, + ) { + if (!open) return onDone(null) + val replaced = pending + pending = onDone + replaced?.invoke(null) + } + override fun close() { closes++ + open = false + answer(null) + } + + /** Ends the pending prompt with [text], as a button press or a container takeover does. */ + fun answer(text: String?) { + val done = pending ?: return + pending = null + done(text) } /** Turns the player so the cursor lands on canvas pixel ([x], [y]). */ diff --git a/plugin/test/golden/fullscreen/expected.sha256 b/plugin/test/golden/fullscreen/expected.sha256 index 8fd0acf6..ee2c86ce 100644 --- a/plugin/test/golden/fullscreen/expected.sha256 +++ b/plugin/test/golden/fullscreen/expected.sha256 @@ -21,8 +21,8 @@ a962b9aee37baf82e29390badf062f445b50a8e577de3731aa0947fcdbbd9222 dist/assets/wi eacb1a012fc0820bb358ea06380857fd97d62a420932142014ac89bcc4afbbc3 dist/assets/window/textures/gui/hitbox.png 9481e534191064dd3f83ebb97a6ba3ca0b967c442808258b0817defd0ef13d61 dist/assets/window/window/debug.json 65dd34ebb2d97b0c2f9ba3e310bc02fe652f4690a069e47d8b3aa99f76a94bfc dist/pack.mcmeta -dfda9d83b4d15cc55829592524be316592ec273f851c54a0991791f026dee0ae kotlin-out/LookoutFullscreen.kt -3983b91d0826ee55ddd9b07526240d8a32c42e16023fec957917bbdb5759c367 kotlin-out/MenuFullscreen.kt +23bf284dd31ac909286bdae4abd75e24f127fe9c720c9bd0cac4872c6f130a46 kotlin-out/LookoutFullscreen.kt +92d2f1d01598c590df7cd43619bd3b83d47048a6955a714733dabe7a25d64fc4 kotlin-out/MenuFullscreen.kt f30b961fdca503fa68f87b140e31c8a42b48ad830a84c029fc7a7435760bfff9 kotlin-out/WindowDefinitions.kt 2c40c9884ffe5ae1a29707d1195e2a15ad469816e166981e0be0b73b1e1a3696 kotlin-out/WindowEntries.kt 87e25adf61686af7eaad080edd2f923102aab006421bef2c7663dfd8e725e74e kotlin-out/WindowFonts.kt