diff --git a/api/src/main/java/com/velocitypowered/api/proxy/Player.java b/api/src/main/java/com/velocitypowered/api/proxy/Player.java index 6d1fa94df..50b3ae7e4 100644 --- a/api/src/main/java/com/velocitypowered/api/proxy/Player.java +++ b/api/src/main/java/com/velocitypowered/api/proxy/Player.java @@ -6,6 +6,7 @@ import com.velocitypowered.api.proxy.messages.ChannelMessageSource; import com.velocitypowered.api.proxy.player.PlayerSettings; import com.velocitypowered.api.proxy.server.RegisteredServer; import com.velocitypowered.api.util.MessagePosition; +import com.velocitypowered.api.util.title.Title; import net.kyori.text.Component; import org.checkerframework.checker.nullness.qual.NonNull; @@ -83,6 +84,12 @@ public interface Player extends CommandSource, InboundConnection, ChannelMessage */ void disconnect(Component reason); + /** + * Sends the specified title to the client. + * @param title the title to send + */ + void sendTitle(Title title); + /** * Sends chat input onto the players current server as if they typed it * into the client chat box. diff --git a/api/src/main/java/com/velocitypowered/api/util/title/TextTitle.java b/api/src/main/java/com/velocitypowered/api/util/title/TextTitle.java new file mode 100644 index 000000000..53a84fe45 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/util/title/TextTitle.java @@ -0,0 +1,236 @@ +package com.velocitypowered.api.util.title; + +import com.google.common.base.Preconditions; +import net.kyori.text.Component; +import org.checkerframework.checker.nullness.qual.Nullable; + +import java.util.Objects; +import java.util.Optional; + +/** + * Represents a "full" title, including all components. This class is immutable. + */ +public class TextTitle implements Title { + private final Component title; + private final Component subtitle; + private final int stay; + private final int fadeIn; + private final int fadeOut; + private final boolean resetBeforeSend; + + private TextTitle(Builder builder) { + this.title = builder.title; + this.subtitle = builder.subtitle; + this.stay = builder.stay; + this.fadeIn = builder.fadeIn; + this.fadeOut = builder.fadeOut; + this.resetBeforeSend = builder.resetBeforeSend; + } + + /** + * Returns the main title this title has, if any. + * @return the main title of this title + */ + public Optional getTitle() { + return Optional.ofNullable(title); + } + + /** + * Returns the subtitle this title has, if any. + * @return the subtitle + */ + public Optional getSubtitle() { + return Optional.ofNullable(subtitle); + } + + /** + * Returns the number of ticks this title will stay up. + * @return how long the title will stay, in ticks + */ + public int getStay() { + return stay; + } + + /** + * Returns the number of ticks over which this title will fade in. + * @return how long the title will fade in, in ticks + */ + public int getFadeIn() { + return fadeIn; + } + + /** + * Returns the number of ticks over which this title will fade out. + * @return how long the title will fade out, in ticks + */ + public int getFadeOut() { + return fadeOut; + } + + /** + * Returns whether or not a reset packet will be sent before this title is sent. By default, unless explicitly + * disabled, this is enabled by default. + * @return whether or not a reset packet will be sent before this title is sent + */ + public boolean isResetBeforeSend() { + return resetBeforeSend; + } + + /** + * Determines whether or not this title has times set on it. If none are set, it will update the previous title + * set on the client. + * @return whether or not this title has times set on it + */ + public boolean areTimesSet() { + return stay != 0 || fadeIn != 0 || fadeOut != 0; + } + + /** + * Creates a new builder from the contents of this title so that it may be changed. + * @return a builder instance with the contents of this title + */ + public Builder toBuilder() { + return new Builder(this); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TextTitle textTitle = (TextTitle) o; + return stay == textTitle.stay && + fadeIn == textTitle.fadeIn && + fadeOut == textTitle.fadeOut && + resetBeforeSend == textTitle.resetBeforeSend && + Objects.equals(title, textTitle.title) && + Objects.equals(subtitle, textTitle.subtitle); + } + + @Override + public String toString() { + return "TextTitle{" + + "title=" + title + + ", subtitle=" + subtitle + + ", stay=" + stay + + ", fadeIn=" + fadeIn + + ", fadeOut=" + fadeOut + + ", resetBeforeSend=" + resetBeforeSend + + '}'; + } + + @Override + public int hashCode() { + return Objects.hash(title, subtitle, stay, fadeIn, fadeOut, resetBeforeSend); + } + + /** + * Creates a new builder for constructing titles. + * @return a builder for constructing titles + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private @Nullable Component title; + private @Nullable Component subtitle; + private int stay; + private int fadeIn; + private int fadeOut; + private boolean resetBeforeSend = true; + + private Builder() {} + + private Builder(TextTitle copy) { + this.title = copy.title; + this.subtitle = copy.subtitle; + this.stay = copy.stay; + this.fadeIn = copy.fadeIn; + this.fadeOut = copy.fadeOut; + this.resetBeforeSend = copy.resetBeforeSend; + } + + public Builder title(Component title) { + this.title = Preconditions.checkNotNull(title, "title"); + return this; + } + + public Builder clearTitle() { + this.title = null; + return this; + } + + public Builder subtitle(Component subtitle) { + this.subtitle = Preconditions.checkNotNull(subtitle, "subtitle"); + return this; + } + + public Builder clearSubtitle() { + this.subtitle = null; + return this; + } + + public Builder stay(int ticks) { + Preconditions.checkArgument(ticks >= 0, "ticks value %s is negative", ticks); + this.stay = ticks; + return this; + } + + public Builder fadeIn(int ticks) { + Preconditions.checkArgument(ticks >= 0, "ticks value %s is negative", ticks); + this.fadeIn = ticks; + return this; + } + + public Builder fadeOut(int ticks) { + Preconditions.checkArgument(ticks >= 0, "ticks value %s is negative", ticks); + this.fadeOut = ticks; + return this; + } + + public Builder resetBeforeSend(boolean b) { + this.resetBeforeSend = b; + return this; + } + + public Component getTitle() { + return title; + } + + public Component getSubtitle() { + return subtitle; + } + + public int getStay() { + return stay; + } + + public int getFadeIn() { + return fadeIn; + } + + public int getFadeOut() { + return fadeOut; + } + + public boolean isResetBeforeSend() { + return resetBeforeSend; + } + + public TextTitle build() { + return new TextTitle(this); + } + + @Override + public String toString() { + return "Builder{" + + "title=" + title + + ", subtitle=" + subtitle + + ", stay=" + stay + + ", fadeIn=" + fadeIn + + ", fadeOut=" + fadeOut + + ", resetBeforeSend=" + resetBeforeSend + + '}'; + } + } +} diff --git a/api/src/main/java/com/velocitypowered/api/util/title/Title.java b/api/src/main/java/com/velocitypowered/api/util/title/Title.java new file mode 100644 index 000000000..6849e38a4 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/util/title/Title.java @@ -0,0 +1,7 @@ +package com.velocitypowered.api.util.title; + +/** + * Represents a title that can be sent to a Minecraft client. + */ +public interface Title { +} diff --git a/api/src/main/java/com/velocitypowered/api/util/title/Titles.java b/api/src/main/java/com/velocitypowered/api/util/title/Titles.java new file mode 100644 index 000000000..242a04411 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/util/title/Titles.java @@ -0,0 +1,50 @@ +package com.velocitypowered.api.util.title; + +/** + * Provides special-purpose titles. + */ +public class Titles { + private Titles() { + throw new AssertionError(); + } + + private static final Title RESET = new Title() { + @Override + public String toString() { + return "reset title"; + } + }; + + private static final Title HIDE = new Title() { + @Override + public String toString() { + return "hide title"; + } + }; + + /** + * Returns a title that, when sent to the client, will cause all title data to be reset and any existing title to be + * hidden. + * @return the reset title + */ + public static Title reset() { + return RESET; + } + + /** + * Returns a title that, when sent to the client, will cause any existing title to be hidden. The title may be + * restored by a {@link TextTitle} with no title or subtitle (only a time). + * @return the hide title + */ + public static Title hide() { + return HIDE; + } + + /** + * Returns a builder for {@link TextTitle}s. + * @return a builder for text titles + */ + public static TextTitle.Builder text() { + return TextTitle.builder(); + } +} diff --git a/api/src/main/java/com/velocitypowered/api/util/title/package-info.java b/api/src/main/java/com/velocitypowered/api/util/title/package-info.java new file mode 100644 index 000000000..467f57ab6 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/util/title/package-info.java @@ -0,0 +1,4 @@ +/** + * Provides data structures for creating and manipulating titles. + */ +package com.velocitypowered.api.util.title; \ No newline at end of file diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java index 0672839dc..cc50e7877 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java @@ -219,6 +219,9 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { player.getConnectedServer().getMinecraftConnection().delayedWrite(pm); } + // Clear any title from the previous server. + player.getConnection().delayedWrite(TitlePacket.resetForProtocolVersion(player.getProtocolVersion())); + // Flush everything player.getConnection().flush(); player.getConnectedServer().getMinecraftConnection().flush(); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java index 77f8722a8..9cbe478aa 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java @@ -15,6 +15,9 @@ import com.velocitypowered.api.proxy.player.PlayerSettings; import com.velocitypowered.api.proxy.server.RegisteredServer; import com.velocitypowered.api.util.GameProfile; import com.velocitypowered.api.util.MessagePosition; +import com.velocitypowered.api.util.title.TextTitle; +import com.velocitypowered.api.util.title.Title; +import com.velocitypowered.api.util.title.Titles; import com.velocitypowered.proxy.VelocityServer; import com.velocitypowered.proxy.connection.MinecraftConnection; import com.velocitypowered.proxy.connection.MinecraftConnectionAssociation; @@ -22,6 +25,7 @@ import com.velocitypowered.proxy.connection.VelocityConstants; import com.velocitypowered.proxy.connection.backend.VelocityServerConnection; import com.velocitypowered.proxy.connection.util.ConnectionMessages; import com.velocitypowered.proxy.connection.util.ConnectionRequestResults; +import com.velocitypowered.proxy.protocol.ProtocolConstants; import com.velocitypowered.proxy.protocol.packet.*; import com.velocitypowered.proxy.server.VelocityRegisteredServer; import com.velocitypowered.proxy.util.ThrowableUtils; @@ -139,11 +143,20 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { byte pos = (byte) position.ordinal(); String json; if (position == MessagePosition.ACTION_BAR) { - // Due to issues with action bar packets, we'll need to convert the text message into a legacy message - // and then inject the legacy text into a component... yuck! - JsonObject object = new JsonObject(); - object.addProperty("text", ComponentSerializers.LEGACY.serialize(component)); - json = VelocityServer.GSON.toJson(object); + if (getProtocolVersion() >= ProtocolConstants.MINECRAFT_1_11) { + // We can use the title packet instead. + TitlePacket pkt = new TitlePacket(); + pkt.setAction(TitlePacket.SET_ACTION_BAR); + pkt.setComponent(ComponentSerializers.JSON.serialize(component)); + connection.write(pkt); + return; + } else { + // Due to issues with action bar packets, we'll need to convert the text message into a legacy message + // and then inject the legacy text into a component... yuck! + JsonObject object = new JsonObject(); + object.addProperty("text", ComponentSerializers.LEGACY.serialize(component)); + json = VelocityServer.GSON.toJson(object); + } } else { json = ComponentSerializers.JSON.serialize(component); } @@ -176,6 +189,48 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { connection.closeWith(Disconnect.create(reason)); } + @Override + public void sendTitle(Title title) { + Preconditions.checkNotNull(title, "title"); + + if (title.equals(Titles.reset())) { + connection.write(TitlePacket.resetForProtocolVersion(connection.getProtocolVersion())); + } else if (title.equals(Titles.hide())) { + connection.write(TitlePacket.hideForProtocolVersion(connection.getProtocolVersion())); + } else if (title instanceof TextTitle) { + TextTitle tt = (TextTitle) title; + + if (tt.isResetBeforeSend()) { + connection.delayedWrite(TitlePacket.resetForProtocolVersion(connection.getProtocolVersion())); + } + + if (tt.getTitle().isPresent()) { + TitlePacket titlePkt = new TitlePacket(); + titlePkt.setAction(TitlePacket.SET_TITLE); + titlePkt.setComponent(ComponentSerializers.JSON.serialize(tt.getTitle().get())); + connection.delayedWrite(titlePkt); + } + if (tt.getSubtitle().isPresent()) { + TitlePacket titlePkt = new TitlePacket(); + titlePkt.setAction(TitlePacket.SET_SUBTITLE); + titlePkt.setComponent(ComponentSerializers.JSON.serialize(tt.getSubtitle().get())); + connection.delayedWrite(titlePkt); + } + + if (tt.areTimesSet()) { + TitlePacket timesPkt = TitlePacket.timesForProtocolVersion(connection.getProtocolVersion()); + timesPkt.setFadeIn(tt.getFadeIn()); + timesPkt.setStay(tt.getStay()); + timesPkt.setFadeOut(tt.getFadeOut()); + connection.delayedWrite(timesPkt); + } + connection.flush(); + } else { + throw new IllegalArgumentException("Unknown title class " + title.getClass().getName()); + } + + } + public VelocityServerConnection getConnectedServer() { return connectedServer; } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java index 8f702ee6b..3c92cfe4c 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java @@ -136,6 +136,12 @@ public enum StateRegistry { map(0x44, MINECRAFT_1_12, true), map(0x45, MINECRAFT_1_12_1, true), map(0x48, MINECRAFT_1_13, true)); + CLIENTBOUND.register(TitlePacket.class, TitlePacket::new, + map(0x45, MINECRAFT_1_8, true), + map(0x45, MINECRAFT_1_9, true), + map(0x47, MINECRAFT_1_12, true), + map(0x48, MINECRAFT_1_12_1, true), + map(0x4B, MINECRAFT_1_13, true)); } }, LOGIN { diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/TitlePacket.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/TitlePacket.java new file mode 100644 index 000000000..de1398060 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/TitlePacket.java @@ -0,0 +1,137 @@ +package com.velocitypowered.proxy.protocol.packet; + +import com.google.common.base.Preconditions; +import com.velocitypowered.proxy.protocol.MinecraftPacket; +import com.velocitypowered.proxy.protocol.ProtocolConstants; +import com.velocitypowered.proxy.protocol.ProtocolUtils; +import io.netty.buffer.ByteBuf; + +public class TitlePacket implements MinecraftPacket { + public static final int SET_TITLE = 0; + public static final int SET_SUBTITLE = 1; + public static final int SET_ACTION_BAR = 2; + public static final int SET_TIMES = 3; + public static final int SET_TIMES_OLD = 2; + public static final int HIDE = 4; + public static final int HIDE_OLD = 3; + public static final int RESET = 5; + public static final int RESET_OLD = 4; + + private int action; + private String component; + private int fadeIn; + private int stay; + private int fadeOut; + + @Override + public void decode(ByteBuf buf, ProtocolConstants.Direction direction, int protocolVersion) { + throw new UnsupportedOperationException(); // encode only + } + + @Override + public void encode(ByteBuf buf, ProtocolConstants.Direction direction, int protocolVersion) { + ProtocolUtils.writeVarInt(buf, action); + if (protocolVersion >= ProtocolConstants.MINECRAFT_1_11) { + // 1.11+ shifted the action enum by 1 to handle the action bar + switch (action) { + case SET_TITLE: + case SET_SUBTITLE: + case SET_ACTION_BAR: + ProtocolUtils.writeString(buf, component); + break; + case SET_TIMES: + buf.writeInt(fadeIn); + buf.writeInt(stay); + buf.writeInt(fadeOut); + break; + case HIDE: + case RESET: + break; + } + } else { + switch (action) { + case SET_TITLE: + case SET_SUBTITLE: + ProtocolUtils.writeString(buf, component); + break; + case SET_TIMES_OLD: + buf.writeInt(fadeIn); + buf.writeInt(stay); + buf.writeInt(fadeOut); + break; + case HIDE_OLD: + case RESET_OLD: + break; + } + } + } + + public int getAction() { + return action; + } + + public void setAction(int action) { + this.action = action; + } + + public String getComponent() { + return component; + } + + public void setComponent(String component) { + this.component = component; + } + + public int getFadeIn() { + return fadeIn; + } + + public void setFadeIn(int fadeIn) { + this.fadeIn = fadeIn; + } + + public int getStay() { + return stay; + } + + public void setStay(int stay) { + this.stay = stay; + } + + public int getFadeOut() { + return fadeOut; + } + + public void setFadeOut(int fadeOut) { + this.fadeOut = fadeOut; + } + + public static TitlePacket hideForProtocolVersion(int protocolVersion) { + TitlePacket packet = new TitlePacket(); + packet.setAction(protocolVersion >= ProtocolConstants.MINECRAFT_1_11 ? TitlePacket.HIDE : TitlePacket.HIDE_OLD); + return packet; + } + + public static TitlePacket resetForProtocolVersion(int protocolVersion) { + TitlePacket packet = new TitlePacket(); + packet.setAction(protocolVersion >= ProtocolConstants.MINECRAFT_1_11 ? TitlePacket.RESET : TitlePacket.RESET_OLD); + return packet; + } + + public static TitlePacket timesForProtocolVersion(int protocolVersion) { + TitlePacket packet = new TitlePacket(); + packet.setAction(protocolVersion >= ProtocolConstants.MINECRAFT_1_11 ? TitlePacket.SET_TIMES : TitlePacket.SET_TIMES_OLD); + return packet; + } + + @Override + public String toString() { + return "TitlePacket{" + + "action=" + action + + ", component='" + component + '\'' + + ", fadeIn=" + fadeIn + + ", stay=" + stay + + ", fadeOut=" + fadeOut + + '}'; + } +}