cards now have some simple animations: idle bob on the board, placement, card capture flip, game over wave, and end duel pop up

This commit is contained in:
trunksbomb
2026-03-23 12:29:01 -04:00
parent 4cd60e4fac
commit 261f540317
6 changed files with 596 additions and 66 deletions

View File

@@ -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());
}
}

View File

@@ -1,17 +1,26 @@
package com.trunksbomb.minetriad.blockentity; package com.trunksbomb.minetriad.blockentity;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; 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.TriadBlockEntities;
import com.trunksbomb.minetriad.registry.TriadItems; import com.trunksbomb.minetriad.registry.TriadItems;
import net.minecraft.ChatFormatting;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup; import net.minecraft.core.HolderLookup;
import net.minecraft.core.NonNullList; import net.minecraft.core.NonNullList;
import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.Connection; import net.minecraft.network.Connection;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.network.chat.Component;
import net.minecraft.world.ContainerHelper; import net.minecraft.world.ContainerHelper;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level; 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_NONE = 0;
public static final int OWNER_FIRST = 1; public static final int OWNER_FIRST = 1;
public static final int OWNER_SECOND = 2; 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<ItemStack> boardCards = NonNullList.withSize(SLOT_COUNT, ItemStack.EMPTY); private final NonNullList<ItemStack> boardCards = NonNullList.withSize(SLOT_COUNT, ItemStack.EMPTY);
private final int[] ownerSlots = new int[SLOT_COUNT]; private final int[] ownerSlots = new int[SLOT_COUNT];
private final int[] slotAges = new int[SLOT_COUNT];
private final ArrayDeque<AnimationStep> pendingAnimations = new ArrayDeque<>();
private UUID firstParticipantId; private UUID firstParticipantId;
private UUID secondParticipantId; 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) { public DuelTableBlockEntity(BlockPos pos, BlockState blockState) {
super(TriadBlockEntities.DUEL_TABLE.get(), pos, blockState); super(TriadBlockEntities.DUEL_TABLE.get(), pos, blockState);
@@ -42,6 +66,10 @@ public class DuelTableBlockEntity extends BlockEntity {
return ownerSlots[slot]; return ownerSlots[slot];
} }
public int slotAge(int slot) {
return slot >= 0 && slot < slotAges.length ? slotAges[slot] : 0;
}
public Optional<UUID> firstParticipantId() { public Optional<UUID> firstParticipantId() {
return Optional.ofNullable(firstParticipantId); return Optional.ofNullable(firstParticipantId);
} }
@@ -50,6 +78,52 @@ public class DuelTableBlockEntity extends BlockEntity {
return Optional.ofNullable(secondParticipantId); 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) { public void setParticipants(UUID firstParticipantId, UUID secondParticipantId) {
this.firstParticipantId = firstParticipantId; this.firstParticipantId = firstParticipantId;
this.secondParticipantId = secondParticipantId; this.secondParticipantId = secondParticipantId;
@@ -63,6 +137,7 @@ public class DuelTableBlockEntity extends BlockEntity {
boardCards.set(slot, stack.copyWithCount(1)); boardCards.set(slot, stack.copyWithCount(1));
ownerSlots[slot] = owner; ownerSlots[slot] = owner;
slotAges[slot] = 0;
sync(); sync();
return true; return true;
} }
@@ -79,7 +154,10 @@ public class DuelTableBlockEntity extends BlockEntity {
for (int index = 0; index < boardCards.size(); index++) { for (int index = 0; index < boardCards.size(); index++) {
boardCards.set(index, ItemStack.EMPTY); boardCards.set(index, ItemStack.EMPTY);
ownerSlots[index] = OWNER_NONE; ownerSlots[index] = OWNER_NONE;
slotAges[index] = 0;
} }
pendingAnimations.clear();
clearAnimationState();
firstParticipantId = null; firstParticipantId = null;
secondParticipantId = null; secondParticipantId = null;
sync(); sync();
@@ -92,18 +170,106 @@ public class DuelTableBlockEntity extends BlockEntity {
Block.popResource(level, pos, stack); Block.popResource(level, pos, stack);
boardCards.set(slot, ItemStack.EMPTY); boardCards.set(slot, ItemStack.EMPTY);
ownerSlots[slot] = OWNER_NONE; ownerSlots[slot] = OWNER_NONE;
slotAges[slot] = 0;
} }
} }
pendingAnimations.clear();
clearAnimationState();
firstParticipantId = null; firstParticipantId = null;
secondParticipantId = null; secondParticipantId = null;
sync(); 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 @Override
protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) { protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.saveAdditional(tag, registries); super.saveAdditional(tag, registries);
ContainerHelper.saveAllItems(tag, boardCards, registries); ContainerHelper.saveAllItems(tag, boardCards, registries);
tag.putIntArray("OwnerSlots", ownerSlots); tag.putIntArray("OwnerSlots", ownerSlots);
tag.putIntArray("SlotAges", slotAges);
saveAnimation(tag);
saveTableAnimation(tag);
if (firstParticipantId != null) { if (firstParticipantId != null) {
tag.putUUID("FirstParticipantId", firstParticipantId); tag.putUUID("FirstParticipantId", firstParticipantId);
} }
@@ -118,6 +284,8 @@ public class DuelTableBlockEntity extends BlockEntity {
clearBoardContents(); clearBoardContents();
ContainerHelper.loadAllItems(tag, boardCards, registries); ContainerHelper.loadAllItems(tag, boardCards, registries);
loadOwnerData(tag); loadOwnerData(tag);
loadAnimation(tag);
loadTableAnimation(tag);
} }
@Override @Override
@@ -125,6 +293,9 @@ public class DuelTableBlockEntity extends BlockEntity {
CompoundTag tag = super.getUpdateTag(registries); CompoundTag tag = super.getUpdateTag(registries);
ContainerHelper.saveAllItems(tag, boardCards, registries); ContainerHelper.saveAllItems(tag, boardCards, registries);
tag.putIntArray("OwnerSlots", ownerSlots); tag.putIntArray("OwnerSlots", ownerSlots);
tag.putIntArray("SlotAges", slotAges);
saveAnimation(tag);
saveTableAnimation(tag);
if (firstParticipantId != null) { if (firstParticipantId != null) {
tag.putUUID("FirstParticipantId", firstParticipantId); tag.putUUID("FirstParticipantId", firstParticipantId);
} }
@@ -146,6 +317,8 @@ public class DuelTableBlockEntity extends BlockEntity {
clearBoardContents(); clearBoardContents();
ContainerHelper.loadAllItems(tag, boardCards, registries); ContainerHelper.loadAllItems(tag, boardCards, registries);
loadOwnerData(tag); loadOwnerData(tag);
loadAnimation(tag);
loadTableAnimation(tag);
} }
} }
@@ -153,7 +326,10 @@ public class DuelTableBlockEntity extends BlockEntity {
for (int index = 0; index < boardCards.size(); index++) { for (int index = 0; index < boardCards.size(); index++) {
boardCards.set(index, ItemStack.EMPTY); boardCards.set(index, ItemStack.EMPTY);
ownerSlots[index] = OWNER_NONE; ownerSlots[index] = OWNER_NONE;
slotAges[index] = 0;
} }
pendingAnimations.clear();
clearAnimationState();
firstParticipantId = null; firstParticipantId = null;
secondParticipantId = null; secondParticipantId = null;
} }
@@ -163,14 +339,153 @@ public class DuelTableBlockEntity extends BlockEntity {
for (int index = 0; index < ownerSlots.length; index++) { for (int index = 0; index < ownerSlots.length; index++) {
ownerSlots[index] = index < loadedOwners.length ? loadedOwners[index] : OWNER_NONE; 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; firstParticipantId = tag.hasUUID("FirstParticipantId") ? tag.getUUID("FirstParticipantId") : null;
secondParticipantId = tag.hasUUID("SecondParticipantId") ? tag.getUUID("SecondParticipantId") : 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() { private void sync() {
setChanged(); setChanged();
if (level != null) { if (level != null) {
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), Block.UPDATE_ALL); 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) {
}
} }

View File

@@ -14,6 +14,10 @@ import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
public class DuelTableBlockEntityRenderer implements BlockEntityRenderer<DuelTableBlockEntity> { public class DuelTableBlockEntityRenderer implements BlockEntityRenderer<DuelTableBlockEntity> {
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) { public DuelTableBlockEntityRenderer(BlockEntityRendererProvider.Context context) {
} }
@@ -27,10 +31,41 @@ public class DuelTableBlockEntityRenderer implements BlockEntityRenderer<DuelTab
BoardCell cell = new BoardCell(slot / BoardCell.SIZE, slot % BoardCell.SIZE); BoardCell cell = new BoardCell(slot / BoardCell.SIZE, slot % BoardCell.SIZE);
BoardLocalSpace.SlotCenter center = BoardLocalSpace.slotCenter(cell, blockEntity.getBlockState().getValue(DuelTableBlock.FACING)); BoardLocalSpace.SlotCenter center = BoardLocalSpace.slotCenter(cell, blockEntity.getBlockState().getValue(DuelTableBlock.FACING));
float y = TABLE_CARD_Y;
float extraFlip = 0.0F;
boolean hideCard = false;
float idleOffset = idleBob(blockEntity, slot, partialTick);
if (blockEntity.animationSlot() == slot) {
float progress = blockEntity.animationProgress(partialTick);
if (blockEntity.isPlacementAnimation()) {
y += (1.0F - progress) * 0.18F;
} else if (blockEntity.isCaptureAnimation()) {
y += idleOffset;
y += (float) Math.sin(progress * Math.PI) * 0.08F;
extraFlip = 360.0F * progress;
}
} else if (!blockEntity.hasActiveAnimation()) {
y += idleOffset;
}
if (blockEntity.hasOverviewSweepAnimation()) {
float progress = blockEntity.overviewSweepSlotProgress(slot, partialTick);
y += (float) Math.sin(progress * Math.PI) * 0.05F;
}
if (blockEntity.hasClearWaveAnimation()) {
float progress = blockEntity.clearWaveSlotProgress(slot, partialTick);
y += progress * 0.22F;
hideCard = progress >= 1.0F;
}
if (hideCard) {
continue;
}
poseStack.pushPose(); 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)))); 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.mulPose(Axis.XN.rotationDegrees(90.0F));
poseStack.scale(0.24F, 0.24F, 0.24F); poseStack.scale(0.24F, 0.24F, 0.24F);
TriadCardItemRenderer.renderCard(stack, poseStack, buffer, perspectivePalette(blockEntity, blockEntity.ownerAt(slot))); TriadCardItemRenderer.renderCard(stack, poseStack, buffer, perspectivePalette(blockEntity, blockEntity.ownerAt(slot)));
@@ -38,6 +73,21 @@ public class DuelTableBlockEntityRenderer implements BlockEntityRenderer<DuelTab
} }
} }
private static float idleBob(DuelTableBlockEntity blockEntity, int slot, float partialTick) {
int age = blockEntity.slotAge(slot);
int delay = 6 + deterministicOffset(blockEntity, slot) % 18;
if (age <= delay) {
return 0.0F;
}
float bobTime = (age - delay) + partialTick;
return (float) Math.sin(bobTime * IDLE_BOB_SPEED) * IDLE_BOB_HEIGHT;
}
private static int deterministicOffset(DuelTableBlockEntity blockEntity, int slot) {
return Math.floorMod(blockEntity.getBlockPos().hashCode() * 31 + slot * 17, 97);
}
private static TriadCardItemRenderer.CardPalette perspectivePalette(DuelTableBlockEntity blockEntity, int owner) { private static TriadCardItemRenderer.CardPalette perspectivePalette(DuelTableBlockEntity blockEntity, int owner) {
if (owner == DuelTableBlockEntity.OWNER_NONE || Minecraft.getInstance().player == null) { if (owner == DuelTableBlockEntity.OWNER_NONE || Minecraft.getInstance().player == null) {
return new TriadCardItemRenderer.CardPalette( return new TriadCardItemRenderer.CardPalette(

View File

@@ -18,6 +18,9 @@ import net.minecraft.world.phys.BlockHitResult;
public final class DuelSession { public final class DuelSession {
private enum Phase { private enum Phase {
PLAYING, PLAYING,
PLAYER_ANIMATION,
OPPONENT_ANIMATION,
CLOSING_ANIMATION,
OVERVIEW OVERVIEW
} }
@@ -26,6 +29,7 @@ public final class DuelSession {
private final TriadMatch match; private final TriadMatch match;
private final List<ResourceLocation> refundablePlayerCards; private final List<ResourceLocation> refundablePlayerCards;
private final boolean refundCardsOnEnd; private final boolean refundCardsOnEnd;
private final boolean aiVsAi;
private final BlockPos tablePos; private final BlockPos tablePos;
private Phase phase; private Phase phase;
@@ -36,12 +40,14 @@ public final class DuelSession {
List<GameCard> opponentHand, List<GameCard> opponentHand,
List<ResourceLocation> refundablePlayerCards, List<ResourceLocation> refundablePlayerCards,
boolean refundCardsOnEnd, boolean refundCardsOnEnd,
boolean aiVsAi,
BlockPos tablePos) { BlockPos tablePos) {
this.playerParticipant = new MatchParticipant(playerId, playerName); this.playerParticipant = new MatchParticipant(playerId, playerName);
this.opponentParticipant = new MatchParticipant(UUID.nameUUIDFromBytes(("opponent:" + playerId).getBytes()), "Training Duelist"); this.opponentParticipant = new MatchParticipant(UUID.nameUUIDFromBytes(("opponent:" + playerId).getBytes()), "Training Duelist");
this.match = new TriadMatch(playerParticipant, opponentParticipant, playerHand, opponentHand, TriadRuleSet.CLASSIC_OPEN); this.match = new TriadMatch(playerParticipant, opponentParticipant, playerHand, opponentHand, TriadRuleSet.CLASSIC_OPEN);
this.refundablePlayerCards = List.copyOf(refundablePlayerCards); this.refundablePlayerCards = List.copyOf(refundablePlayerCards);
this.refundCardsOnEnd = refundCardsOnEnd; this.refundCardsOnEnd = refundCardsOnEnd;
this.aiVsAi = aiVsAi;
this.tablePos = tablePos.immutable(); this.tablePos = tablePos.immutable();
this.phase = Phase.PLAYING; this.phase = Phase.PLAYING;
} }
@@ -62,6 +68,42 @@ public final class DuelSession {
return phase == Phase.OVERVIEW; 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() { public void enterOverview() {
phase = Phase.OVERVIEW; phase = Phase.OVERVIEW;
} }
@@ -96,15 +138,23 @@ public final class DuelSession {
} }
public MoveResult playOpponentTurn() { public MoveResult playOpponentTurn() {
return playAiTurnFor(opponentParticipant);
}
public MoveResult playAiTurn() {
return playAiTurnFor(match.activeParticipant());
}
private MoveResult playAiTurnFor(MatchParticipant participant) {
int bestHandIndex = -1; int bestHandIndex = -1;
BoardCell bestCell = null; BoardCell bestCell = null;
int bestCaptures = -1; int bestCaptures = -1;
List<GameCard> opponentHand = match.handFor(opponentParticipant); List<GameCard> hand = match.handFor(participant);
for (int handIndex = 0; handIndex < opponentHand.size(); handIndex++) { for (int handIndex = 0; handIndex < hand.size(); handIndex++) {
for (BoardCell cell : openCells()) { for (BoardCell cell : openCells()) {
TriadMatch probe = duplicateMatch(); TriadMatch probe = duplicateMatch();
MoveResult result = probe.play(new TriadMove(opponentParticipant, handIndex, cell)); MoveResult result = probe.play(new TriadMove(participant, handIndex, cell));
if (!result.valid()) { if (!result.valid()) {
continue; continue;
} }
@@ -121,7 +171,7 @@ public final class DuelSession {
return MoveResult.failure("Opponent could not find a legal move"); 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() { public Component boardSummary() {

View File

@@ -6,6 +6,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.Collections;
import com.trunksbomb.minetriad.card.CardRegistry; import com.trunksbomb.minetriad.card.CardRegistry;
import com.trunksbomb.minetriad.card.CardDefinition; import com.trunksbomb.minetriad.card.CardDefinition;
@@ -29,12 +30,27 @@ public final class DuelSessionManager {
return ACTIVE_DUELS.get(player.getUUID()); 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) { public static DuelSession start(Player player, BlockPos tablePos) {
return start(player, tablePos, false);
}
public static DuelSession start(Player player, BlockPos tablePos, boolean aiVsAi) {
List<GameCard> playerHand = buildPlayerHand(player.getInventory()); List<GameCard> 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"); throw new IllegalStateException("A duel requires 5 Triad Cards in your inventory");
} }
if (aiVsAi) {
playerHand = buildRandomHand();
}
List<ResourceLocation> refundableCards = playerHand.stream() List<ResourceLocation> refundableCards = playerHand.stream()
.map(card -> card.definition().id()) .map(card -> card.definition().id())
.toList(); .toList();
@@ -42,9 +58,10 @@ public final class DuelSessionManager {
player.getUUID(), player.getUUID(),
player.getName().getString(), player.getName().getString(),
playerHand, playerHand,
buildOpponentHand(), aiVsAi ? buildRandomHand() : buildOpponentHand(),
refundableCards, refundableCards,
!player.getAbilities().instabuild, !aiVsAi && !player.getAbilities().instabuild,
aiVsAi,
tablePos); tablePos);
ACTIVE_DUELS.put(player.getUUID(), session); ACTIVE_DUELS.put(player.getUUID(), session);
return session; return session;
@@ -109,4 +126,13 @@ public final class DuelSessionManager {
.map(GameCard::new) .map(GameCard::new)
.toList(); .toList();
} }
private static List<GameCard> buildRandomHand() {
List<CardDefinition> cards = new ArrayList<>(CardRegistry.all());
Collections.shuffle(cards);
return cards.stream()
.limit(5)
.map(GameCard::new)
.toList();
}
} }

View File

@@ -2,6 +2,7 @@ package com.trunksbomb.minetriad.world;
import com.mojang.serialization.MapCodec; import com.mojang.serialization.MapCodec;
import com.trunksbomb.minetriad.MineTriad; import com.trunksbomb.minetriad.MineTriad;
import com.trunksbomb.minetriad.blockentity.DuelTableAnimationResolver;
import com.trunksbomb.minetriad.blockentity.DuelTableBlockEntity; import com.trunksbomb.minetriad.blockentity.DuelTableBlockEntity;
import com.trunksbomb.minetriad.card.CardStackData; import com.trunksbomb.minetriad.card.CardStackData;
import com.trunksbomb.minetriad.game.DuelSession; 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.RenderShape;
import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.entity.BlockEntity; 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.StateDefinition;
import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties; import net.minecraft.world.level.block.state.properties.BlockStateProperties;
@@ -67,16 +70,24 @@ public class DuelTableBlock extends BaseEntityBlock {
DuelSession session = DuelSessionManager.get(player); DuelSession session = DuelSessionManager.get(player);
if (session == null) { if (session == null) {
boolean aiVsAi = stack.isEmpty();
try { try {
session = DuelSessionManager.start(player, pos); session = DuelSessionManager.start(player, pos, aiVsAi);
} catch (IllegalStateException exception) { } catch (IllegalStateException exception) {
player.displayClientMessage(Component.literal("You need 5 Triad Cards in your inventory to start a duel.").withStyle(ChatFormatting.YELLOW), false); player.displayClientMessage(Component.literal("You need 5 Triad Cards in your inventory to start a duel.").withStyle(ChatFormatting.YELLOW), false);
return ItemInteractionResult.CONSUME; return ItemInteractionResult.CONSUME;
} }
table.clearBoard(); table.clearBoard();
table.setParticipants(session.playerParticipantId(), session.opponentParticipantId()); table.setParticipants(session.playerParticipantId(), session.opponentParticipantId());
player.displayClientMessage(session.startMessage().copy().withStyle(ChatFormatting.GOLD), false); if (aiVsAi) {
player.displayClientMessage(session.handSummary(), false); 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; return ItemInteractionResult.CONSUME;
} }
@@ -85,19 +96,8 @@ public class DuelTableBlock extends BaseEntityBlock {
return ItemInteractionResult.CONSUME; return ItemInteractionResult.CONSUME;
} }
if (!session.isPlayerTurn()) { if (table.hasActiveAnimation() || session.isAnimating()) {
MoveResult opponentMove = session.playOpponentTurn(); player.displayClientMessage(Component.literal("Wait for the current card animation to finish.").withStyle(ChatFormatting.YELLOW), false);
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);
return ItemInteractionResult.CONSUME; return ItemInteractionResult.CONSUME;
} }
@@ -126,36 +126,15 @@ public class DuelTableBlock extends BaseEntityBlock {
clearAndRefund(level, pos, player, true); clearAndRefund(level, pos, player, true);
return ItemInteractionResult.CONSUME; return ItemInteractionResult.CONSUME;
} }
applyCapturedOwnership(table, playerMove, DuelTableBlockEntity.OWNER_FIRST);
if (!player.getAbilities().instabuild) { if (!player.getAbilities().instabuild) {
stack.consume(1, player); stack.consume(1, player);
} }
session.beginPlayerAnimation();
table.startMoveAnimation(playerMove, DuelTableBlockEntity.OWNER_FIRST);
player.displayClientMessage(Component.literal("You play " + playerMove.playedCardName() + "."), false); player.displayClientMessage(Component.literal("You play " + playerMove.playedCardName() + "."), false);
sendBattleLog(player, playerMove, ChatFormatting.DARK_AQUA); 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; return ItemInteractionResult.CONSUME;
} catch (Exception exception) { } catch (Exception exception) {
MineTriad.LOGGER.error("Duel table interaction failed", exception); MineTriad.LOGGER.error("Duel table interaction failed", exception);
@@ -203,6 +182,11 @@ public class DuelTableBlock extends BaseEntityBlock {
return new DuelTableBlockEntity(pos, state); return new DuelTableBlockEntity(pos, state);
} }
@Override
public <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state, BlockEntityType<T> blockEntityType) {
return createTickerHelper(blockEntityType, com.trunksbomb.minetriad.registry.TriadBlockEntities.DUEL_TABLE.get(), DuelTableBlockEntity::tick);
}
@Override @Override
protected RenderShape getRenderShape(BlockState state) { protected RenderShape getRenderShape(BlockState state) {
return RenderShape.MODEL; 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); 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) { private static void sendBattleLog(Player player, MoveResult moveResult, ChatFormatting color) {
for (String line : moveResult.battleLog()) { for (String line : moveResult.battleLog()) {
player.displayClientMessage(Component.literal(line).withStyle(color), false); player.displayClientMessage(Component.literal(line).withStyle(color), false);
@@ -242,16 +220,6 @@ public class DuelTableBlock extends BaseEntityBlock {
return blockEntity instanceof DuelTableBlockEntity duelTableBlockEntity ? duelTableBlockEntity : null; 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) { private static void clearAndRefund(Level level, BlockPos pos, Player player, boolean refundFullHand) {
DuelTableBlockEntity table = getTableEntity(level, pos); DuelTableBlockEntity table = getTableEntity(level, pos);
if (table != null) { if (table != null) {
@@ -263,9 +231,8 @@ public class DuelTableBlock extends BaseEntityBlock {
private static void completeOverview(Level level, BlockPos pos, Player player, DuelSession session) { private static void completeOverview(Level level, BlockPos pos, Player player, DuelSession session) {
DuelTableBlockEntity table = getTableEntity(level, pos); DuelTableBlockEntity table = getTableEntity(level, pos);
if (table != null) { 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);
} }
} }