From 261f540317ed896ee2492b49dd42c4edaa608e7e Mon Sep 17 00:00:00 2001 From: trunksbomb Date: Mon, 23 Mar 2026 12:29:01 -0400 Subject: [PATCH] cards now have some simple animations: idle bob on the board, placement, card capture flip, game over wave, and end duel pop up --- .../DuelTableAnimationResolver.java | 122 +++++++ .../blockentity/DuelTableBlockEntity.java | 315 ++++++++++++++++++ .../render/DuelTableBlockEntityRenderer.java | 52 ++- .../minetriad/game/DuelSession.java | 58 +++- .../minetriad/game/DuelSessionManager.java | 32 +- .../minetriad/world/DuelTableBlock.java | 83 ++--- 6 files changed, 596 insertions(+), 66 deletions(-) create mode 100644 src/main/java/com/trunksbomb/minetriad/blockentity/DuelTableAnimationResolver.java diff --git a/src/main/java/com/trunksbomb/minetriad/blockentity/DuelTableAnimationResolver.java b/src/main/java/com/trunksbomb/minetriad/blockentity/DuelTableAnimationResolver.java new file mode 100644 index 0000000..02906e4 --- /dev/null +++ b/src/main/java/com/trunksbomb/minetriad/blockentity/DuelTableAnimationResolver.java @@ -0,0 +1,122 @@ +package com.trunksbomb.minetriad.blockentity; + +import com.trunksbomb.minetriad.game.DuelSession; +import com.trunksbomb.minetriad.game.DuelSessionManager; +import com.trunksbomb.minetriad.game.MoveResult; +import com.trunksbomb.minetriad.item.CardItem; +import com.trunksbomb.minetriad.registry.TriadItems; + +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.Level; + +public final class DuelTableAnimationResolver { + private DuelTableAnimationResolver() { + } + + static void handleAfterPlayerAnimation(Level level, BlockPos pos, DuelTableBlockEntity table, DuelSession session) { + ServerPlayer player = getPlayer(level, session); + if (player == null) { + return; + } + + if (session.isComplete()) { + enterOverview(table, session, player); + return; + } + + if (session.isAiVsAi()) { + startAiTurn(table, session, player); + return; + } + + MoveResult opponentMove = session.playOpponentTurn(); + if (!opponentMove.valid()) { + player.displayClientMessage(Component.literal(opponentMove.errorMessage()).withStyle(ChatFormatting.RED), false); + table.clearBoard(); + DuelSessionManager.end(player, true); + return; + } + + if (!table.setCard(opponentMove.playedCell().index(), CardItem.createCardStack(opponentMove.playedCardId(), TriadItems.TRIAD_CARD.get()), DuelTableBlockEntity.OWNER_SECOND)) { + player.displayClientMessage(Component.literal("The Duel Table could not stage the opponent move.").withStyle(ChatFormatting.RED), false); + table.clearBoard(); + DuelSessionManager.end(player, true); + return; + } + + session.beginOpponentAnimation(); + table.startMoveAnimation(opponentMove, DuelTableBlockEntity.OWNER_SECOND); + player.displayClientMessage(Component.literal("Training Duelist answers with " + opponentMove.playedCardName() + ".").withStyle(ChatFormatting.GRAY), false); + sendBattleLog(player, opponentMove, ChatFormatting.GRAY); + } + + static void handleAfterOpponentAnimation(Level level, BlockPos pos, DuelTableBlockEntity table, DuelSession session) { + ServerPlayer player = getPlayer(level, session); + if (player == null) { + return; + } + + if (session.isComplete()) { + enterOverview(table, session, player); + return; + } + + if (session.isAiVsAi()) { + startAiTurn(table, session, player); + return; + } + + session.returnToPlaying(); + player.displayClientMessage(session.boardSummary(), false); + player.displayClientMessage(Component.literal("Your turn. Hold one of your remaining duel cards and click an open space.").withStyle(ChatFormatting.YELLOW), false); + player.displayClientMessage(session.handSummary(), false); + } + + public static void startAiTurn(DuelTableBlockEntity table, DuelSession session, ServerPlayer player) { + MoveResult aiMove = session.playAiTurn(); + if (!aiMove.valid()) { + player.displayClientMessage(Component.literal(aiMove.errorMessage()).withStyle(ChatFormatting.RED), false); + table.clearBoard(); + DuelSessionManager.end(player, true); + return; + } + + int owner = session.isPlayerTurn() ? DuelTableBlockEntity.OWNER_SECOND : DuelTableBlockEntity.OWNER_FIRST; + if (!table.setCard(aiMove.playedCell().index(), CardItem.createCardStack(aiMove.playedCardId(), TriadItems.TRIAD_CARD.get()), owner)) { + player.displayClientMessage(Component.literal("The Duel Table could not stage the AI move.").withStyle(ChatFormatting.RED), false); + table.clearBoard(); + DuelSessionManager.end(player, true); + return; + } + + if (owner == DuelTableBlockEntity.OWNER_FIRST) { + session.beginPlayerAnimation(); + } else { + session.beginOpponentAnimation(); + } + table.startMoveAnimation(aiMove, owner); + player.displayClientMessage(Component.literal((owner == DuelTableBlockEntity.OWNER_FIRST ? "Blue AI" : "Red AI") + " plays " + aiMove.playedCardName() + ".") + .withStyle(ChatFormatting.GRAY), false); + sendBattleLog(player, aiMove, ChatFormatting.GRAY); + } + + private static void enterOverview(DuelTableBlockEntity table, DuelSession session, ServerPlayer player) { + session.enterOverview(); + table.startOverviewSweepAnimation(); + player.displayClientMessage(session.resultSummary().copy().withStyle(ChatFormatting.AQUA), false); + player.displayClientMessage(session.overviewMessage().copy().withStyle(ChatFormatting.YELLOW), false); + } + + private static void sendBattleLog(ServerPlayer player, MoveResult moveResult, ChatFormatting color) { + for (String line : moveResult.battleLog()) { + player.displayClientMessage(Component.literal(line).withStyle(color), false); + } + } + + private static ServerPlayer getPlayer(Level level, DuelSession session) { + return level.getServer() == null ? null : level.getServer().getPlayerList().getPlayer(session.playerParticipantId()); + } +} diff --git a/src/main/java/com/trunksbomb/minetriad/blockentity/DuelTableBlockEntity.java b/src/main/java/com/trunksbomb/minetriad/blockentity/DuelTableBlockEntity.java index 5649bf9..6027c1f 100644 --- a/src/main/java/com/trunksbomb/minetriad/blockentity/DuelTableBlockEntity.java +++ b/src/main/java/com/trunksbomb/minetriad/blockentity/DuelTableBlockEntity.java @@ -1,17 +1,26 @@ package com.trunksbomb.minetriad.blockentity; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; import java.util.UUID; +import com.trunksbomb.minetriad.game.BoardCell; +import com.trunksbomb.minetriad.game.DuelSession; +import com.trunksbomb.minetriad.game.DuelSessionManager; +import com.trunksbomb.minetriad.game.MoveResult; import com.trunksbomb.minetriad.registry.TriadBlockEntities; import com.trunksbomb.minetriad.registry.TriadItems; +import net.minecraft.ChatFormatting; import net.minecraft.core.BlockPos; import net.minecraft.core.HolderLookup; import net.minecraft.core.NonNullList; import net.minecraft.nbt.CompoundTag; import net.minecraft.network.Connection; import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.network.chat.Component; import net.minecraft.world.ContainerHelper; import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.Level; @@ -24,11 +33,26 @@ public class DuelTableBlockEntity extends BlockEntity { public static final int OWNER_NONE = 0; public static final int OWNER_FIRST = 1; public static final int OWNER_SECOND = 2; + private static final int PLACEMENT_DURATION = 10; + private static final int CAPTURE_DURATION = 10; + private static final int OVERVIEW_SWEEP_DURATION = 18; + private static final int CLEAR_WAVE_DURATION = 10; private final NonNullList boardCards = NonNullList.withSize(SLOT_COUNT, ItemStack.EMPTY); private final int[] ownerSlots = new int[SLOT_COUNT]; + private final int[] slotAges = new int[SLOT_COUNT]; + private final ArrayDeque pendingAnimations = new ArrayDeque<>(); private UUID firstParticipantId; private UUID secondParticipantId; + private AnimationType animationType = AnimationType.NONE; + private int animationSlot = -1; + private int animationTargetOwner = OWNER_NONE; + private int animationAge; + private int animationDuration; + private boolean animationOwnerApplied; + private TableAnimationType tableAnimationType = TableAnimationType.NONE; + private int tableAnimationAge; + private int tableAnimationDuration; public DuelTableBlockEntity(BlockPos pos, BlockState blockState) { super(TriadBlockEntities.DUEL_TABLE.get(), pos, blockState); @@ -42,6 +66,10 @@ public class DuelTableBlockEntity extends BlockEntity { return ownerSlots[slot]; } + public int slotAge(int slot) { + return slot >= 0 && slot < slotAges.length ? slotAges[slot] : 0; + } + public Optional firstParticipantId() { return Optional.ofNullable(firstParticipantId); } @@ -50,6 +78,52 @@ public class DuelTableBlockEntity extends BlockEntity { return Optional.ofNullable(secondParticipantId); } + public boolean hasActiveAnimation() { + return animationType != AnimationType.NONE || tableAnimationType != TableAnimationType.NONE; + } + + public String animationTypeName() { + return animationType.name(); + } + + public boolean isPlacementAnimation() { + return animationType == AnimationType.PLACEMENT; + } + + public boolean isCaptureAnimation() { + return animationType == AnimationType.CAPTURE; + } + + public int animationSlot() { + return animationSlot; + } + + public float animationProgress(float partialTick) { + if (!hasActiveAnimation() || animationDuration <= 0) { + return 0.0F; + } + return Math.clamp((animationAge + partialTick) / animationDuration, 0.0F, 1.0F); + } + + public boolean hasOverviewSweepAnimation() { + return tableAnimationType == TableAnimationType.OVERVIEW_SWEEP; + } + + public boolean hasClearWaveAnimation() { + return tableAnimationType == TableAnimationType.CLEARING; + } + + public float tableAnimationProgress(float partialTick) { + if (tableAnimationType == TableAnimationType.NONE || tableAnimationDuration <= 0) { + return 0.0F; + } + return Math.clamp((tableAnimationAge + partialTick) / tableAnimationDuration, 0.0F, 1.0F); + } + + public boolean animationOwnerApplied() { + return animationOwnerApplied; + } + public void setParticipants(UUID firstParticipantId, UUID secondParticipantId) { this.firstParticipantId = firstParticipantId; this.secondParticipantId = secondParticipantId; @@ -63,6 +137,7 @@ public class DuelTableBlockEntity extends BlockEntity { boardCards.set(slot, stack.copyWithCount(1)); ownerSlots[slot] = owner; + slotAges[slot] = 0; sync(); return true; } @@ -79,7 +154,10 @@ public class DuelTableBlockEntity extends BlockEntity { for (int index = 0; index < boardCards.size(); index++) { boardCards.set(index, ItemStack.EMPTY); ownerSlots[index] = OWNER_NONE; + slotAges[index] = 0; } + pendingAnimations.clear(); + clearAnimationState(); firstParticipantId = null; secondParticipantId = null; sync(); @@ -92,18 +170,106 @@ public class DuelTableBlockEntity extends BlockEntity { Block.popResource(level, pos, stack); boardCards.set(slot, ItemStack.EMPTY); ownerSlots[slot] = OWNER_NONE; + slotAges[slot] = 0; } } + pendingAnimations.clear(); + clearAnimationState(); firstParticipantId = null; secondParticipantId = null; sync(); } + public void startMoveAnimation(MoveResult moveResult, int owner) { + pendingAnimations.clear(); + clearAnimationState(); + clearTableAnimationState(); + if (moveResult.playedCell() == null) { + return; + } + + pendingAnimations.addLast(new AnimationStep(AnimationType.PLACEMENT, moveResult.playedCell().index(), owner)); + for (BoardCell capturedCell : moveResult.capturedCells()) { + pendingAnimations.addLast(new AnimationStep(AnimationType.CAPTURE, capturedCell.index(), owner)); + } + advanceAnimationStep(); + } + + public void startOverviewSweepAnimation() { + clearAnimationState(); + tableAnimationType = TableAnimationType.OVERVIEW_SWEEP; + tableAnimationAge = 0; + tableAnimationDuration = OVERVIEW_SWEEP_DURATION; + sync(); + } + + public void startClearWaveAnimation() { + clearAnimationState(); + tableAnimationType = TableAnimationType.CLEARING; + tableAnimationAge = 0; + tableAnimationDuration = CLEAR_WAVE_DURATION; + sync(); + } + + public static void tick(Level level, BlockPos pos, BlockState state, DuelTableBlockEntity blockEntity) { + if (blockEntity.hasActiveAnimation()) { + if (blockEntity.animationType != AnimationType.NONE) { + blockEntity.animationAge++; + if (!level.isClientSide + && blockEntity.animationType == AnimationType.CAPTURE + && !blockEntity.animationOwnerApplied + && blockEntity.animationAge >= Math.max(1, blockEntity.animationDuration / 2)) { + blockEntity.setOwner(blockEntity.animationSlot, blockEntity.animationTargetOwner); + blockEntity.animationOwnerApplied = true; + blockEntity.sync(); + } + + if (blockEntity.animationAge >= blockEntity.animationDuration) { + if (!level.isClientSide) { + blockEntity.advanceAnimationStep(); + if (blockEntity.animationType == AnimationType.NONE && blockEntity.tableAnimationType == TableAnimationType.NONE) { + blockEntity.resolvePostAnimation(level, pos); + } + } else { + blockEntity.clearAnimationState(); + } + } + } + + if (blockEntity.tableAnimationType != TableAnimationType.NONE) { + blockEntity.tableAnimationAge++; + if (!level.isClientSide + && blockEntity.tableAnimationType == TableAnimationType.CLEARING) { + blockEntity.updateClearWaveState(level, pos); + } + if (blockEntity.tableAnimationAge >= blockEntity.tableAnimationDuration) { + if (!level.isClientSide) { + blockEntity.clearTableAnimationState(); + blockEntity.resolvePostAnimation(level, pos); + } else { + blockEntity.clearTableAnimationState(); + } + } + } + } + + for (int slot = 0; slot < SLOT_COUNT; slot++) { + if (!blockEntity.boardCards.get(slot).isEmpty()) { + blockEntity.slotAges[slot]++; + } else { + blockEntity.slotAges[slot] = 0; + } + } + } + @Override protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) { super.saveAdditional(tag, registries); ContainerHelper.saveAllItems(tag, boardCards, registries); tag.putIntArray("OwnerSlots", ownerSlots); + tag.putIntArray("SlotAges", slotAges); + saveAnimation(tag); + saveTableAnimation(tag); if (firstParticipantId != null) { tag.putUUID("FirstParticipantId", firstParticipantId); } @@ -118,6 +284,8 @@ public class DuelTableBlockEntity extends BlockEntity { clearBoardContents(); ContainerHelper.loadAllItems(tag, boardCards, registries); loadOwnerData(tag); + loadAnimation(tag); + loadTableAnimation(tag); } @Override @@ -125,6 +293,9 @@ public class DuelTableBlockEntity extends BlockEntity { CompoundTag tag = super.getUpdateTag(registries); ContainerHelper.saveAllItems(tag, boardCards, registries); tag.putIntArray("OwnerSlots", ownerSlots); + tag.putIntArray("SlotAges", slotAges); + saveAnimation(tag); + saveTableAnimation(tag); if (firstParticipantId != null) { tag.putUUID("FirstParticipantId", firstParticipantId); } @@ -146,6 +317,8 @@ public class DuelTableBlockEntity extends BlockEntity { clearBoardContents(); ContainerHelper.loadAllItems(tag, boardCards, registries); loadOwnerData(tag); + loadAnimation(tag); + loadTableAnimation(tag); } } @@ -153,7 +326,10 @@ public class DuelTableBlockEntity extends BlockEntity { for (int index = 0; index < boardCards.size(); index++) { boardCards.set(index, ItemStack.EMPTY); ownerSlots[index] = OWNER_NONE; + slotAges[index] = 0; } + pendingAnimations.clear(); + clearAnimationState(); firstParticipantId = null; secondParticipantId = null; } @@ -163,14 +339,153 @@ public class DuelTableBlockEntity extends BlockEntity { for (int index = 0; index < ownerSlots.length; index++) { ownerSlots[index] = index < loadedOwners.length ? loadedOwners[index] : OWNER_NONE; } + int[] loadedAges = tag.getIntArray("SlotAges"); + for (int index = 0; index < slotAges.length; index++) { + slotAges[index] = index < loadedAges.length ? loadedAges[index] : 0; + } firstParticipantId = tag.hasUUID("FirstParticipantId") ? tag.getUUID("FirstParticipantId") : null; secondParticipantId = tag.hasUUID("SecondParticipantId") ? tag.getUUID("SecondParticipantId") : null; } + private void saveAnimation(CompoundTag tag) { + tag.putString("AnimationType", animationType.name()); + tag.putInt("AnimationSlot", animationSlot); + tag.putInt("AnimationTargetOwner", animationTargetOwner); + tag.putInt("AnimationAge", animationAge); + tag.putInt("AnimationDuration", animationDuration); + tag.putBoolean("AnimationOwnerApplied", animationOwnerApplied); + } + + private void loadAnimation(CompoundTag tag) { + animationType = AnimationType.valueOf(tag.getString("AnimationType").isEmpty() ? AnimationType.NONE.name() : tag.getString("AnimationType")); + animationSlot = tag.getInt("AnimationSlot"); + animationTargetOwner = tag.getInt("AnimationTargetOwner"); + animationAge = tag.getInt("AnimationAge"); + animationDuration = tag.getInt("AnimationDuration"); + animationOwnerApplied = tag.getBoolean("AnimationOwnerApplied"); + } + + private void saveTableAnimation(CompoundTag tag) { + tag.putString("TableAnimationType", tableAnimationType.name()); + tag.putInt("TableAnimationAge", tableAnimationAge); + tag.putInt("TableAnimationDuration", tableAnimationDuration); + } + + private void loadTableAnimation(CompoundTag tag) { + tableAnimationType = TableAnimationType.valueOf(tag.getString("TableAnimationType").isEmpty() ? TableAnimationType.NONE.name() : tag.getString("TableAnimationType")); + tableAnimationAge = tag.getInt("TableAnimationAge"); + tableAnimationDuration = tag.getInt("TableAnimationDuration"); + } + + private void advanceAnimationStep() { + AnimationStep nextStep = pendingAnimations.pollFirst(); + if (nextStep == null) { + clearAnimationState(); + sync(); + return; + } + + animationType = nextStep.type(); + animationSlot = nextStep.slot(); + animationTargetOwner = nextStep.targetOwner(); + animationAge = 0; + animationDuration = nextStep.type() == AnimationType.PLACEMENT ? PLACEMENT_DURATION : CAPTURE_DURATION; + animationOwnerApplied = nextStep.type() != AnimationType.CAPTURE; + sync(); + } + + private void clearAnimationState() { + animationType = AnimationType.NONE; + animationSlot = -1; + animationTargetOwner = OWNER_NONE; + animationAge = 0; + animationDuration = 0; + animationOwnerApplied = false; + } + + private void clearTableAnimationState() { + tableAnimationType = TableAnimationType.NONE; + tableAnimationAge = 0; + tableAnimationDuration = 0; + } + + private void resolvePostAnimation(Level level, BlockPos pos) { + DuelSession session = DuelSessionManager.getAt(pos); + if (session == null) { + return; + } + + if (session.isClosingAnimation()) { + clearBoard(); + var player = level.getServer() == null ? null : level.getServer().getPlayerList().getPlayer(session.playerParticipantId()); + if (player != null) { + DuelSessionManager.end(player, true); + player.displayClientMessage(Component.literal("Duel finished. All played cards have been returned.").withStyle(ChatFormatting.GREEN), false); + } + return; + } + + if (session.isAwaitingPlayerAnimation()) { + DuelTableAnimationResolver.handleAfterPlayerAnimation(level, pos, this, session); + return; + } + + if (session.isAwaitingOpponentAnimation()) { + DuelTableAnimationResolver.handleAfterOpponentAnimation(level, pos, this, session); + } + } + + private void updateClearWaveState(Level level, BlockPos pos) { + for (int slot = 0; slot < SLOT_COUNT; slot++) { + if (boardCards.get(slot).isEmpty()) { + continue; + } + if (clearWaveSlotProgress(slot, 0.0F) >= 1.0F) { + boardCards.set(slot, ItemStack.EMPTY); + ownerSlots[slot] = OWNER_NONE; + slotAges[slot] = 0; + } + } + sync(); + } + + public float overviewSweepSlotProgress(int slot, float partialTick) { + return waveSlotProgress(slot, partialTick, SLOT_COUNT, tableAnimationAge, tableAnimationDuration); + } + + public float clearWaveSlotProgress(int slot, float partialTick) { + return waveSlotProgress(slot, partialTick, SLOT_COUNT, tableAnimationAge, tableAnimationDuration); + } + + private float waveSlotProgress(int slot, float partialTick, int slotCount, int age, int duration) { + if (slot < 0 || slot >= slotCount || duration <= 0) { + return 0.0F; + } + float global = Math.clamp((age + partialTick) / duration, 0.0F, 1.0F); + float delay = slot / (float) slotCount; + float window = 0.28F; + return Math.clamp((global - delay) / window, 0.0F, 1.0F); + } + private void sync() { setChanged(); if (level != null) { level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), Block.UPDATE_ALL); } } + + private enum AnimationType { + NONE, + PLACEMENT, + CAPTURE + } + + private enum TableAnimationType { + NONE, + OVERVIEW_SWEEP, + CLEARING + } + + private record AnimationStep(AnimationType type, int slot, int targetOwner) { + } } diff --git a/src/main/java/com/trunksbomb/minetriad/client/render/DuelTableBlockEntityRenderer.java b/src/main/java/com/trunksbomb/minetriad/client/render/DuelTableBlockEntityRenderer.java index e54b7f4..42eb528 100644 --- a/src/main/java/com/trunksbomb/minetriad/client/render/DuelTableBlockEntityRenderer.java +++ b/src/main/java/com/trunksbomb/minetriad/client/render/DuelTableBlockEntityRenderer.java @@ -14,6 +14,10 @@ import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; import net.minecraft.world.item.ItemStack; public class DuelTableBlockEntityRenderer implements BlockEntityRenderer { + private static final float TABLE_CARD_Y = 1.066F; + private static final float IDLE_BOB_HEIGHT = 0.012F; + private static final float IDLE_BOB_SPEED = 0.08F; + public DuelTableBlockEntityRenderer(BlockEntityRendererProvider.Context context) { } @@ -27,10 +31,41 @@ public class DuelTableBlockEntityRenderer implements BlockEntityRenderer= 1.0F; + } + if (hideCard) { + continue; + } poseStack.pushPose(); - poseStack.translate(center.x(), 1.066F, center.z()); + poseStack.translate(center.x(), y, center.z()); poseStack.mulPose(Axis.YP.rotationDegrees(BoardLocalSpace.cardYawDegrees(blockEntity.getBlockState().getValue(DuelTableBlock.FACING)))); + if (extraFlip != 0.0F) { + poseStack.mulPose(Axis.ZP.rotationDegrees(extraFlip)); + } poseStack.mulPose(Axis.XN.rotationDegrees(90.0F)); poseStack.scale(0.24F, 0.24F, 0.24F); TriadCardItemRenderer.renderCard(stack, poseStack, buffer, perspectivePalette(blockEntity, blockEntity.ownerAt(slot))); @@ -38,6 +73,21 @@ public class DuelTableBlockEntityRenderer implements BlockEntityRenderer refundablePlayerCards; private final boolean refundCardsOnEnd; + private final boolean aiVsAi; private final BlockPos tablePos; private Phase phase; @@ -36,12 +40,14 @@ public final class DuelSession { List opponentHand, List refundablePlayerCards, boolean refundCardsOnEnd, + boolean aiVsAi, BlockPos tablePos) { this.playerParticipant = new MatchParticipant(playerId, playerName); this.opponentParticipant = new MatchParticipant(UUID.nameUUIDFromBytes(("opponent:" + playerId).getBytes()), "Training Duelist"); this.match = new TriadMatch(playerParticipant, opponentParticipant, playerHand, opponentHand, TriadRuleSet.CLASSIC_OPEN); this.refundablePlayerCards = List.copyOf(refundablePlayerCards); this.refundCardsOnEnd = refundCardsOnEnd; + this.aiVsAi = aiVsAi; this.tablePos = tablePos.immutable(); this.phase = Phase.PLAYING; } @@ -62,6 +68,42 @@ public final class DuelSession { return phase == Phase.OVERVIEW; } + public boolean isAnimating() { + return phase == Phase.PLAYER_ANIMATION || phase == Phase.OPPONENT_ANIMATION; + } + + public boolean isAwaitingPlayerAnimation() { + return phase == Phase.PLAYER_ANIMATION; + } + + public boolean isAwaitingOpponentAnimation() { + return phase == Phase.OPPONENT_ANIMATION; + } + + public void beginPlayerAnimation() { + phase = Phase.PLAYER_ANIMATION; + } + + public void beginOpponentAnimation() { + phase = Phase.OPPONENT_ANIMATION; + } + + public void returnToPlaying() { + phase = Phase.PLAYING; + } + + public void beginClosingAnimation() { + phase = Phase.CLOSING_ANIMATION; + } + + public boolean isClosingAnimation() { + return phase == Phase.CLOSING_ANIMATION; + } + + public boolean isAiVsAi() { + return aiVsAi; + } + public void enterOverview() { phase = Phase.OVERVIEW; } @@ -96,15 +138,23 @@ public final class DuelSession { } public MoveResult playOpponentTurn() { + return playAiTurnFor(opponentParticipant); + } + + public MoveResult playAiTurn() { + return playAiTurnFor(match.activeParticipant()); + } + + private MoveResult playAiTurnFor(MatchParticipant participant) { int bestHandIndex = -1; BoardCell bestCell = null; int bestCaptures = -1; - List opponentHand = match.handFor(opponentParticipant); - for (int handIndex = 0; handIndex < opponentHand.size(); handIndex++) { + List hand = match.handFor(participant); + for (int handIndex = 0; handIndex < hand.size(); handIndex++) { for (BoardCell cell : openCells()) { TriadMatch probe = duplicateMatch(); - MoveResult result = probe.play(new TriadMove(opponentParticipant, handIndex, cell)); + MoveResult result = probe.play(new TriadMove(participant, handIndex, cell)); if (!result.valid()) { continue; } @@ -121,7 +171,7 @@ public final class DuelSession { return MoveResult.failure("Opponent could not find a legal move"); } - return match.play(new TriadMove(opponentParticipant, bestHandIndex, bestCell)); + return match.play(new TriadMove(participant, bestHandIndex, bestCell)); } public Component boardSummary() { diff --git a/src/main/java/com/trunksbomb/minetriad/game/DuelSessionManager.java b/src/main/java/com/trunksbomb/minetriad/game/DuelSessionManager.java index 0f2b81f..ebfc9a9 100644 --- a/src/main/java/com/trunksbomb/minetriad/game/DuelSessionManager.java +++ b/src/main/java/com/trunksbomb/minetriad/game/DuelSessionManager.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.Collections; import com.trunksbomb.minetriad.card.CardRegistry; import com.trunksbomb.minetriad.card.CardDefinition; @@ -29,12 +30,27 @@ public final class DuelSessionManager { return ACTIVE_DUELS.get(player.getUUID()); } + public static DuelSession getAt(BlockPos tablePos) { + return ACTIVE_DUELS.values().stream() + .filter(session -> session.isAtTable(tablePos)) + .findFirst() + .orElse(null); + } + public static DuelSession start(Player player, BlockPos tablePos) { + return start(player, tablePos, false); + } + + public static DuelSession start(Player player, BlockPos tablePos, boolean aiVsAi) { List playerHand = buildPlayerHand(player.getInventory()); - if (playerHand.size() < 5) { + if (!aiVsAi && playerHand.size() < 5) { throw new IllegalStateException("A duel requires 5 Triad Cards in your inventory"); } + if (aiVsAi) { + playerHand = buildRandomHand(); + } + List refundableCards = playerHand.stream() .map(card -> card.definition().id()) .toList(); @@ -42,9 +58,10 @@ public final class DuelSessionManager { player.getUUID(), player.getName().getString(), playerHand, - buildOpponentHand(), + aiVsAi ? buildRandomHand() : buildOpponentHand(), refundableCards, - !player.getAbilities().instabuild, + !aiVsAi && !player.getAbilities().instabuild, + aiVsAi, tablePos); ACTIVE_DUELS.put(player.getUUID(), session); return session; @@ -109,4 +126,13 @@ public final class DuelSessionManager { .map(GameCard::new) .toList(); } + + private static List buildRandomHand() { + List cards = new ArrayList<>(CardRegistry.all()); + Collections.shuffle(cards); + return cards.stream() + .limit(5) + .map(GameCard::new) + .toList(); + } } diff --git a/src/main/java/com/trunksbomb/minetriad/world/DuelTableBlock.java b/src/main/java/com/trunksbomb/minetriad/world/DuelTableBlock.java index ef961aa..4658b4c 100644 --- a/src/main/java/com/trunksbomb/minetriad/world/DuelTableBlock.java +++ b/src/main/java/com/trunksbomb/minetriad/world/DuelTableBlock.java @@ -2,6 +2,7 @@ package com.trunksbomb.minetriad.world; import com.mojang.serialization.MapCodec; import com.trunksbomb.minetriad.MineTriad; +import com.trunksbomb.minetriad.blockentity.DuelTableAnimationResolver; import com.trunksbomb.minetriad.blockentity.DuelTableBlockEntity; import com.trunksbomb.minetriad.card.CardStackData; import com.trunksbomb.minetriad.game.DuelSession; @@ -26,6 +27,8 @@ import net.minecraft.world.level.block.HorizontalDirectionalBlock; import net.minecraft.world.level.block.RenderShape; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityTicker; +import net.minecraft.world.level.block.entity.BlockEntityType; import net.minecraft.world.level.block.state.StateDefinition; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.properties.BlockStateProperties; @@ -67,16 +70,24 @@ public class DuelTableBlock extends BaseEntityBlock { DuelSession session = DuelSessionManager.get(player); if (session == null) { + boolean aiVsAi = stack.isEmpty(); try { - session = DuelSessionManager.start(player, pos); + session = DuelSessionManager.start(player, pos, aiVsAi); } catch (IllegalStateException exception) { player.displayClientMessage(Component.literal("You need 5 Triad Cards in your inventory to start a duel.").withStyle(ChatFormatting.YELLOW), false); return ItemInteractionResult.CONSUME; } table.clearBoard(); table.setParticipants(session.playerParticipantId(), session.opponentParticipantId()); - player.displayClientMessage(session.startMessage().copy().withStyle(ChatFormatting.GOLD), false); - player.displayClientMessage(session.handSummary(), false); + 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) { + DuelTableAnimationResolver.startAiTurn(table, session, serverPlayer); + } + } else { + player.displayClientMessage(session.startMessage().copy().withStyle(ChatFormatting.GOLD), false); + player.displayClientMessage(session.handSummary(), false); + } return ItemInteractionResult.CONSUME; } @@ -85,19 +96,8 @@ public class DuelTableBlock extends BaseEntityBlock { return ItemInteractionResult.CONSUME; } - if (!session.isPlayerTurn()) { - MoveResult opponentMove = session.playOpponentTurn(); - if (!opponentMove.valid()) { - player.displayClientMessage(Component.literal(opponentMove.errorMessage()).withStyle(ChatFormatting.RED), false); - clearAndRefund(level, pos, player, true); - return ItemInteractionResult.CONSUME; - } - placeBoardCard(table, opponentMove, DuelTableBlockEntity.OWNER_SECOND); - applyCapturedOwnership(table, opponentMove, DuelTableBlockEntity.OWNER_SECOND); - player.displayClientMessage(Component.literal("Training Duelist takes a turn."), false); - sendBattleLog(player, opponentMove, ChatFormatting.GRAY); - player.displayClientMessage(session.boardSummary(), false); - finishIfComplete(level, pos, player, session); + if (table.hasActiveAnimation() || session.isAnimating()) { + player.displayClientMessage(Component.literal("Wait for the current card animation to finish.").withStyle(ChatFormatting.YELLOW), false); return ItemInteractionResult.CONSUME; } @@ -126,36 +126,15 @@ public class DuelTableBlock extends BaseEntityBlock { clearAndRefund(level, pos, player, true); return ItemInteractionResult.CONSUME; } - applyCapturedOwnership(table, playerMove, DuelTableBlockEntity.OWNER_FIRST); if (!player.getAbilities().instabuild) { stack.consume(1, player); } + session.beginPlayerAnimation(); + table.startMoveAnimation(playerMove, DuelTableBlockEntity.OWNER_FIRST); player.displayClientMessage(Component.literal("You play " + playerMove.playedCardName() + "."), false); sendBattleLog(player, playerMove, ChatFormatting.DARK_AQUA); - player.displayClientMessage(session.boardSummary(), false); - finishIfComplete(level, pos, player, session); - if (session.isComplete()) { - return ItemInteractionResult.CONSUME; - } - - MoveResult opponentMove = session.playOpponentTurn(); - if (!opponentMove.valid()) { - player.displayClientMessage(Component.literal(opponentMove.errorMessage()).withStyle(ChatFormatting.RED), false); - clearAndRefund(level, pos, player, true); - return ItemInteractionResult.CONSUME; - } - placeBoardCard(table, opponentMove, DuelTableBlockEntity.OWNER_SECOND); - applyCapturedOwnership(table, opponentMove, DuelTableBlockEntity.OWNER_SECOND); - player.displayClientMessage(Component.literal("Training Duelist answers with " + opponentMove.playedCardName() + "."), false); - sendBattleLog(player, opponentMove, ChatFormatting.GRAY); - player.displayClientMessage(session.boardSummary(), false); - finishIfComplete(level, pos, player, session); - if (!session.isComplete()) { - player.displayClientMessage(Component.literal("Your turn. Hold one of your remaining duel cards and click an open space.").withStyle(ChatFormatting.YELLOW), false); - player.displayClientMessage(session.handSummary(), false); - } return ItemInteractionResult.CONSUME; } catch (Exception exception) { MineTriad.LOGGER.error("Duel table interaction failed", exception); @@ -203,6 +182,11 @@ public class DuelTableBlock extends BaseEntityBlock { return new DuelTableBlockEntity(pos, state); } + @Override + public BlockEntityTicker getTicker(Level level, BlockState state, BlockEntityType blockEntityType) { + return createTickerHelper(blockEntityType, com.trunksbomb.minetriad.registry.TriadBlockEntities.DUEL_TABLE.get(), DuelTableBlockEntity::tick); + } + @Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; @@ -225,12 +209,6 @@ public class DuelTableBlock extends BaseEntityBlock { return table.setCard(moveResult.playedCell().index(), CardItem.createCardStack(moveResult.playedCardId(), TriadItems.TRIAD_CARD.get()), owner); } - private static void applyCapturedOwnership(DuelTableBlockEntity table, MoveResult moveResult, int owner) { - for (var cell : moveResult.capturedCells()) { - table.setOwner(cell.index(), owner); - } - } - private static void sendBattleLog(Player player, MoveResult moveResult, ChatFormatting color) { for (String line : moveResult.battleLog()) { player.displayClientMessage(Component.literal(line).withStyle(color), false); @@ -242,16 +220,6 @@ public class DuelTableBlock extends BaseEntityBlock { return blockEntity instanceof DuelTableBlockEntity duelTableBlockEntity ? duelTableBlockEntity : null; } - private static void finishIfComplete(Level level, BlockPos pos, Player player, DuelSession session) { - if (!session.isComplete()) { - return; - } - - session.enterOverview(); - player.displayClientMessage(session.resultSummary().copy().withStyle(ChatFormatting.AQUA), false); - player.displayClientMessage(session.overviewMessage().copy().withStyle(ChatFormatting.YELLOW), false); - } - private static void clearAndRefund(Level level, BlockPos pos, Player player, boolean refundFullHand) { DuelTableBlockEntity table = getTableEntity(level, pos); if (table != null) { @@ -263,9 +231,8 @@ public class DuelTableBlock extends BaseEntityBlock { private static void completeOverview(Level level, BlockPos pos, Player player, DuelSession session) { DuelTableBlockEntity table = getTableEntity(level, pos); if (table != null) { - table.clearBoard(); + session.beginClosingAnimation(); + table.startClearWaveAnimation(); } - DuelSessionManager.end(player, true); - player.displayClientMessage(Component.literal("Duel finished. All played cards have been returned.").withStyle(ChatFormatting.GREEN), false); } }