From d778129ead1e3074ec617933b19a89d1fb5873cb Mon Sep 17 00:00:00 2001 From: Sam van Remortel Date: Sun, 24 May 2026 18:46:16 +0200 Subject: [PATCH] Added auto-accept, player heads beside party. --- README.md | 15 + gradle.properties | 2 +- .../client/ClientPartySettings.java | 20 ++ .../vaultpartyui/client/ClientTickEvents.java | 26 ++ .../client/screen/FilterMode.java | 7 + .../client/screen/OnlinePlayer.java | 13 + .../vaultpartyui/client/screen/OnlineRow.java | 11 + .../client/screen/PartyRosterService.java | 132 ++++++++ .../client/screen/PartyScreen.java | 315 +++++++++++++----- .../client/screen/RowPresentation.java | 72 ++++ .../vaultpartyui/client/screen/RowState.java | 10 + .../vaultpartyui/client/screen/UiToast.java | 15 + .../assets/vaultpartyui/lang/en_us.json | 22 +- 13 files changed, 566 insertions(+), 94 deletions(-) create mode 100644 src/main/java/dev/massuus/vaultpartyui/client/ClientPartySettings.java create mode 100644 src/main/java/dev/massuus/vaultpartyui/client/screen/FilterMode.java create mode 100644 src/main/java/dev/massuus/vaultpartyui/client/screen/OnlinePlayer.java create mode 100644 src/main/java/dev/massuus/vaultpartyui/client/screen/OnlineRow.java create mode 100644 src/main/java/dev/massuus/vaultpartyui/client/screen/PartyRosterService.java create mode 100644 src/main/java/dev/massuus/vaultpartyui/client/screen/RowPresentation.java create mode 100644 src/main/java/dev/massuus/vaultpartyui/client/screen/RowState.java create mode 100644 src/main/java/dev/massuus/vaultpartyui/client/screen/UiToast.java diff --git a/README.md b/README.md index 4daa71e..749e0ec 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,21 @@ Client-side Forge mod for Vault Hunters (Minecraft 1.18.2) that provides a party - Party member list panel. - Online player list with per-player `Invite` / `Remove` actions. +## Changelog + +### 1.1.0 + +- Added auto-accept invites toggle that works even when the screen is closed. +- Added player heads beside party and online player names. +- Improved row states, invite cooldown handling, and inline UI feedback. +- Refined layout spacing and removed the extra filter button/context text. +- Split party screen logic into helper classes to keep the screen file smaller. + +### 1.0.0 + +- Initial release of the Vault Party UI. +- Added the party management screen, keybind, and core invite/remove actions. + ## Requirements - Minecraft `1.18.2` diff --git a/gradle.properties b/gradle.properties index cfbf113..9464d20 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,5 +8,5 @@ mapping_version=1.18.2 mod_id=vaultpartyui mod_name=Vault Party UI -mod_version=1.0.0 +mod_version=1.1.0 mod_group_id=dev.massuus.vaultpartyui diff --git a/src/main/java/dev/massuus/vaultpartyui/client/ClientPartySettings.java b/src/main/java/dev/massuus/vaultpartyui/client/ClientPartySettings.java new file mode 100644 index 0000000..93f1b83 --- /dev/null +++ b/src/main/java/dev/massuus/vaultpartyui/client/ClientPartySettings.java @@ -0,0 +1,20 @@ +package dev.massuus.vaultpartyui.client; + +public final class ClientPartySettings { + private static boolean autoAcceptInvitesEnabled; + + private ClientPartySettings() { + } + + public static boolean isAutoAcceptInvitesEnabled() { + return autoAcceptInvitesEnabled; + } + + public static void setAutoAcceptInvitesEnabled(boolean enabled) { + autoAcceptInvitesEnabled = enabled; + } + + public static void toggleAutoAcceptInvites() { + autoAcceptInvitesEnabled = !autoAcceptInvitesEnabled; + } +} diff --git a/src/main/java/dev/massuus/vaultpartyui/client/ClientTickEvents.java b/src/main/java/dev/massuus/vaultpartyui/client/ClientTickEvents.java index 86c7245..5ae8721 100644 --- a/src/main/java/dev/massuus/vaultpartyui/client/ClientTickEvents.java +++ b/src/main/java/dev/massuus/vaultpartyui/client/ClientTickEvents.java @@ -1,14 +1,21 @@ package dev.massuus.vaultpartyui.client; import dev.massuus.vaultpartyui.VaultPartyUiMod; +import iskallia.vault.client.data.ClientPartyData; +import iskallia.vault.client.data.ClientPartyInviteState; import dev.massuus.vaultpartyui.client.screen.PartyScreen; +import iskallia.vault.network.message.ServerboundPartyInviteResponseMessage; import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.TranslatableComponent; import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.client.event.InputEvent; import net.minecraftforge.event.TickEvent; import net.minecraftforge.eventbus.api.SubscribeEvent; import net.minecraftforge.fml.common.Mod; +import java.util.UUID; + @Mod.EventBusSubscriber(modid = VaultPartyUiMod.MODID, value = Dist.CLIENT, bus = Mod.EventBusSubscriber.Bus.FORGE) public final class ClientTickEvents { private ClientTickEvents() { @@ -26,5 +33,24 @@ public final class ClientTickEvents { minecraft.setScreen(new PartyScreen(minecraft.screen)); } } + + if (minecraft.player == null) { + return; + } + + if (ClientPartySettings.isAutoAcceptInvitesEnabled() + && ClientPartyData.getParty(minecraft.player.getUUID()) == null + && ClientPartyInviteState.hasPendingInvite()) { + String inviterName = ClientPartyInviteState.getInviterName(); + UUID inviteId = ClientPartyInviteState.getInviteId(); + if (inviteId != null) { + ServerboundPartyInviteResponseMessage.send(inviteId, true); + ClientPartyInviteState.clearInvite(); + if (inviterName != null && !inviterName.isEmpty()) { + Component msg = new TranslatableComponent("screen.vaultpartyui.auto_accepted", inviterName); + minecraft.player.displayClientMessage(msg, false); + } + } + } } } diff --git a/src/main/java/dev/massuus/vaultpartyui/client/screen/FilterMode.java b/src/main/java/dev/massuus/vaultpartyui/client/screen/FilterMode.java new file mode 100644 index 0000000..9b56f04 --- /dev/null +++ b/src/main/java/dev/massuus/vaultpartyui/client/screen/FilterMode.java @@ -0,0 +1,7 @@ +package dev.massuus.vaultpartyui.client.screen; + +enum FilterMode { + ALL, + ACTIONABLE, + OTHER_PARTY +} diff --git a/src/main/java/dev/massuus/vaultpartyui/client/screen/OnlinePlayer.java b/src/main/java/dev/massuus/vaultpartyui/client/screen/OnlinePlayer.java new file mode 100644 index 0000000..f0a175a --- /dev/null +++ b/src/main/java/dev/massuus/vaultpartyui/client/screen/OnlinePlayer.java @@ -0,0 +1,13 @@ +package dev.massuus.vaultpartyui.client.screen; + +import java.util.UUID; + +final class OnlinePlayer { + final UUID id; + final String name; + + OnlinePlayer(UUID id, String name) { + this.id = id; + this.name = name; + } +} diff --git a/src/main/java/dev/massuus/vaultpartyui/client/screen/OnlineRow.java b/src/main/java/dev/massuus/vaultpartyui/client/screen/OnlineRow.java new file mode 100644 index 0000000..13bfe51 --- /dev/null +++ b/src/main/java/dev/massuus/vaultpartyui/client/screen/OnlineRow.java @@ -0,0 +1,11 @@ +package dev.massuus.vaultpartyui.client.screen; + +final class OnlineRow { + final OnlinePlayer player; + final RowState state; + + OnlineRow(OnlinePlayer player, RowState state) { + this.player = player; + this.state = state; + } +} diff --git a/src/main/java/dev/massuus/vaultpartyui/client/screen/PartyRosterService.java b/src/main/java/dev/massuus/vaultpartyui/client/screen/PartyRosterService.java new file mode 100644 index 0000000..5f7beab --- /dev/null +++ b/src/main/java/dev/massuus/vaultpartyui/client/screen/PartyRosterService.java @@ -0,0 +1,132 @@ +package dev.massuus.vaultpartyui.client.screen; + +import iskallia.vault.client.data.ClientPartyData; +import iskallia.vault.world.data.VaultPartyData.Party; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +final class PartyRosterService { + private PartyRosterService() { + } + + static List buildRows( + List players, + FilterMode filterMode, + Party currentParty, + UUID localPlayerId, + Map inviteCooldownUntilMs + ) { + if (players == null || players.isEmpty()) { + return java.util.Collections.emptyList(); + } + + List rows = new ArrayList<>(); + for (OnlinePlayer player : players) { + RowState state = resolveRowState(player, currentParty, localPlayerId, inviteCooldownUntilMs); + if (filterMode == FilterMode.ACTIONABLE && state != RowState.INVITEABLE && state != RowState.PARTY_MEMBER) { + continue; + } + if (filterMode == FilterMode.OTHER_PARTY && state != RowState.OTHER_PARTY) { + continue; + } + rows.add(new OnlineRow(player, state)); + } + + rows.sort((a, b) -> { + int p = Integer.compare(rowStatePriority(a.state), rowStatePriority(b.state)); + if (p != 0) return p; + return a.player.name.compareToIgnoreCase(b.player.name); + }); + + return rows; + } + + static boolean isPartyLeader(Party currentParty, UUID localPlayerId) { + if (currentParty == null || localPlayerId == null) return false; + UUID leader = currentParty.getLeader(); + return leader != null && leader.equals(localPlayerId); + } + + static boolean isLocalPlayerInParty(Party currentParty, UUID localPlayerId) { + if (currentParty == null || localPlayerId == null) return false; + UUID leader = currentParty.getLeader(); + if (leader != null && leader.equals(localPlayerId)) return true; + List members = currentParty.getMembers(); + if (members != null) { + for (UUID m : members) { + if (localPlayerId.equals(m)) return true; + } + } + return false; + } + + static boolean isPlayerInCurrentParty(Party currentParty, UUID playerId) { + if (currentParty == null || playerId == null) return false; + UUID leader = currentParty.getLeader(); + if (leader != null && leader.equals(playerId)) return true; + List members = currentParty.getMembers(); + if (members != null) { + for (UUID memberId : members) { + if (playerId.equals(memberId)) return true; + } + } + return false; + } + + static boolean isPlayerInOtherParty(Party currentParty, UUID playerId) { + if (playerId == null) return false; + Party party = ClientPartyData.getParty(playerId); + if (party == null) return false; + return !isPlayerInCurrentParty(currentParty, playerId); + } + + static void pruneCooldowns(Map cooldownMap, long nowMs) { + if (cooldownMap == null || cooldownMap.isEmpty()) { + return; + } + java.util.Iterator> it = cooldownMap.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = it.next(); + if (e.getValue() == null || e.getValue() <= nowMs) { + it.remove(); + } + } + } + + private static RowState resolveRowState( + OnlinePlayer player, + Party currentParty, + UUID localPlayerId, + Map inviteCooldownUntilMs + ) { + if (player == null || player.id == null) return RowState.NO_ACTION; + if (localPlayerId != null && localPlayerId.equals(player.id)) return RowState.SELF; + if (isPlayerInOtherParty(currentParty, player.id)) return RowState.OTHER_PARTY; + if (!isLocalPlayerInParty(currentParty, localPlayerId)) return RowState.NO_ACTION; + if (isPlayerInCurrentParty(currentParty, player.id)) return RowState.PARTY_MEMBER; + Long until = inviteCooldownUntilMs == null ? null : inviteCooldownUntilMs.get(player.id); + if (until != null && until > System.currentTimeMillis()) return RowState.COOLDOWN; + return RowState.INVITEABLE; + } + + private static int rowStatePriority(RowState state) { + if (state == null) return 99; + switch (state) { + case INVITEABLE: + return 0; + case COOLDOWN: + return 1; + case PARTY_MEMBER: + return 2; + case OTHER_PARTY: + return 3; + case SELF: + return 4; + default: + return 5; + } + } +} diff --git a/src/main/java/dev/massuus/vaultpartyui/client/screen/PartyScreen.java b/src/main/java/dev/massuus/vaultpartyui/client/screen/PartyScreen.java index ac8b3a0..9ed9ea4 100644 --- a/src/main/java/dev/massuus/vaultpartyui/client/screen/PartyScreen.java +++ b/src/main/java/dev/massuus/vaultpartyui/client/screen/PartyScreen.java @@ -3,6 +3,7 @@ package dev.massuus.vaultpartyui.client.screen; import com.mojang.authlib.GameProfile; import com.mojang.blaze3d.systems.RenderSystem; import com.mojang.blaze3d.vertex.PoseStack; +import dev.massuus.vaultpartyui.client.ClientPartySettings; import iskallia.vault.client.data.ClientPartyData; import iskallia.vault.client.data.ClientPartyInviteState; import iskallia.vault.network.message.ServerboundPartyInviteResponseMessage; @@ -24,20 +25,25 @@ import net.minecraft.util.Mth; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.HashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.UUID; +import org.lwjgl.glfw.GLFW; public class PartyScreen extends Screen { private static final int BUTTON_WIDTH = 90; private static final int BUTTON_HEIGHT = 20; private static final int BUTTON_GAP = 4; - private static final int PANEL_TOP = 100; + private static final int PANEL_TOP = 124; private static final int PANEL_HEIGHT = 155; private static final int PANEL_PADDING = 10; private static final int ONLINE_ROW_HEIGHT = 14; private static final int VISIBLE_ONLINE_ROWS = 8; private static final int HEAD_SIZE = 8; + private static final long INVITE_COOLDOWN_MS = 8000L; + private static final int STATE_REFRESH_INTERVAL_TICKS = 4; private final Screen parentScreen; @@ -51,7 +57,12 @@ public class PartyScreen extends Screen { private Button inviteAllButton; private Button acceptInviteButton; private Button declineInviteButton; + private Button autoAcceptToggleButton; private int onlineScrollOffset; + private int selectedOnlineIndex = -1; + private int stateRefreshTicks; + private final Map inviteCooldownUntilMs = new HashMap<>(); + private final List toasts = new ArrayList<>(); public PartyScreen(Screen parentScreen) { super(new TranslatableComponent("screen.vaultpartyui.title")); @@ -67,18 +78,23 @@ public class PartyScreen extends Screen { int rowWidth = BUTTON_WIDTH * 3 + BUTTON_GAP * 2; int rowX = centerX - rowWidth / 2; - this.createPartyButton = addRenderableWidget(new Button(rowX, 24, BUTTON_WIDTH, BUTTON_HEIGHT, new TranslatableComponent("screen.vaultpartyui.create"), button -> sendPartyCommand("party create"))); - this.leavePartyButton = addRenderableWidget(new Button(rowX + BUTTON_WIDTH + BUTTON_GAP, 24, BUTTON_WIDTH, BUTTON_HEIGHT, new TranslatableComponent("screen.vaultpartyui.leave"), button -> sendPartyCommand("party leave"))); - this.disbandPartyButton = addRenderableWidget(new Button(rowX + (BUTTON_WIDTH + BUTTON_GAP) * 2, 24, BUTTON_WIDTH, BUTTON_HEIGHT, new TranslatableComponent("screen.vaultpartyui.disband"), button -> sendPartyCommand("party disband"))); + this.createPartyButton = addRenderableWidget(new Button(rowX, 34, BUTTON_WIDTH, BUTTON_HEIGHT, new TranslatableComponent("screen.vaultpartyui.create"), button -> sendPartyCommand("party create"))); + this.leavePartyButton = addRenderableWidget(new Button(rowX + BUTTON_WIDTH + BUTTON_GAP, 34, BUTTON_WIDTH, BUTTON_HEIGHT, new TranslatableComponent("screen.vaultpartyui.leave"), button -> sendPartyCommand("party leave"))); + this.disbandPartyButton = addRenderableWidget(new Button(rowX + (BUTTON_WIDTH + BUTTON_GAP) * 2, 34, BUTTON_WIDTH, BUTTON_HEIGHT, new TranslatableComponent("screen.vaultpartyui.disband"), button -> sendPartyCommand("party disband"))); - this.inviteNearbyButton = addRenderableWidget(new Button(centerX - BUTTON_WIDTH - (BUTTON_GAP / 2), 48, BUTTON_WIDTH, BUTTON_HEIGHT, new TranslatableComponent("screen.vaultpartyui.invite_nearby"), button -> sendPartyCommand("party invite nearby"))); - this.inviteAllButton = addRenderableWidget(new Button(centerX + (BUTTON_GAP / 2), 48, BUTTON_WIDTH, BUTTON_HEIGHT, new TranslatableComponent("screen.vaultpartyui.invite_all"), button -> sendPartyCommand("party invite all"))); + this.inviteNearbyButton = addRenderableWidget(new Button(centerX - BUTTON_WIDTH - (BUTTON_GAP / 2), 62, BUTTON_WIDTH, BUTTON_HEIGHT, new TranslatableComponent("screen.vaultpartyui.invite_nearby"), button -> sendPartyCommand("party invite nearby"))); + this.inviteAllButton = addRenderableWidget(new Button(centerX + (BUTTON_GAP / 2), 62, BUTTON_WIDTH, BUTTON_HEIGHT, new TranslatableComponent("screen.vaultpartyui.invite_all"), button -> sendPartyCommand("party invite all"))); int inviteButtonWidth = 140; int inviteButtonX = centerX - inviteButtonWidth - 4; int declineButtonX = centerX + 4; - this.acceptInviteButton = addRenderableWidget(new Button(inviteButtonX, 72, inviteButtonWidth, BUTTON_HEIGHT, new TranslatableComponent("screen.vaultpartyui.accept_invite"), button -> acceptPendingInvite())); - this.declineInviteButton = addRenderableWidget(new Button(declineButtonX, 72, inviteButtonWidth, BUTTON_HEIGHT, new TranslatableComponent("screen.vaultpartyui.decline_invite"), button -> declinePendingInvite())); + this.acceptInviteButton = addRenderableWidget(new Button(inviteButtonX, 90, inviteButtonWidth, BUTTON_HEIGHT, new TranslatableComponent("screen.vaultpartyui.accept_invite"), button -> acceptPendingInvite())); + this.declineInviteButton = addRenderableWidget(new Button(declineButtonX, 90, inviteButtonWidth, BUTTON_HEIGHT, new TranslatableComponent("screen.vaultpartyui.decline_invite"), button -> declinePendingInvite())); + this.autoAcceptToggleButton = addRenderableWidget(new Button(centerX - 95, 90, 190, BUTTON_HEIGHT, autoAcceptToggleLabel(), button -> { + ClientPartySettings.toggleAutoAcceptInvites(); + updateAutoAcceptToggleLabel(); + pushToast(new TranslatableComponent(ClientPartySettings.isAutoAcceptInvitesEnabled() ? "screen.vaultpartyui.toast_auto_accept_on" : "screen.vaultpartyui.toast_auto_accept_off"), 0xE3C38C); + })); int panelWidth = (this.width - 40 - PANEL_PADDING) / 2; int targetBoxWidth = panelWidth - PANEL_PADDING * 2; @@ -93,7 +109,14 @@ public class PartyScreen extends Screen { @Override public void tick() { super.tick(); - rebuildState(); + this.stateRefreshTicks++; + if (this.stateRefreshTicks >= STATE_REFRESH_INTERVAL_TICKS) { + this.stateRefreshTicks = 0; + rebuildState(); + } + + pruneTransientState(); + if (this.targetBox != null) { this.targetBox.tick(); } @@ -154,6 +177,8 @@ public class PartyScreen extends Screen { this.font.drawShadow(poseStack, tip, tipX, tipY, 0xFFFFFF); } + renderToasts(poseStack); + } @@ -194,7 +219,7 @@ public class PartyScreen extends Screen { return super.mouseScrolled(mouseX, mouseY, scrollDelta); } - List visiblePlayers = filteredOnlinePlayers(); + List visiblePlayers = filteredOnlineRows(); int maxOffset = Math.max(0, visiblePlayers.size() - VISIBLE_ONLINE_ROWS); if (maxOffset == 0) { return true; @@ -210,21 +235,68 @@ public class PartyScreen extends Screen { this.minecraft.setScreen(this.parentScreen); } + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (this.targetBox != null && this.targetBox.isFocused()) { + return super.keyPressed(keyCode, scanCode, modifiers); + } + + List rows = filteredOnlineRows(); + if (rows.isEmpty()) { + return super.keyPressed(keyCode, scanCode, modifiers); + } + + if (keyCode == GLFW.GLFW_KEY_DOWN) { + if (this.selectedOnlineIndex < 0) { + this.selectedOnlineIndex = 0; + } else { + this.selectedOnlineIndex = Math.min(rows.size() - 1, this.selectedOnlineIndex + 1); + } + ensureSelectedRowVisible(rows.size()); + return true; + } + + if (keyCode == GLFW.GLFW_KEY_UP) { + if (this.selectedOnlineIndex < 0) { + this.selectedOnlineIndex = rows.size() - 1; + } else { + this.selectedOnlineIndex = Math.max(0, this.selectedOnlineIndex - 1); + } + ensureSelectedRowVisible(rows.size()); + return true; + } + + if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER) { + if (this.selectedOnlineIndex >= 0 && this.selectedOnlineIndex < rows.size()) { + performPrimaryRowAction(rows.get(this.selectedOnlineIndex)); + return true; + } + } + + return super.keyPressed(keyCode, scanCode, modifiers); + } + private void rebuildState() { Minecraft minecraft = Minecraft.getInstance(); if (minecraft.player == null) { this.currentParty = null; this.onlinePlayers = Collections.emptyList(); this.onlineScrollOffset = 0; + this.selectedOnlineIndex = -1; return; } this.currentParty = ClientPartyData.getParty(minecraft.player.getUUID()); this.onlinePlayers = gatherOnlinePlayers(minecraft.getConnection()); - List visiblePlayers = filteredOnlinePlayers(); + List visiblePlayers = filteredOnlineRows(); int maxOffset = Math.max(0, visiblePlayers.size() - VISIBLE_ONLINE_ROWS); this.onlineScrollOffset = Mth.clamp(this.onlineScrollOffset, 0, maxOffset); + if (visiblePlayers.isEmpty()) { + this.selectedOnlineIndex = -1; + } else { + this.selectedOnlineIndex = Mth.clamp(this.selectedOnlineIndex, 0, visiblePlayers.size() - 1); + } } private List gatherOnlinePlayers(ClientPacketListener connection) { @@ -263,6 +335,16 @@ public class PartyScreen extends Screen { return filtered; } + private List filteredOnlineRows() { + return PartyRosterService.buildRows( + filteredOnlinePlayers(), + FilterMode.ALL, + this.currentParty, + getLocalPlayerId(), + this.inviteCooldownUntilMs + ); + } + private void sendPartyCommand(String command) { Minecraft minecraft = Minecraft.getInstance(); ClientPacketListener connection = minecraft.getConnection(); @@ -298,7 +380,7 @@ public class PartyScreen extends Screen { } private void updateInviteButtons() { - boolean hasInvite = ClientPartyInviteState.hasPendingInvite() && this.currentParty == null; + boolean hasInvite = ClientPartyInviteState.hasPendingInvite() && this.currentParty == null && !ClientPartySettings.isAutoAcceptInvitesEnabled(); if (this.acceptInviteButton != null) { this.acceptInviteButton.visible = hasInvite; } @@ -308,7 +390,7 @@ public class PartyScreen extends Screen { } private void updateActionVisibility() { - boolean inParty = this.currentParty != null; + boolean inParty = isLocalPlayerInParty(); int centerX = this.width / 2; if (this.createPartyButton != null) { @@ -336,7 +418,10 @@ public class PartyScreen extends Screen { if (this.inviteAllButton != null) { this.inviteAllButton.visible = inParty; } - + if (this.autoAcceptToggleButton != null) { + this.autoAcceptToggleButton.visible = true; + this.autoAcceptToggleButton.active = true; + } // Keep the second action row centered as a pair. if (this.inviteNearbyButton != null && this.inviteAllButton != null) { int rowWidth = BUTTON_WIDTH * 2 + BUTTON_GAP; @@ -346,6 +431,17 @@ public class PartyScreen extends Screen { } updateInviteButtons(); + updateAutoAcceptToggleLabel(); + } + + private Component autoAcceptToggleLabel() { + return new TranslatableComponent(ClientPartySettings.isAutoAcceptInvitesEnabled() ? "screen.vaultpartyui.auto_accept_on" : "screen.vaultpartyui.auto_accept_off"); + } + + private void updateAutoAcceptToggleLabel() { + if (this.autoAcceptToggleButton != null) { + this.autoAcceptToggleButton.setMessage(autoAcceptToggleLabel()); + } } private void renderPartyPanel(PoseStack poseStack, int panelX, int panelWidth, int mouseX, int mouseY) { @@ -382,7 +478,7 @@ public class PartyScreen extends Screen { color = statusColor(cachedMember.status); } - drawPlayerHead(poseStack, memberId, textX, textY + 1); + drawPlayerHead(poseStack, memberId, textX, textY); this.font.draw(poseStack, line.toString(), textX + HEAD_SIZE + 4, textY, color); textY += 14; } @@ -390,16 +486,20 @@ public class PartyScreen extends Screen { private void renderOnlinePanel(PoseStack poseStack, int panelX, int panelWidth, int mouseX, int mouseY) { int textX = panelX + 10; - int boxWidth = panelWidth - 20; int listTop = PANEL_TOP + 48; int listHeight = VISIBLE_ONLINE_ROWS * ONLINE_ROW_HEIGHT + 6; fill(poseStack, panelX + 8, PANEL_TOP + 20, panelX + panelWidth - 8, PANEL_TOP + 42, 0xAA1A1A1A); this.font.draw(poseStack, new TranslatableComponent("screen.vaultpartyui.target").getString(), textX, PANEL_TOP + 24, 0xA0A0A0); - List visiblePlayers = filteredOnlinePlayers(); + List visiblePlayers = filteredOnlineRows(); int maxOffset = Math.max(0, visiblePlayers.size() - VISIBLE_ONLINE_ROWS); this.onlineScrollOffset = Mth.clamp(this.onlineScrollOffset, 0, maxOffset); + if (visiblePlayers.isEmpty()) { + this.selectedOnlineIndex = -1; + } else { + this.selectedOnlineIndex = Mth.clamp(this.selectedOnlineIndex, 0, visiblePlayers.size() - 1); + } int startIndex = this.onlineScrollOffset; int endIndex = Math.min(visiblePlayers.size(), startIndex + VISIBLE_ONLINE_ROWS); @@ -408,35 +508,33 @@ public class PartyScreen extends Screen { fill(poseStack, panelX + 8, listTop, panelX + panelWidth - 8, listTop + 1, 0xFFE3C38C); if (visiblePlayers.isEmpty()) { - this.font.draw(poseStack, "No matching players.", textX, listTop + 6, 0xA0A0A0); + this.font.draw(poseStack, new TranslatableComponent("screen.vaultpartyui.no_matching").getString(), textX, listTop + 6, 0xA0A0A0); return; } int rowY = listTop + 4; for (int index = startIndex; index < endIndex; index++) { - OnlinePlayer player = visiblePlayers.get(index); + OnlineRow row = visiblePlayers.get(index); + OnlinePlayer player = row.player; boolean hovered = mouseX >= panelX + 10 && mouseX <= panelX + panelWidth - 10 && mouseY >= rowY - 2 && mouseY < rowY + ONLINE_ROW_HEIGHT - 2; - int background = hovered ? 0x663C3122 : 0x00000000; + boolean selected = index == this.selectedOnlineIndex; + int background = RowPresentation.backgroundColor(row.state, hovered, selected); // draw player name and per-row action (invite/remove) this.fill(poseStack, panelX + 10, rowY - 2, panelX + panelWidth - 10, rowY + ONLINE_ROW_HEIGHT - 2, background); - drawPlayerHead(poseStack, player.id, panelX + 12, rowY + 1); - this.font.draw(poseStack, player.name, panelX + 12 + HEAD_SIZE + 4, rowY, 0xFFFFFF); + drawPlayerHead(poseStack, player.id, panelX + 12, rowY); + this.font.draw(poseStack, player.name, panelX + 12 + HEAD_SIZE + 4, rowY, RowPresentation.nameColor(row.state)); - // skip drawing actions for self - if (!player.id.equals(getLocalPlayerId())) { - int actionX = panelX + panelWidth - 84; - boolean targetIsInParty = isPlayerInCurrentParty(player.id); - if (isLocalPlayerInParty()) { - if (targetIsInParty) { - // Remove button for current members, leader only - if (isPartyLeader()) { - this.font.draw(poseStack, new TranslatableComponent("screen.vaultpartyui.remove").getString(), actionX, rowY, 0xE0A0A0); - } - } else { - // Invite button for non-members, visible to any party member - this.font.draw(poseStack, new TranslatableComponent("screen.vaultpartyui.invite").getString(), actionX, rowY, 0xA0E0A0); - } + int actionX = panelX + panelWidth - 110; + Component action = RowPresentation.actionLabel(row, isPartyLeader()); + if (action != null) { + this.font.draw(poseStack, action.getString(), actionX, rowY, RowPresentation.actionColor(row.state)); + } + + if (hovered) { + Component hint = RowPresentation.tooltip(row, isPartyLeader()); + if (hint != null) { + renderTooltip(poseStack, hint, mouseX, mouseY); } } @@ -462,70 +560,114 @@ public class PartyScreen extends Screen { int listTop = PANEL_TOP + 48; int relativeY = (int)mouseY - listTop - 4; int index = this.onlineScrollOffset + (relativeY / ONLINE_ROW_HEIGHT); - List visiblePlayers = filteredOnlinePlayers(); + List visiblePlayers = filteredOnlineRows(); if (index < 0 || index >= visiblePlayers.size()) { return false; } - OnlinePlayer player = visiblePlayers.get(index); - if (player.id.equals(getLocalPlayerId())) { - return false; - } - int actionX = panelX + ((this.width - 40 - PANEL_PADDING) / 2) - 84; + this.selectedOnlineIndex = index; + + OnlineRow row = visiblePlayers.get(index); + OnlinePlayer player = row.player; int actionY = listTop + (index - this.onlineScrollOffset) * ONLINE_ROW_HEIGHT + 4; - // Use the same action bounds and logic as the renderer. The renderer places the action at - // panelX + panelWidth - 84. Recompute panelWidth here for clarity. int panelWidth = (this.width - 40 - PANEL_PADDING) / 2; - actionX = panelX + panelWidth - 84; - if (mouseX >= actionX && mouseX <= actionX + 70 && mouseY >= actionY && mouseY <= actionY + ONLINE_ROW_HEIGHT - 2) { - boolean targetIsInParty = isPlayerInCurrentParty(player.id); - if (isLocalPlayerInParty()) { - if (targetIsInParty) { - if (isPartyLeader()) { - sendPartyCommand("party remove " + player.name); - } - } else { - sendPartyCommand("party invite " + player.name); - } - } + int actionX = panelX + panelWidth - 110; + if (mouseX >= actionX && mouseX <= actionX + 104 && mouseY >= actionY && mouseY <= actionY + ONLINE_ROW_HEIGHT - 2) { + performPrimaryRowAction(row); return true; } - return false; + // Clicking anywhere on row selects it for keyboard action. + return true; } private boolean isPartyLeader() { - if (this.currentParty == null) return false; - UUID leader = this.currentParty.getLeader(); - return leader != null && leader.equals(getLocalPlayerId()); + return PartyRosterService.isPartyLeader(this.currentParty, getLocalPlayerId()); } private boolean isLocalPlayerInParty() { - if (this.currentParty == null) return false; - UUID local = getLocalPlayerId(); - if (local == null) return false; - UUID leader = this.currentParty.getLeader(); - if (leader != null && leader.equals(local)) return true; - List members = this.currentParty.getMembers(); - if (members != null) { - for (UUID m : members) { - if (local.equals(m)) return true; - } - } - return false; + return PartyRosterService.isLocalPlayerInParty(this.currentParty, getLocalPlayerId()); } - private boolean isPlayerInCurrentParty(UUID playerId) { - if (this.currentParty == null || playerId == null) return false; - UUID leader = this.currentParty.getLeader(); - if (leader != null && leader.equals(playerId)) return true; - List members = this.currentParty.getMembers(); - if (members != null) { - for (UUID memberId : members) { - if (playerId.equals(memberId)) return true; - } + private boolean performPrimaryRowAction(OnlineRow row) { + if (row == null || row.player == null) return false; + OnlinePlayer player = row.player; + switch (row.state) { + case INVITEABLE: + sendPartyCommand("party invite " + player.name); + this.inviteCooldownUntilMs.put(player.id, System.currentTimeMillis() + INVITE_COOLDOWN_MS); + pushToast(new TranslatableComponent("screen.vaultpartyui.toast_invited", player.name), 0xA0E0A0); + return true; + case PARTY_MEMBER: + if (isPartyLeader()) { + sendPartyCommand("party remove " + player.name); + pushToast(new TranslatableComponent("screen.vaultpartyui.toast_removed", player.name), 0xE0A0A0); + return true; + } + pushToast(new TranslatableComponent("screen.vaultpartyui.tip_member"), 0xE3C38C); + return false; + case OTHER_PARTY: + Component msg = new TranslatableComponent("screen.vaultpartyui.already_in_party_local", player.name); + showClientMessage(msg); + pushToast(msg, 0xE3C38C); + return false; + case COOLDOWN: + pushToast(new TranslatableComponent("screen.vaultpartyui.tip_cooldown"), 0xB0B0B0); + return false; + case NO_ACTION: + pushToast(new TranslatableComponent("screen.vaultpartyui.tip_no_action"), 0xB0B0B0); + return false; + default: + return false; + } + } + + private void ensureSelectedRowVisible(int rowCount) { + if (rowCount <= 0 || this.selectedOnlineIndex < 0) return; + if (this.selectedOnlineIndex < this.onlineScrollOffset) { + this.onlineScrollOffset = this.selectedOnlineIndex; + } + int maxVisible = this.onlineScrollOffset + VISIBLE_ONLINE_ROWS - 1; + if (this.selectedOnlineIndex > maxVisible) { + this.onlineScrollOffset = this.selectedOnlineIndex - VISIBLE_ONLINE_ROWS + 1; + } + int maxOffset = Math.max(0, rowCount - VISIBLE_ONLINE_ROWS); + this.onlineScrollOffset = Mth.clamp(this.onlineScrollOffset, 0, maxOffset); + } + + private void pruneTransientState() { + long now = System.currentTimeMillis(); + PartyRosterService.pruneCooldowns(this.inviteCooldownUntilMs, now); + + this.toasts.removeIf(t -> t.expiresAt <= now); + } + + private void pushToast(Component message, int color) { + if (message == null) return; + this.toasts.add(new UiToast(message, color, System.currentTimeMillis() + 2600L)); + if (this.toasts.size() > 3) { + this.toasts.remove(0); + } + } + + private void renderToasts(PoseStack poseStack) { + if (this.toasts.isEmpty()) return; + int y = 34; + for (UiToast toast : this.toasts) { + String text = toast.message.getString(); + int w = this.font.width(text) + 10; + int x = this.width - w - 10; + fill(poseStack, x, y - 1, x + w, y + this.font.lineHeight + 3, 0xCC111111); + this.font.drawShadow(poseStack, text, x + 5, y + 1, toast.color); + y += this.font.lineHeight + 6; + } + } + + private void showClientMessage(Component message) { + Minecraft minecraft = Minecraft.getInstance(); + if (minecraft.player != null && message != null) { + minecraft.player.displayClientMessage(message, false); } - return false; } private UUID getLocalPlayerId() { @@ -616,13 +758,4 @@ public class PartyScreen extends Screen { } } - private static final class OnlinePlayer { - final UUID id; - final String name; - - OnlinePlayer(UUID id, String name) { - this.id = id; - this.name = name; - } - } } diff --git a/src/main/java/dev/massuus/vaultpartyui/client/screen/RowPresentation.java b/src/main/java/dev/massuus/vaultpartyui/client/screen/RowPresentation.java new file mode 100644 index 0000000..7e65a9a --- /dev/null +++ b/src/main/java/dev/massuus/vaultpartyui/client/screen/RowPresentation.java @@ -0,0 +1,72 @@ +package dev.massuus.vaultpartyui.client.screen; + +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.TranslatableComponent; + +final class RowPresentation { + private RowPresentation() { + } + + static int backgroundColor(RowState state, boolean hovered, boolean selected) { + if (selected) { + return 0x66594A2D; + } + if (hovered) { + return 0x663C3122; + } + if (state == RowState.OTHER_PARTY) { + return 0x221A1A1A; + } + return 0x00000000; + } + + static int nameColor(RowState state) { + if (state == RowState.OTHER_PARTY) return 0xC8B9A3; + if (state == RowState.NO_ACTION) return 0xBBBBBB; + return 0xFFFFFF; + } + + static int actionColor(RowState state) { + if (state == RowState.INVITEABLE) return 0xA0E0A0; + if (state == RowState.PARTY_MEMBER) return 0xE0A0A0; + if (state == RowState.COOLDOWN) return 0xA0A0A0; + if (state == RowState.OTHER_PARTY) return 0xE3C38C; + return 0x909090; + } + + static Component actionLabel(OnlineRow row, boolean isPartyLeader) { + if (row == null) return null; + switch (row.state) { + case INVITEABLE: + return new TranslatableComponent("screen.vaultpartyui.invite"); + case PARTY_MEMBER: + return isPartyLeader ? new TranslatableComponent("screen.vaultpartyui.remove") : new TranslatableComponent("screen.vaultpartyui.in_your_party"); + case OTHER_PARTY: + return new TranslatableComponent("screen.vaultpartyui.in_party"); + case COOLDOWN: + return new TranslatableComponent("screen.vaultpartyui.invited"); + case SELF: + return new TranslatableComponent("screen.vaultpartyui.self"); + default: + return null; + } + } + + static Component tooltip(OnlineRow row, boolean isPartyLeader) { + if (row == null) return null; + switch (row.state) { + case INVITEABLE: + return new TranslatableComponent("screen.vaultpartyui.tip_invite"); + case PARTY_MEMBER: + return isPartyLeader ? new TranslatableComponent("screen.vaultpartyui.tip_remove") : new TranslatableComponent("screen.vaultpartyui.tip_member"); + case OTHER_PARTY: + return new TranslatableComponent("screen.vaultpartyui.tip_other_party"); + case COOLDOWN: + return new TranslatableComponent("screen.vaultpartyui.tip_cooldown"); + case NO_ACTION: + return new TranslatableComponent("screen.vaultpartyui.tip_no_action"); + default: + return null; + } + } +} diff --git a/src/main/java/dev/massuus/vaultpartyui/client/screen/RowState.java b/src/main/java/dev/massuus/vaultpartyui/client/screen/RowState.java new file mode 100644 index 0000000..9f08013 --- /dev/null +++ b/src/main/java/dev/massuus/vaultpartyui/client/screen/RowState.java @@ -0,0 +1,10 @@ +package dev.massuus.vaultpartyui.client.screen; + +enum RowState { + SELF, + PARTY_MEMBER, + OTHER_PARTY, + INVITEABLE, + COOLDOWN, + NO_ACTION +} diff --git a/src/main/java/dev/massuus/vaultpartyui/client/screen/UiToast.java b/src/main/java/dev/massuus/vaultpartyui/client/screen/UiToast.java new file mode 100644 index 0000000..31b1fa0 --- /dev/null +++ b/src/main/java/dev/massuus/vaultpartyui/client/screen/UiToast.java @@ -0,0 +1,15 @@ +package dev.massuus.vaultpartyui.client.screen; + +import net.minecraft.network.chat.Component; + +final class UiToast { + final Component message; + final int color; + final long expiresAt; + + UiToast(Component message, int color, long expiresAt) { + this.message = message; + this.color = color; + this.expiresAt = expiresAt; + } +} diff --git a/src/main/resources/assets/vaultpartyui/lang/en_us.json b/src/main/resources/assets/vaultpartyui/lang/en_us.json index 0685764..f1235b4 100644 --- a/src/main/resources/assets/vaultpartyui/lang/en_us.json +++ b/src/main/resources/assets/vaultpartyui/lang/en_us.json @@ -17,6 +17,11 @@ "screen.vaultpartyui.remove_selected": "Remove Selected", "screen.vaultpartyui.accept_invite": "Accept Invite", "screen.vaultpartyui.decline_invite": "Decline Invite", + "screen.vaultpartyui.auto_accept_on": "Auto accept invites: On", + "screen.vaultpartyui.auto_accept_off": "Auto accept invites: Off", + "screen.vaultpartyui.auto_accepted": "Auto-accepted invite from %s", + "screen.vaultpartyui.toast_auto_accept_on": "Auto-accept enabled", + "screen.vaultpartyui.toast_auto_accept_off": "Auto-accept disabled", "screen.vaultpartyui.no_party": "You are not in a party.", "screen.vaultpartyui.no_target": "Type or select a player name first.", "screen.vaultpartyui.pending_invite": "Incoming invite from %s", @@ -25,6 +30,19 @@ "screen.vaultpartyui.leader": "Leader", "screen.vaultpartyui.invite": "Invite", "screen.vaultpartyui.remove": "Remove", - "screen.vaultpartyui.self": "You" - ,"screen.vaultpartyui.credit_tooltip": "Click to open the GitHub repo" + "screen.vaultpartyui.in_party": "In Other Party", + "screen.vaultpartyui.in_your_party": "In Party", + "screen.vaultpartyui.invited": "Invited", + "screen.vaultpartyui.already_in_party_local": "%s is already in another party.", + "screen.vaultpartyui.tip_invite": "Invite this player", + "screen.vaultpartyui.tip_remove": "Remove this member", + "screen.vaultpartyui.tip_member": "Only leader can remove members", + "screen.vaultpartyui.tip_other_party": "Player is already in another party", + "screen.vaultpartyui.tip_cooldown": "Invite recently sent", + "screen.vaultpartyui.tip_no_action": "Create or join a party to invite", + "screen.vaultpartyui.toast_invited": "Invited %s", + "screen.vaultpartyui.toast_removed": "Removed %s", + "screen.vaultpartyui.no_matching": "No matching players.", + "screen.vaultpartyui.self": "You", + "screen.vaultpartyui.credit_tooltip": "Click to open the GitHub repo" }