add basic "proximity UI" that appears when you approach a game in progress
Some checks failed
Build / build (push) Has been cancelled

This commit is contained in:
trunksbomb
2026-03-23 18:10:17 -04:00
parent 95d98fb721
commit 905bb986f1
6 changed files with 342 additions and 0 deletions

View File

@@ -1,5 +1,6 @@
package com.trunksbomb.minetriad;
import com.trunksbomb.minetriad.client.hud.LocalDuelHudOverlay;
import com.trunksbomb.minetriad.client.render.FirstPersonCardHandRenderer;
import com.trunksbomb.minetriad.client.render.DuelTableBlockEntityRenderer;
import com.trunksbomb.minetriad.client.screen.CardBinderScreen;
@@ -16,7 +17,9 @@ import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.client.event.EntityRenderersEvent;
import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent;
import net.neoforged.neoforge.client.event.ClientTickEvent;
import net.neoforged.neoforge.client.event.RenderHandEvent;
import net.neoforged.neoforge.client.event.RenderGuiLayerEvent;
@Mod(value = MineTriad.MOD_ID, dist = Dist.CLIENT)
public final class MineTriadClient {
@@ -55,5 +58,15 @@ public final class MineTriadClient {
event.getEquipProgress(),
event.getItemStack());
}
@SubscribeEvent
public static void onClientTick(ClientTickEvent.Post event) {
LocalDuelHudOverlay.onClientTick(event);
}
@SubscribeEvent
public static void onRenderGui(RenderGuiLayerEvent.Post event) {
LocalDuelHudOverlay.onRenderGui(event);
}
}
}

View File

@@ -48,6 +48,7 @@ public final class DuelTableAnimationResolver {
}
session.beginOpponentAnimation();
table.updateHudState(session);
table.startMoveAnimation(opponentMove, DuelTableBlockEntity.OWNER_SECOND);
}
@@ -68,6 +69,7 @@ public final class DuelTableAnimationResolver {
}
session.returnToPlaying();
table.updateHudState(session);
}
public static void startAiTurn(DuelTableBlockEntity table, DuelSession session, ServerPlayer player) {
@@ -92,6 +94,7 @@ public final class DuelTableAnimationResolver {
} else {
session.beginOpponentAnimation();
}
table.updateHudState(session);
table.startMoveAnimation(aiMove, owner);
}
@@ -101,6 +104,7 @@ public final class DuelTableAnimationResolver {
.map(cardId -> CardItem.createCardStack(cardId, TriadItems.TRIAD_CARD.get()))
.toList());
table.removeBoardCardsOwnedBy(DuelTableBlockEntity.OWNER_SECOND);
table.updateHudState(session);
table.startOverviewSweepAnimation();
player.displayClientMessage(session.resultSummary().copy().withStyle(ChatFormatting.AQUA), false);
}

View File

@@ -67,6 +67,15 @@ public class DuelTableBlockEntity extends BlockEntity {
private TableAnimationType tableAnimationType = TableAnimationType.NONE;
private int tableAnimationAge;
private int tableAnimationDuration;
private boolean hudActive;
private int hudP1HandCount;
private int hudP2HandCount;
private int hudP1Score;
private int hudP2Score;
private String hudRules = "";
private String hudGameState = "";
private String hudTurn = "";
private String hudInstruction = "";
public DuelTableBlockEntity(BlockPos pos, BlockState blockState) {
super(TriadBlockEntities.DUEL_TABLE.get(), pos, blockState);
@@ -110,6 +119,42 @@ public class DuelTableBlockEntity extends BlockEntity {
return Optional.ofNullable(secondParticipantId);
}
public boolean hudActive() {
return hudActive;
}
public int hudP1HandCount() {
return hudP1HandCount;
}
public int hudP2HandCount() {
return hudP2HandCount;
}
public int hudP1Score() {
return hudP1Score;
}
public int hudP2Score() {
return hudP2Score;
}
public String hudRules() {
return hudRules;
}
public String hudGameState() {
return hudGameState;
}
public String hudTurn() {
return hudTurn;
}
public String hudInstruction() {
return hudInstruction;
}
public boolean hasActiveAnimation() {
return animationType != AnimationType.NONE || tableAnimationType != TableAnimationType.NONE;
}
@@ -171,6 +216,34 @@ public class DuelTableBlockEntity extends BlockEntity {
sync();
}
public void updateHudState(DuelSession session) {
hudActive = true;
hudP1HandCount = session.playerHandCount();
hudP2HandCount = session.opponentHandCount();
hudP1Score = session.playerScore();
hudP2Score = session.opponentScore();
hudRules = session.rulesSummary();
hudGameState = session.gameStateLabel();
hudTurn = session.turnIndicator();
hudInstruction = session.isInOverview()
? rewardCardCount() > 0 ? "P1 choosing 1 cards to keep" : "Ready for next game"
: session.nextStepInstruction();
sync();
}
public void clearHudState() {
hudActive = false;
hudP1HandCount = 0;
hudP2HandCount = 0;
hudP1Score = 0;
hudP2Score = 0;
hudRules = "";
hudGameState = "";
hudTurn = "";
hudInstruction = "";
sync();
}
public boolean setCard(int slot, ItemStack stack, int owner) {
if (slot < 0 || slot >= boardCards.size() || !boardCards.get(slot).isEmpty() || !stack.is(TriadItems.TRIAD_CARD.get())) {
return false;
@@ -202,6 +275,7 @@ public class DuelTableBlockEntity extends BlockEntity {
clearRewardCards();
firstParticipantId = null;
secondParticipantId = null;
clearHudState();
sync();
}
@@ -236,6 +310,7 @@ public class DuelTableBlockEntity extends BlockEntity {
dropRewardCards(level, pos);
firstParticipantId = null;
secondParticipantId = null;
clearHudState();
sync();
}
@@ -248,6 +323,10 @@ public class DuelTableBlockEntity extends BlockEntity {
}
}
refreshRewardInteractions();
DuelSession session = level == null ? null : DuelSessionManager.getAt(worldPosition);
if (session != null) {
updateHudState(session);
}
sync();
}
@@ -270,6 +349,10 @@ public class DuelTableBlockEntity extends BlockEntity {
}
rewardCards.set(slot, ItemStack.EMPTY);
refreshRewardInteractions();
DuelSession session = level == null ? null : DuelSessionManager.getAt(worldPosition);
if (session != null) {
updateHudState(session);
}
sync();
return taken;
}
@@ -475,6 +558,7 @@ public class DuelTableBlockEntity extends BlockEntity {
ContainerHelper.saveAllItems(rewardTag, rewardCards, registries);
tag.put("RewardCards", rewardTag);
saveRewardTakeAnimations(tag, registries);
saveHudState(tag);
tag.putIntArray("OwnerSlots", ownerSlots);
tag.putIntArray("SlotAges", slotAges);
saveAnimation(tag);
@@ -496,6 +580,7 @@ public class DuelTableBlockEntity extends BlockEntity {
ContainerHelper.loadAllItems(tag.getCompound("RewardCards"), rewardCards, registries);
}
loadRewardTakeAnimations(tag, registries);
loadHudState(tag);
loadOwnerData(tag);
loadAnimation(tag);
loadTableAnimation(tag);
@@ -509,6 +594,7 @@ public class DuelTableBlockEntity extends BlockEntity {
ContainerHelper.saveAllItems(rewardTag, rewardCards, registries);
tag.put("RewardCards", rewardTag);
saveRewardTakeAnimations(tag, registries);
saveHudState(tag);
tag.putIntArray("OwnerSlots", ownerSlots);
tag.putIntArray("SlotAges", slotAges);
saveAnimation(tag);
@@ -537,6 +623,7 @@ public class DuelTableBlockEntity extends BlockEntity {
ContainerHelper.loadAllItems(tag.getCompound("RewardCards"), rewardCards, registries);
}
loadRewardTakeAnimations(tag, registries);
loadHudState(tag);
loadOwnerData(tag);
loadAnimation(tag);
loadTableAnimation(tag);
@@ -554,6 +641,15 @@ public class DuelTableBlockEntity extends BlockEntity {
clearRewardCards();
firstParticipantId = null;
secondParticipantId = null;
hudActive = false;
hudP1HandCount = 0;
hudP2HandCount = 0;
hudP1Score = 0;
hudP2Score = 0;
hudRules = "";
hudGameState = "";
hudTurn = "";
hudInstruction = "";
}
private void loadOwnerData(CompoundTag tag) {
@@ -585,6 +681,30 @@ public class DuelTableBlockEntity extends BlockEntity {
tag.put("RewardTakeAnimations", animationsTag);
}
private void saveHudState(CompoundTag tag) {
tag.putBoolean("HudActive", hudActive);
tag.putInt("HudP1HandCount", hudP1HandCount);
tag.putInt("HudP2HandCount", hudP2HandCount);
tag.putInt("HudP1Score", hudP1Score);
tag.putInt("HudP2Score", hudP2Score);
tag.putString("HudRules", hudRules);
tag.putString("HudGameState", hudGameState);
tag.putString("HudTurn", hudTurn);
tag.putString("HudInstruction", hudInstruction);
}
private void loadHudState(CompoundTag tag) {
hudActive = tag.getBoolean("HudActive");
hudP1HandCount = tag.getInt("HudP1HandCount");
hudP2HandCount = tag.getInt("HudP2HandCount");
hudP1Score = tag.getInt("HudP1Score");
hudP2Score = tag.getInt("HudP2Score");
hudRules = tag.getString("HudRules");
hudGameState = tag.getString("HudGameState");
hudTurn = tag.getString("HudTurn");
hudInstruction = tag.getString("HudInstruction");
}
private void loadRewardTakeAnimations(CompoundTag tag, HolderLookup.Provider registries) {
rewardTakeAnimations.clear();
if (!tag.contains("RewardTakeAnimations", Tag.TAG_COMPOUND)) {

View File

@@ -0,0 +1,130 @@
package com.trunksbomb.minetriad.client.hud;
import java.util.ArrayList;
import java.util.List;
import com.trunksbomb.minetriad.blockentity.DuelTableBlockEntity;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.neoforged.neoforge.client.event.ClientTickEvent;
import net.neoforged.neoforge.client.event.RenderGuiLayerEvent;
public final class LocalDuelHudOverlay {
private static final int SCAN_INTERVAL_TICKS = 10;
private static final int STALE_TIMEOUT_TICKS = 20;
private static final double MAX_DISTANCE_SQ = 10.0D * 10.0D;
private static BlockPos trackedTablePos;
private static int nextScanIn;
private static int staleTicksRemaining;
private LocalDuelHudOverlay() {
}
public static void onClientTick(ClientTickEvent.Post event) {
Minecraft minecraft = Minecraft.getInstance();
if (minecraft.level == null || minecraft.player == null) {
clear();
return;
}
if (trackedTablePos != null && !isValidTrackedTable(minecraft, trackedTablePos)) {
clear();
}
if (nextScanIn > 0) {
nextScanIn--;
} else {
trackedTablePos = findNearestActiveTable(minecraft);
nextScanIn = SCAN_INTERVAL_TICKS;
staleTicksRemaining = trackedTablePos == null ? 0 : STALE_TIMEOUT_TICKS;
}
if (trackedTablePos != null) {
staleTicksRemaining--;
if (staleTicksRemaining <= 0 && !isValidTrackedTable(minecraft, trackedTablePos)) {
clear();
} else if (isValidTrackedTable(minecraft, trackedTablePos)) {
staleTicksRemaining = STALE_TIMEOUT_TICKS;
}
}
}
public static void onRenderGui(RenderGuiLayerEvent.Post event) {
Minecraft minecraft = Minecraft.getInstance();
if (minecraft.level == null || minecraft.player == null || trackedTablePos == null) {
return;
}
DuelTableBlockEntity table = duelTableAt(minecraft, trackedTablePos);
if (table == null || !table.hudActive()) {
return;
}
List<Component> lines = new ArrayList<>();
lines.add(Component.literal("P1 Cards in hand: " + table.hudP1HandCount()));
lines.add(Component.literal("P2 Cards in hand: " + table.hudP2HandCount()));
lines.add(Component.literal("Score: " + table.hudP1Score() + ":" + table.hudP2Score()));
lines.add(Component.literal("Rules: " + table.hudRules()));
lines.add(Component.literal("Game state: " + table.hudGameState()));
lines.add(Component.literal("Turn: " + table.hudTurn()));
lines.add(Component.literal("Next: " + table.hudInstruction()));
GuiGraphics guiGraphics = event.getGuiGraphics();
int x = 8;
int y = 8;
int lineHeight = 10;
int width = 0;
for (Component line : lines) {
width = Math.max(width, minecraft.font.width(line));
}
int height = lines.size() * lineHeight + 8;
guiGraphics.fill(x - 4, y - 4, x + width + 4, y + height - 4, 0x80000000);
for (int index = 0; index < lines.size(); index++) {
guiGraphics.drawString(minecraft.font, lines.get(index), x, y + index * lineHeight, 0xFFFFFF, false);
}
}
private static BlockPos findNearestActiveTable(Minecraft minecraft) {
BlockPos origin = minecraft.player.blockPosition();
BlockPos nearest = null;
double nearestDistance = MAX_DISTANCE_SQ;
for (BlockPos pos : BlockPos.betweenClosed(origin.offset(-8, -4, -8), origin.offset(8, 4, 8))) {
DuelTableBlockEntity table = duelTableAt(minecraft, pos);
if (table == null || !table.hudActive()) {
continue;
}
double distance = pos.distSqr(minecraft.player.blockPosition());
if (distance <= nearestDistance) {
nearestDistance = distance;
nearest = pos.immutable();
}
}
return nearest;
}
private static boolean isValidTrackedTable(Minecraft minecraft, BlockPos pos) {
DuelTableBlockEntity table = duelTableAt(minecraft, pos);
return table != null
&& table.hudActive()
&& pos.distSqr(minecraft.player.blockPosition()) <= MAX_DISTANCE_SQ;
}
private static DuelTableBlockEntity duelTableAt(Minecraft minecraft, BlockPos pos) {
if (minecraft.level == null) {
return null;
}
BlockEntity blockEntity = minecraft.level.getBlockEntity(pos);
return blockEntity instanceof DuelTableBlockEntity duelTableBlockEntity ? duelTableBlockEntity : null;
}
private static void clear() {
trackedTablePos = null;
nextScanIn = 0;
staleTicksRemaining = 0;
}
}

View File

@@ -108,6 +108,78 @@ public final class DuelSession {
return aiVsAi;
}
public int playerHandCount() {
return match.handFor(playerParticipant).size();
}
public int opponentHandCount() {
return match.handFor(opponentParticipant).size();
}
public int playerScore() {
return match.scoreFor(playerParticipant);
}
public int opponentScore() {
return match.scoreFor(opponentParticipant);
}
public String rulesSummary() {
List<String> rules = new ArrayList<>();
if (match.ruleSet().sameRule()) {
rules.add("Same");
}
if (match.ruleSet().sameWallRule()) {
rules.add("Same Wall");
}
if (match.ruleSet().plusRule()) {
rules.add("Plus");
}
if (match.ruleSet().openHands()) {
rules.add("Open");
}
if (rules.isEmpty()) {
return "None";
}
return String.join(", ", rules);
}
public String turnIndicator() {
return isPlayerTurn() ? "P1" : "P2";
}
public String gameStateLabel() {
return switch (phase) {
case PLAYING -> "Playing";
case PLAYER_ANIMATION, OPPONENT_ANIMATION -> "Animating";
case CLOSING_ANIMATION -> "Closing";
case OVERVIEW -> "Overview";
};
}
public String nextStepInstruction() {
if (isComplete()) {
int playerScore = playerScore();
int opponentScore = opponentScore();
String winner = playerScore == opponentScore ? "Draw" : playerScore > opponentScore ? "P1" : "P2";
if (isInOverview()) {
return opponentRewardCards().isEmpty()
? "Ready for next game"
: winner + " choosing 1 cards to keep";
}
return "Game over - " + winner + (winner.equals("Draw") ? "" : " wins");
}
return switch (phase) {
case PLAYER_ANIMATION, OPPONENT_ANIMATION -> "Resolving animations";
case CLOSING_ANIMATION -> "Clearing board";
case OVERVIEW -> "Choose a reward card or finish";
case PLAYING -> isPlayerTurn()
? "Waiting for you to place a card"
: "Waiting for P2 to place a card";
};
}
public void enterOverview() {
phase = Phase.OVERVIEW;
}

View File

@@ -102,6 +102,7 @@ public class DuelTableBlock extends BaseEntityBlock {
}
table.clearBoard();
table.setParticipants(session.playerParticipantId(), session.opponentParticipantId());
table.updateHudState(session);
if (aiVsAi) {
player.displayClientMessage(Component.literal("AI vs AI duel started. Empty-hand click on a Duel Table launches an autoplay match.").withStyle(ChatFormatting.GOLD), false);
if (player instanceof net.minecraft.server.level.ServerPlayer serverPlayer) {
@@ -144,6 +145,7 @@ public class DuelTableBlock extends BaseEntityBlock {
}
session.beginPlayerAnimation();
table.updateHudState(session);
table.startMoveAnimation(playerMove, DuelTableBlockEntity.OWNER_FIRST);
return ItemInteractionResult.CONSUME;
} catch (Exception exception) {
@@ -284,6 +286,7 @@ public class DuelTableBlock extends BaseEntityBlock {
DuelTableBlockEntity table = getTableEntity(level, pos);
if (table != null) {
session.beginClosingAnimation();
table.updateHudState(session);
table.startClearWaveAnimation();
}
}