diff --git a/src/main/java/com/trunksbomb/minetriad/MineTriadClient.java b/src/main/java/com/trunksbomb/minetriad/MineTriadClient.java index 07fdc0f..1342b73 100644 --- a/src/main/java/com/trunksbomb/minetriad/MineTriadClient.java +++ b/src/main/java/com/trunksbomb/minetriad/MineTriadClient.java @@ -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); + } } } diff --git a/src/main/java/com/trunksbomb/minetriad/blockentity/DuelTableAnimationResolver.java b/src/main/java/com/trunksbomb/minetriad/blockentity/DuelTableAnimationResolver.java index dc8dcae..173b3d9 100644 --- a/src/main/java/com/trunksbomb/minetriad/blockentity/DuelTableAnimationResolver.java +++ b/src/main/java/com/trunksbomb/minetriad/blockentity/DuelTableAnimationResolver.java @@ -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); } diff --git a/src/main/java/com/trunksbomb/minetriad/blockentity/DuelTableBlockEntity.java b/src/main/java/com/trunksbomb/minetriad/blockentity/DuelTableBlockEntity.java index 1e612d2..893a34c 100644 --- a/src/main/java/com/trunksbomb/minetriad/blockentity/DuelTableBlockEntity.java +++ b/src/main/java/com/trunksbomb/minetriad/blockentity/DuelTableBlockEntity.java @@ -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)) { diff --git a/src/main/java/com/trunksbomb/minetriad/client/hud/LocalDuelHudOverlay.java b/src/main/java/com/trunksbomb/minetriad/client/hud/LocalDuelHudOverlay.java new file mode 100644 index 0000000..5157814 --- /dev/null +++ b/src/main/java/com/trunksbomb/minetriad/client/hud/LocalDuelHudOverlay.java @@ -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 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; + } +} diff --git a/src/main/java/com/trunksbomb/minetriad/game/DuelSession.java b/src/main/java/com/trunksbomb/minetriad/game/DuelSession.java index 2732586..37fa15f 100644 --- a/src/main/java/com/trunksbomb/minetriad/game/DuelSession.java +++ b/src/main/java/com/trunksbomb/minetriad/game/DuelSession.java @@ -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 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; } diff --git a/src/main/java/com/trunksbomb/minetriad/world/DuelTableBlock.java b/src/main/java/com/trunksbomb/minetriad/world/DuelTableBlock.java index 1af81de..13d7fb1 100644 --- a/src/main/java/com/trunksbomb/minetriad/world/DuelTableBlock.java +++ b/src/main/java/com/trunksbomb/minetriad/world/DuelTableBlock.java @@ -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(); } }