Compare commits

..

8 Commits

13 changed files with 1744 additions and 113 deletions

View File

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

View File

@@ -0,0 +1,115 @@
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.updateHudState(session);
table.startMoveAnimation(opponentMove, DuelTableBlockEntity.OWNER_SECOND);
}
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();
table.updateHudState(session);
}
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.updateHudState(session);
table.startMoveAnimation(aiMove, owner);
}
private static void enterOverview(DuelTableBlockEntity table, DuelSession session, ServerPlayer player) {
session.enterOverview();
table.setRewardCards(session.opponentRewardCards().stream()
.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);
}
private static ServerPlayer getPlayer(Level level, DuelSession session) {
return level.getServer() == null ? null : level.getServer().getPlayerList().getPlayer(session.playerParticipantId());
}
}

View File

@@ -1,34 +1,81 @@
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.nbt.Tag;
import net.minecraft.network.Connection;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.Interaction;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.AABB;
import com.trunksbomb.minetriad.game.BoardLocalSpace;
import com.trunksbomb.minetriad.world.DuelTableBlock;
public class DuelTableBlockEntity extends BlockEntity {
public static final String REWARD_PROXY_TAG = "minetriad.reward_proxy";
public static final String REWARD_PROXY_POS_PREFIX = "minetriad.reward_pos:";
public static final String REWARD_PROXY_SLOT_PREFIX = "minetriad.reward_slot:";
public static final int SLOT_COUNT = 9;
public static final int REWARD_SLOT_COUNT = 5;
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 static final int REWARD_TAKE_DURATION = 14;
private final NonNullList<ItemStack> boardCards = NonNullList.withSize(SLOT_COUNT, ItemStack.EMPTY);
private final NonNullList<ItemStack> rewardCards = NonNullList.withSize(REWARD_SLOT_COUNT, ItemStack.EMPTY);
private final List<RewardTakeAnimation> rewardTakeAnimations = new ArrayList<>();
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 secondParticipantId;
private AnimationType animationType = AnimationType.NONE;
private int[] animationSlots = new int[0];
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;
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);
@@ -38,10 +85,32 @@ public class DuelTableBlockEntity extends BlockEntity {
return boardCards.get(slot);
}
public ItemStack getRewardCard(int slot) {
return slot >= 0 && slot < rewardCards.size() ? rewardCards.get(slot) : ItemStack.EMPTY;
}
public int rewardCardCount() {
int count = 0;
for (ItemStack rewardCard : rewardCards) {
if (!rewardCard.isEmpty()) {
count++;
}
}
return count;
}
public List<RewardTakeAnimation> rewardTakeAnimations() {
return List.copyOf(rewardTakeAnimations);
}
public int ownerAt(int slot) {
return ownerSlots[slot];
}
public int slotAge(int slot) {
return slot >= 0 && slot < slotAges.length ? slotAges[slot] : 0;
}
public Optional<UUID> firstParticipantId() {
return Optional.ofNullable(firstParticipantId);
}
@@ -50,12 +119,131 @@ 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;
}
public String animationTypeName() {
return animationType.name();
}
public boolean isPlacementAnimation() {
return animationType == AnimationType.PLACEMENT;
}
public boolean isCaptureAnimation() {
return animationType == AnimationType.CAPTURE;
}
public int animationSlot() {
return animationSlots.length > 0 ? animationSlots[0] : -1;
}
public boolean isAnimatingSlot(int slot) {
for (int animatedSlot : animationSlots) {
if (animatedSlot == slot) {
return true;
}
}
return false;
}
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;
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;
@@ -63,6 +251,7 @@ public class DuelTableBlockEntity extends BlockEntity {
boardCards.set(slot, stack.copyWithCount(1));
ownerSlots[slot] = owner;
slotAges[slot] = 0;
sync();
return true;
}
@@ -79,12 +268,33 @@ 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();
clearRewardCards();
firstParticipantId = null;
secondParticipantId = null;
clearHudState();
sync();
}
public void removeBoardCardsOwnedBy(int owner) {
boolean changed = false;
for (int index = 0; index < boardCards.size(); index++) {
if (ownerSlots[index] != owner || boardCards.get(index).isEmpty()) {
continue;
}
boardCards.set(index, ItemStack.EMPTY);
ownerSlots[index] = OWNER_NONE;
slotAges[index] = 0;
changed = true;
}
if (changed) {
sync();
}
}
public void dropBoardCards(Level level, BlockPos pos) {
for (int slot = 0; slot < boardCards.size(); slot++) {
ItemStack stack = boardCards.get(slot);
@@ -92,18 +302,267 @@ 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();
dropRewardCards(level, pos);
firstParticipantId = null;
secondParticipantId = null;
clearHudState();
sync();
}
public void setRewardCards(List<ItemStack> stacks) {
clearRewardCards();
for (int index = 0; index < rewardCards.size() && index < stacks.size(); index++) {
ItemStack stack = stacks.get(index);
if (!stack.isEmpty()) {
rewardCards.set(index, stack.copyWithCount(1));
}
}
refreshRewardInteractions();
DuelSession session = level == null ? null : DuelSessionManager.getAt(worldPosition);
if (session != null) {
updateHudState(session);
}
sync();
}
public ItemStack takeRewardCard(int slot) {
if (slot < 0 || slot >= rewardCards.size()) {
return ItemStack.EMPTY;
}
ItemStack taken = rewardCards.get(slot);
if (taken.isEmpty()) {
return ItemStack.EMPTY;
}
int visibleIndex = visibleRewardIndex(slot);
int rewardCountBeforeTake = rewardCardCount();
if (visibleIndex >= 0 && rewardCountBeforeTake > 0) {
BoardLocalSpace.SlotCenter center = BoardLocalSpace.rewardCenter(
visibleIndex,
getBlockState().getValue(DuelTableBlock.FACING),
rewardCountBeforeTake);
rewardTakeAnimations.add(new RewardTakeAnimation(taken.copyWithCount(1), center.x(), center.z(), 0, REWARD_TAKE_DURATION));
}
rewardCards.set(slot, ItemStack.EMPTY);
refreshRewardInteractions();
DuelSession session = level == null ? null : DuelSessionManager.getAt(worldPosition);
if (session != null) {
updateHudState(session);
}
sync();
return taken;
}
public void clearRewardCards() {
for (int index = 0; index < rewardCards.size(); index++) {
rewardCards.set(index, ItemStack.EMPTY);
}
rewardTakeAnimations.clear();
removeRewardInteractions();
}
public void dropRewardCards(Level level, BlockPos pos) {
for (int slot = 0; slot < rewardCards.size(); slot++) {
ItemStack stack = rewardCards.get(slot);
if (!stack.isEmpty()) {
Block.popResource(level, pos, stack);
rewardCards.set(slot, ItemStack.EMPTY);
}
}
rewardTakeAnimations.clear();
removeRewardInteractions();
}
public void refreshRewardInteractions() {
if (level == null || level.isClientSide) {
return;
}
removeRewardInteractions();
int visibleIndex = 0;
var facing = getBlockState().getValue(DuelTableBlock.FACING);
for (int slot = 0; slot < rewardCards.size(); slot++) {
if (rewardCards.get(slot).isEmpty()) {
continue;
}
BoardLocalSpace.SlotCenter center = BoardLocalSpace.rewardCenter(visibleIndex, facing, rewardCardCount());
Interaction interaction = new Interaction(EntityType.INTERACTION, level);
interaction.setPos(worldPosition.getX() + center.x(), worldPosition.getY() + 1.12D, worldPosition.getZ() + center.z());
interaction.setNoGravity(true);
interaction.addTag(REWARD_PROXY_TAG);
interaction.addTag(REWARD_PROXY_POS_PREFIX + worldPosition.getX() + "," + worldPosition.getY() + "," + worldPosition.getZ());
interaction.addTag(REWARD_PROXY_SLOT_PREFIX + slot);
level.addFreshEntity(interaction);
visibleIndex++;
}
}
public void removeRewardInteractions() {
if (level == null || level.isClientSide) {
return;
}
AABB searchBox = new AABB(worldPosition).inflate(2.0D, 2.0D, 2.0D);
for (Interaction interaction : level.getEntitiesOfClass(Interaction.class, searchBox, DuelTableBlockEntity::isRewardProxy)) {
if (worldPosition.equals(rewardProxyPos(interaction))) {
interaction.discard();
}
}
}
public static boolean isRewardProxy(Interaction interaction) {
return interaction.getTags().contains(REWARD_PROXY_TAG);
}
public static BlockPos rewardProxyPos(Interaction interaction) {
for (String tag : interaction.getTags()) {
if (!tag.startsWith(REWARD_PROXY_POS_PREFIX)) {
continue;
}
String[] parts = tag.substring(REWARD_PROXY_POS_PREFIX.length()).split(",");
if (parts.length == 3) {
try {
return new BlockPos(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]), Integer.parseInt(parts[2]));
} catch (NumberFormatException ignored) {
return null;
}
}
}
return null;
}
public static int rewardProxySlot(Interaction interaction) {
for (String tag : interaction.getTags()) {
if (tag.startsWith(REWARD_PROXY_SLOT_PREFIX)) {
try {
return Integer.parseInt(tag.substring(REWARD_PROXY_SLOT_PREFIX.length()));
} catch (NumberFormatException ignored) {
return -1;
}
}
}
return -1;
}
public void startMoveAnimation(MoveResult moveResult, int owner) {
pendingAnimations.clear();
clearAnimationState();
clearTableAnimationState();
if (moveResult.playedCell() == null) {
return;
}
pendingAnimations.addLast(new AnimationStep(AnimationType.PLACEMENT, new int[] {moveResult.playedCell().index()}, owner));
for (List<BoardCell> captureWave : moveResult.captureWaves()) {
int[] captureSlots = captureWave.stream()
.mapToInt(BoardCell::index)
.toArray();
if (captureSlots.length > 0) {
pendingAnimations.addLast(new AnimationStep(AnimationType.CAPTURE, captureSlots, 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)) {
for (int slot : blockEntity.animationSlots) {
blockEntity.setOwner(slot, 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;
}
}
if (!blockEntity.rewardTakeAnimations.isEmpty()) {
boolean changed = false;
for (int index = blockEntity.rewardTakeAnimations.size() - 1; index >= 0; index--) {
RewardTakeAnimation animation = blockEntity.rewardTakeAnimations.get(index);
animation.age++;
if (animation.age >= animation.duration) {
blockEntity.rewardTakeAnimations.remove(index);
changed = true;
}
}
if (!level.isClientSide && changed) {
blockEntity.sync();
}
}
}
@Override
protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.saveAdditional(tag, registries);
ContainerHelper.saveAllItems(tag, boardCards, registries);
CompoundTag rewardTag = new CompoundTag();
ContainerHelper.saveAllItems(rewardTag, rewardCards, registries);
tag.put("RewardCards", rewardTag);
saveRewardTakeAnimations(tag, registries);
saveHudState(tag);
tag.putIntArray("OwnerSlots", ownerSlots);
tag.putIntArray("SlotAges", slotAges);
saveAnimation(tag);
saveTableAnimation(tag);
if (firstParticipantId != null) {
tag.putUUID("FirstParticipantId", firstParticipantId);
}
@@ -117,14 +576,29 @@ public class DuelTableBlockEntity extends BlockEntity {
super.loadAdditional(tag, registries);
clearBoardContents();
ContainerHelper.loadAllItems(tag, boardCards, registries);
if (tag.contains("RewardCards", Tag.TAG_COMPOUND)) {
ContainerHelper.loadAllItems(tag.getCompound("RewardCards"), rewardCards, registries);
}
loadRewardTakeAnimations(tag, registries);
loadHudState(tag);
loadOwnerData(tag);
loadAnimation(tag);
loadTableAnimation(tag);
}
@Override
public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
CompoundTag tag = super.getUpdateTag(registries);
ContainerHelper.saveAllItems(tag, boardCards, registries);
CompoundTag rewardTag = new CompoundTag();
ContainerHelper.saveAllItems(rewardTag, rewardCards, registries);
tag.put("RewardCards", rewardTag);
saveRewardTakeAnimations(tag, registries);
saveHudState(tag);
tag.putIntArray("OwnerSlots", ownerSlots);
tag.putIntArray("SlotAges", slotAges);
saveAnimation(tag);
saveTableAnimation(tag);
if (firstParticipantId != null) {
tag.putUUID("FirstParticipantId", firstParticipantId);
}
@@ -145,7 +619,14 @@ public class DuelTableBlockEntity extends BlockEntity {
if (tag != null) {
clearBoardContents();
ContainerHelper.loadAllItems(tag, boardCards, registries);
if (tag.contains("RewardCards", Tag.TAG_COMPOUND)) {
ContainerHelper.loadAllItems(tag.getCompound("RewardCards"), rewardCards, registries);
}
loadRewardTakeAnimations(tag, registries);
loadHudState(tag);
loadOwnerData(tag);
loadAnimation(tag);
loadTableAnimation(tag);
}
}
@@ -153,9 +634,22 @@ 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();
clearRewardCards();
firstParticipantId = null;
secondParticipantId = null;
hudActive = false;
hudP1HandCount = 0;
hudP2HandCount = 0;
hudP1Score = 0;
hudP2Score = 0;
hudRules = "";
hudGameState = "";
hudTurn = "";
hudInstruction = "";
}
private void loadOwnerData(CompoundTag tag) {
@@ -163,14 +657,265 @@ 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 saveRewardTakeAnimations(CompoundTag tag, HolderLookup.Provider registries) {
CompoundTag animationsTag = new CompoundTag();
for (int index = 0; index < rewardTakeAnimations.size(); index++) {
RewardTakeAnimation animation = rewardTakeAnimations.get(index);
CompoundTag animationTag = new CompoundTag();
animationTag.put("Stack", animation.stack.saveOptional(registries));
animationTag.putFloat("LocalX", animation.localX);
animationTag.putFloat("LocalZ", animation.localZ);
animationTag.putInt("Age", animation.age);
animationTag.putInt("Duration", animation.duration);
animationsTag.put("Animation" + index, animationTag);
}
animationsTag.putInt("Count", rewardTakeAnimations.size());
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)) {
return;
}
CompoundTag animationsTag = tag.getCompound("RewardTakeAnimations");
int count = animationsTag.getInt("Count");
for (int index = 0; index < count; index++) {
CompoundTag animationTag = animationsTag.getCompound("Animation" + index);
ItemStack stack = ItemStack.parseOptional(registries, animationTag.getCompound("Stack"));
if (stack.isEmpty()) {
continue;
}
rewardTakeAnimations.add(new RewardTakeAnimation(
stack,
animationTag.getFloat("LocalX"),
animationTag.getFloat("LocalZ"),
animationTag.getInt("Age"),
animationTag.getInt("Duration")));
}
}
private void saveAnimation(CompoundTag tag) {
tag.putString("AnimationType", animationType.name());
tag.putIntArray("AnimationSlots", animationSlots);
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"));
animationSlots = tag.getIntArray("AnimationSlots");
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();
animationSlots = nextStep.slots();
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;
animationSlots = new int[0];
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[] slots, int targetOwner) {
}
public static final class RewardTakeAnimation {
private final ItemStack stack;
private final float localX;
private final float localZ;
private int age;
private final int duration;
private RewardTakeAnimation(ItemStack stack, float localX, float localZ, int age, int duration) {
this.stack = stack;
this.localX = localX;
this.localZ = localZ;
this.age = age;
this.duration = duration;
}
public ItemStack stack() {
return stack;
}
public float localX() {
return localX;
}
public float localZ() {
return localZ;
}
public int age() {
return age;
}
public int duration() {
return duration;
}
}
private int visibleRewardIndex(int slot) {
int visibleIndex = 0;
for (int index = 0; index < rewardCards.size(); index++) {
if (rewardCards.get(index).isEmpty()) {
continue;
}
if (index == slot) {
return visibleIndex;
}
visibleIndex++;
}
return -1;
}
}

View File

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

View File

@@ -8,12 +8,21 @@ import com.trunksbomb.minetriad.game.BoardLocalSpace;
import com.trunksbomb.minetriad.world.DuelTableBlock;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.LevelRenderer;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.world.entity.Interaction;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.AABB;
public class DuelTableBlockEntityRenderer implements BlockEntityRenderer<DuelTableBlockEntity> {
private static final float TABLE_CARD_Y = 1.066F;
private static final float REWARD_CARD_Y = 1.75F;
private static final float IDLE_BOB_HEIGHT = 0.012F;
private static final float IDLE_BOB_SPEED = 0.08F;
public DuelTableBlockEntityRenderer(BlockEntityRendererProvider.Context context) {
}
@@ -27,15 +36,155 @@ public class DuelTableBlockEntityRenderer implements BlockEntityRenderer<DuelTab
BoardCell cell = new BoardCell(slot / BoardCell.SIZE, slot % BoardCell.SIZE);
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.isAnimatingSlot(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 {
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.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)));
poseStack.popPose();
}
renderRewardCards(blockEntity, partialTick, poseStack, buffer);
renderRewardTakeAnimations(blockEntity, partialTick, poseStack, buffer);
renderRewardProxyDebug(blockEntity, poseStack, buffer);
}
private static void renderRewardCards(DuelTableBlockEntity blockEntity, float partialTick, PoseStack poseStack, MultiBufferSource buffer) {
int rewardCount = blockEntity.rewardCardCount();
if (rewardCount <= 0) {
return;
}
int rewardIndex = 0;
for (int slot = 0; slot < DuelTableBlockEntity.REWARD_SLOT_COUNT; slot++) {
ItemStack rewardStack = blockEntity.getRewardCard(slot);
if (rewardStack.isEmpty()) {
continue;
}
BoardLocalSpace.SlotCenter center = BoardLocalSpace.rewardCenter(
rewardIndex,
blockEntity.getBlockState().getValue(DuelTableBlock.FACING),
rewardCount);
float manifestProgress = blockEntity.hasOverviewSweepAnimation()
? Math.max(0.2F, blockEntity.overviewSweepSlotProgress(Math.min(slot, DuelTableBlockEntity.SLOT_COUNT - 1), partialTick))
: 1.0F;
float y = 1.0F + (REWARD_CARD_Y - 1.0F) * manifestProgress + (float) Math.sin((Minecraft.getInstance().level == null ? 0.0F : (Minecraft.getInstance().level.getGameTime() + partialTick) * 0.08F) + rewardIndex * 0.7F) * 0.01F;
poseStack.pushPose();
poseStack.translate(center.x(), y, center.z());
poseStack.mulPose(Axis.YP.rotationDegrees(blockEntity.getBlockState().getValue(DuelTableBlock.FACING).toYRot()));
poseStack.scale(0.3F, 0.3F, 0.3F);
TriadCardItemRenderer.renderCard(rewardStack, poseStack, buffer, perspectivePalette(blockEntity, DuelTableBlockEntity.OWNER_SECOND));
poseStack.popPose();
rewardIndex++;
}
}
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 void renderRewardTakeAnimations(DuelTableBlockEntity blockEntity, float partialTick, PoseStack poseStack, MultiBufferSource buffer) {
var facing = blockEntity.getBlockState().getValue(DuelTableBlock.FACING);
float forwardX = facing.getStepX() * 0.33F;
float forwardZ = facing.getStepZ() * 0.33F;
for (DuelTableBlockEntity.RewardTakeAnimation animation : blockEntity.rewardTakeAnimations()) {
float progress = Math.clamp((animation.age() + partialTick) / animation.duration(), 0.0F, 1.0F);
float riseProgress = Math.min(1.0F, progress / 0.45F);
float arcProgress = progress <= 0.45F ? 0.0F : (progress - 0.45F) / 0.55F;
float x = animation.localX() + forwardX * arcProgress;
float y = REWARD_CARD_Y
+ riseProgress * 0.26F
+ (float) Math.sin(arcProgress * Math.PI) * 0.08F
- arcProgress * 0.26F;
float z = animation.localZ() + forwardZ * arcProgress;
float spin = progress * 720.0F;
poseStack.pushPose();
poseStack.translate(x, y, z);
poseStack.mulPose(Axis.YP.rotationDegrees(facing.toYRot()));
poseStack.mulPose(Axis.YP.rotationDegrees(spin));
poseStack.scale(0.3F, 0.3F, 0.3F);
TriadCardItemRenderer.renderCard(animation.stack(), poseStack, buffer, perspectivePalette(blockEntity, DuelTableBlockEntity.OWNER_SECOND));
poseStack.popPose();
}
}
private static void renderRewardProxyDebug(DuelTableBlockEntity blockEntity, PoseStack poseStack, MultiBufferSource buffer) {
if (Minecraft.getInstance().level == null) {
return;
}
AABB searchBox = new AABB(blockEntity.getBlockPos()).inflate(2.0D, 2.0D, 2.0D);
for (Interaction interaction : Minecraft.getInstance().level.getEntitiesOfClass(
Interaction.class,
searchBox,
entity -> DuelTableBlockEntity.isRewardProxy(entity)
&& blockEntity.getBlockPos().equals(DuelTableBlockEntity.rewardProxyPos(entity)))) {
AABB box = interaction.getBoundingBox().move(
-blockEntity.getBlockPos().getX(),
-blockEntity.getBlockPos().getY(),
-blockEntity.getBlockPos().getZ());
LevelRenderer.addChainedFilledBoxVertices(
poseStack,
buffer.getBuffer(RenderType.debugFilledBox()),
(float) box.minX,
(float) box.minY,
(float) box.minZ,
(float) box.maxX,
(float) box.maxY,
(float) box.maxZ,
0.2F,
0.95F,
0.35F,
0.28F);
}
}
private static TriadCardItemRenderer.CardPalette perspectivePalette(DuelTableBlockEntity blockEntity, int owner) {

View File

@@ -27,12 +27,15 @@ import org.joml.Matrix4f;
public final class TriadCardItemRenderer extends BlockEntityWithoutLevelRenderer {
private static final float CARD_MIN = -0.42F;
private static final float CARD_MAX = 0.42F;
private static final float FRONT_Z = 0.024F;
private static final float BACK_Z = -0.024F;
private static final float BORDER_Z = 0.026F;
private static final float VALUE_Z = 0.030F;
private static final float CARD_DEPTH = 0.024F;
private static final float FRONT_Z = CARD_DEPTH;
private static final float BACK_Z = -CARD_DEPTH;
private static final float ART_Z = FRONT_Z + 0.004F;
private static final float BORDER_Z = FRONT_Z + 0.008F;
private static final float VALUE_Z = FRONT_Z + 0.012F;
private static final float INNER_MIN = -0.37F;
private static final float INNER_MAX = 0.37F;
private static final float[] EDGE_COLOR = new float[] {0.18F, 0.14F, 0.10F};
private static TriadCardItemRenderer INSTANCE;
private static final CardPalette NEUTRAL_PALETTE = new CardPalette(
new float[] {0.97F, 0.93F, 0.80F},
@@ -77,6 +80,7 @@ public final class TriadCardItemRenderer extends BlockEntityWithoutLevelRenderer
CardDefinition card = cardData == null || cardData.equals(CardStackData.EMPTY) ? null : CardRegistry.get(cardData.cardId());
poseStack.pushPose();
drawCardBody(poseStack, buffer);
drawBack(poseStack, buffer);
if (card != null) {
drawFrontArt(poseStack, buffer, artTexture(card));
@@ -94,6 +98,25 @@ public final class TriadCardItemRenderer extends BlockEntityWithoutLevelRenderer
poseStack.popPose();
}
private static void drawCardBody(PoseStack poseStack, MultiBufferSource buffer) {
float r = EDGE_COLOR[0];
float g = EDGE_COLOR[1];
float b = EDGE_COLOR[2];
LevelRenderer.addChainedFilledBoxVertices(
poseStack,
buffer.getBuffer(RenderType.debugFilledBox()),
CARD_MIN,
CARD_MIN,
BACK_Z,
CARD_MAX,
CARD_MAX,
FRONT_Z,
r,
g,
b,
1.0F);
}
private static String displayRank(int rank) {
return rank == 10 ? "A" : Integer.toString(rank);
}
@@ -161,7 +184,7 @@ public final class TriadCardItemRenderer extends BlockEntityWithoutLevelRenderer
}
private static void drawFrontArt(PoseStack poseStack, MultiBufferSource buffer, ResourceLocation textureLocation) {
drawTexturedQuad(poseStack, buffer, textureLocation, FRONT_Z, false);
drawTexturedQuad(poseStack, buffer, textureLocation, ART_Z, false);
}
private static void drawTexturedQuad(PoseStack poseStack, MultiBufferSource buffer, ResourceLocation textureLocation, float z, boolean reverseWinding) {

View File

@@ -36,6 +36,44 @@ public final class BoardLocalSpace {
};
}
public static SlotCenter rewardCenter(int index, Direction boardFacing, int count) {
if (count <= 0) {
return new SlotCenter(0.5F, 0.22F);
}
float columnProgress = count == 1 ? 0.5F : -0.04F + (1.08F * index / (count - 1.0F));
float rowProgress = 0.15F;
HorizontalVector bottomVector = vectorFor(boardFacing);
HorizontalVector rightVector = vectorFor(rightDirection(boardFacing));
float x = 0.5F + bottomVector.x() * (rowProgress - 0.5F) + rightVector.x() * (columnProgress - 0.5F);
float z = 0.5F + bottomVector.z() * (rowProgress - 0.5F) + rightVector.z() * (columnProgress - 0.5F);
return new SlotCenter(x, z);
}
public static int rewardIndexForHit(BlockHitResult hitResult, Direction boardFacing, int count) {
double localX = hitResult.getLocation().x - hitResult.getBlockPos().getX();
double localY = hitResult.getLocation().y - hitResult.getBlockPos().getY();
double localZ = hitResult.getLocation().z - hitResult.getBlockPos().getZ();
if (localY < 1.20D || localY > 2.02D) {
return -1;
}
int nearestIndex = -1;
double nearestDistance = Double.MAX_VALUE;
for (int index = 0; index < count; index++) {
SlotCenter center = rewardCenter(index, boardFacing, count);
double dx = localX - center.x();
double dz = localZ - center.z();
if (Math.abs(dx) <= 0.12D && Math.abs(dz) <= 0.18D) {
return index;
}
double distanceSq = dx * dx + dz * dz;
if (distanceSq < nearestDistance) {
nearestDistance = distanceSq;
nearestIndex = index;
}
}
return nearestDistance <= 0.05D * 0.05D ? nearestIndex : -1;
}
private static double projectProgress(double localX, double localZ, Direction direction) {
HorizontalVector vector = vectorFor(direction);
double offsetX = localX - 0.5D;

View File

@@ -18,6 +18,9 @@ import net.minecraft.world.phys.BlockHitResult;
public final class DuelSession {
private enum Phase {
PLAYING,
PLAYER_ANIMATION,
OPPONENT_ANIMATION,
CLOSING_ANIMATION,
OVERVIEW
}
@@ -25,7 +28,9 @@ public final class DuelSession {
private final MatchParticipant opponentParticipant;
private final TriadMatch match;
private final List<ResourceLocation> refundablePlayerCards;
private final List<ResourceLocation> opponentStartingCards;
private final boolean refundCardsOnEnd;
private final boolean aiVsAi;
private final BlockPos tablePos;
private Phase phase;
@@ -36,12 +41,17 @@ public final class DuelSession {
List<GameCard> opponentHand,
List<ResourceLocation> 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.opponentStartingCards = opponentHand.stream()
.map(card -> card.definition().id())
.toList();
this.refundCardsOnEnd = refundCardsOnEnd;
this.aiVsAi = aiVsAi;
this.tablePos = tablePos.immutable();
this.phase = Phase.PLAYING;
}
@@ -62,6 +72,114 @@ 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 int playerHandCount() {
return match.handFor(playerParticipant).size();
}
public int opponentHandCount() {
return match.handFor(opponentParticipant).size();
}
public int playerScore() {
return match.scoreFor(playerParticipant);
}
public int opponentScore() {
return match.scoreFor(opponentParticipant);
}
public String rulesSummary() {
List<String> rules = new ArrayList<>();
if (match.ruleSet().sameRule()) {
rules.add("Same");
}
if (match.ruleSet().sameWallRule()) {
rules.add("Same Wall");
}
if (match.ruleSet().plusRule()) {
rules.add("Plus");
}
if (match.ruleSet().openHands()) {
rules.add("Open");
}
if (rules.isEmpty()) {
return "None";
}
return String.join(", ", rules);
}
public String turnIndicator() {
return isPlayerTurn() ? "P1" : "P2";
}
public String gameStateLabel() {
return switch (phase) {
case PLAYING -> "Playing";
case PLAYER_ANIMATION, OPPONENT_ANIMATION -> "Animating";
case CLOSING_ANIMATION -> "Closing";
case OVERVIEW -> "Overview";
};
}
public String nextStepInstruction() {
if (isComplete()) {
int playerScore = playerScore();
int opponentScore = opponentScore();
String winner = playerScore == opponentScore ? "Draw" : playerScore > opponentScore ? "P1" : "P2";
if (isInOverview()) {
return opponentRewardCards().isEmpty()
? "Ready for next game"
: winner + " choosing 1 cards to keep";
}
return "Game over - " + winner + (winner.equals("Draw") ? "" : " wins");
}
return switch (phase) {
case PLAYER_ANIMATION, OPPONENT_ANIMATION -> "Resolving animations";
case CLOSING_ANIMATION -> "Clearing board";
case OVERVIEW -> "Choose a reward card or finish";
case PLAYING -> isPlayerTurn()
? "Waiting for you to place a card"
: "Waiting for P2 to place a card";
};
}
public void enterOverview() {
phase = Phase.OVERVIEW;
}
@@ -96,15 +214,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<GameCard> opponentHand = match.handFor(opponentParticipant);
for (int handIndex = 0; handIndex < opponentHand.size(); handIndex++) {
List<GameCard> 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 +247,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() {
@@ -160,7 +286,7 @@ public final class DuelSession {
}
public Component overviewMessage() {
return Component.literal("Duel overview. Right-click the table to finish and return cards.");
return Component.literal("Duel overview. Right-click a floating red card to take it, or right-click the table to finish.");
}
public List<ResourceLocation> refundablePlayerCards() {
@@ -195,6 +321,10 @@ public final class DuelSession {
return refundCardsOnEnd;
}
public List<ResourceLocation> opponentRewardCards() {
return List.copyOf(opponentStartingCards);
}
public boolean isAtTable(BlockPos blockPos) {
return tablePos.equals(blockPos);
}

View File

@@ -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<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");
}
if (aiVsAi) {
playerHand = buildRandomHand();
}
List<ResourceLocation> 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<GameCard> buildRandomHand() {
List<CardDefinition> cards = new ArrayList<>(CardRegistry.all());
Collections.shuffle(cards);
return cards.stream()
.limit(5)
.map(GameCard::new)
.toList();
}
}

View File

@@ -7,21 +7,29 @@ import net.minecraft.resources.ResourceLocation;
public record MoveResult(
boolean valid,
String errorMessage,
List<List<BoardCell>> captureWaves,
List<BoardCell> capturedCells,
List<String> battleLog,
String playedCardName,
ResourceLocation playedCardId,
BoardCell playedCell) {
public static MoveResult success(
List<BoardCell> capturedCells,
List<List<BoardCell>> captureWaves,
List<String> battleLog,
String playedCardName,
ResourceLocation playedCardId,
BoardCell playedCell) {
return new MoveResult(true, "", List.copyOf(capturedCells), List.copyOf(battleLog), playedCardName, playedCardId, playedCell);
List<List<BoardCell>> immutableWaves = captureWaves.stream()
.map(List::copyOf)
.toList();
List<BoardCell> flattenedCaptures = immutableWaves.stream()
.flatMap(List::stream)
.distinct()
.toList();
return new MoveResult(true, "", immutableWaves, flattenedCaptures, List.copyOf(battleLog), playedCardName, playedCardId, playedCell);
}
public static MoveResult failure(String errorMessage) {
return new MoveResult(false, errorMessage, List.of(), List.of(), "", null, null);
return new MoveResult(false, errorMessage, List.of(), List.of(), List.of(), "", null, null);
}
}

View File

@@ -2,7 +2,11 @@ package com.trunksbomb.minetriad.game;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public final class TriadMatch {
private final MatchParticipant firstParticipant;
@@ -75,14 +79,111 @@ public final class TriadMatch {
board[move.targetCell().index()] = new PlacedCard(move.participant(), playedCard);
List<String> battleLog = new ArrayList<>();
List<BoardCell> capturedCells = resolveCaptures(move.targetCell(), move.participant(), playedCard, battleLog);
CaptureResolution captureResolution = resolveCaptures(move.targetCell(), move.participant(), playedCard, battleLog);
activeParticipant = otherParticipant(move.participant());
return MoveResult.success(capturedCells, battleLog, playedCard.definition().name().getString(), playedCard.definition().id(), move.targetCell());
return MoveResult.success(captureResolution.captureWaves(), battleLog, playedCard.definition().name().getString(), playedCard.definition().id(), move.targetCell());
}
private List<BoardCell> resolveCaptures(BoardCell origin, MatchParticipant owner, GameCard playedCard, List<String> battleLog) {
List<BoardCell> captured = new ArrayList<>();
private CaptureResolution resolveCaptures(BoardCell origin, MatchParticipant owner, GameCard playedCard, List<String> battleLog) {
List<SideComparison> comparisons = buildComparisons(origin, owner, playedCard, battleLog);
LinkedHashSet<BoardCell> firstWave = new LinkedHashSet<>();
LinkedHashSet<BoardCell> comboSeeds = new LinkedHashSet<>();
for (SideComparison comparison : comparisons) {
if (!comparison.neighbor().owner().equals(owner) && comparison.attackValue() > comparison.defendValue()) {
firstWave.add(comparison.cell());
}
}
if (ruleSet.sameRule()) {
List<SameCandidate> sameCandidates = buildSameCandidates(origin, playedCard, battleLog);
long sameMatches = sameCandidates.stream().filter(SameCandidate::matches).count();
if (sameMatches >= 2) {
List<BoardCell> sameCaptures = sameCandidates.stream()
.filter(SameCandidate::matches)
.map(SameCandidate::cell)
.filter(cell -> cell != null)
.distinct()
.filter(cell -> {
PlacedCard placedCard = cardAt(cell);
return placedCard != null && !placedCard.owner().equals(owner);
})
.toList();
if (!sameCaptures.isEmpty()) {
battleLog.add("Same triggers.");
firstWave.addAll(sameCaptures);
comboSeeds.addAll(sameCaptures);
}
}
}
if (ruleSet.plusRule()) {
Map<Integer, List<SideComparison>> plusGroups = new LinkedHashMap<>();
for (SideComparison comparison : comparisons) {
int sum = comparison.attackValue() + comparison.defendValue();
plusGroups.computeIfAbsent(sum, ignored -> new ArrayList<>()).add(comparison);
}
for (Map.Entry<Integer, List<SideComparison>> entry : plusGroups.entrySet()) {
if (entry.getValue().size() < 2) {
continue;
}
List<BoardCell> plusCaptures = entry.getValue().stream()
.map(SideComparison::cell)
.distinct()
.filter(cell -> {
PlacedCard placedCard = cardAt(cell);
return placedCard != null && !placedCard.owner().equals(owner);
})
.toList();
if (!plusCaptures.isEmpty()) {
battleLog.add("Plus triggers on sum " + entry.getKey() + ".");
firstWave.addAll(plusCaptures);
comboSeeds.addAll(plusCaptures);
}
}
}
List<List<BoardCell>> waves = new ArrayList<>();
if (!firstWave.isEmpty()) {
applyOwnership(owner, firstWave);
waves.add(List.copyOf(firstWave));
}
if (!comboSeeds.isEmpty()) {
LinkedHashSet<BoardCell> comboWave = new LinkedHashSet<>();
for (BoardCell comboCell : comboSeeds) {
PlacedCard comboCard = cardAt(comboCell);
if (comboCard == null || !comboCard.owner().equals(owner)) {
continue;
}
for (CardSide side : CardSide.values()) {
BoardCell neighborCell = neighbor(comboCell, side);
if (neighborCell == null) {
continue;
}
PlacedCard neighbor = cardAt(neighborCell);
if (neighbor == null || neighbor.owner().equals(owner)) {
continue;
}
int attackValue = comboCard.card().value(side);
int defendValue = neighbor.card().value(side.opposite());
if (attackValue > defendValue) {
comboWave.add(neighborCell);
}
}
}
if (!comboWave.isEmpty()) {
battleLog.add("Combo triggers.");
applyOwnership(owner, comboWave);
waves.add(List.copyOf(comboWave));
}
}
return new CaptureResolution(waves);
}
private List<SideComparison> buildComparisons(BoardCell origin, MatchParticipant owner, GameCard playedCard, List<String> battleLog) {
List<SideComparison> comparisons = new ArrayList<>();
for (CardSide side : CardSide.values()) {
BoardCell neighborCell = neighbor(origin, side);
if (neighborCell == null) {
@@ -90,13 +191,12 @@ public final class TriadMatch {
}
PlacedCard neighbor = cardAt(neighborCell);
if (neighbor == null || neighbor.owner().equals(owner)) {
if (neighbor == null) {
continue;
}
int attackValue = playedCard.value(side);
int defendValue = neighbor.card().value(side.opposite());
boolean capturedNeighbor = attackValue > defendValue;
battleLog.add(String.format(
"%s %s=%d vs %s %s=%d at [%d,%d] -> %s",
playedCard.definition().name().getString(),
@@ -107,14 +207,47 @@ public final class TriadMatch {
defendValue,
neighborCell.row(),
neighborCell.column(),
capturedNeighbor ? "flip" : "hold"));
if (capturedNeighbor) {
board[neighborCell.index()] = new PlacedCard(owner, neighbor.card());
captured.add(neighborCell);
!neighbor.owner().equals(owner) && attackValue > defendValue ? "flip" : "hold"));
comparisons.add(new SideComparison(side, neighborCell, neighbor, attackValue, defendValue));
}
return comparisons;
}
return captured;
private List<SameCandidate> buildSameCandidates(BoardCell origin, GameCard playedCard, List<String> battleLog) {
List<SameCandidate> candidates = new ArrayList<>();
for (CardSide side : CardSide.values()) {
BoardCell neighborCell = neighbor(origin, side);
if (neighborCell == null) {
boolean wallMatch = ruleSet.sameWallRule() && playedCard.value(side) == 10;
battleLog.add(String.format(
"%s %s=%d vs WALL=%d -> %s",
playedCard.definition().name().getString(),
side.name(),
playedCard.value(side),
10,
wallMatch ? "same" : "ignore"));
candidates.add(new SameCandidate(side, null, wallMatch));
continue;
}
PlacedCard neighbor = cardAt(neighborCell);
if (neighbor == null) {
continue;
}
boolean sameMatch = playedCard.value(side) == neighbor.card().value(side.opposite());
candidates.add(new SameCandidate(side, neighborCell, sameMatch));
}
return candidates;
}
private void applyOwnership(MatchParticipant owner, Set<BoardCell> capturedCells) {
for (BoardCell cell : capturedCells) {
PlacedCard placedCard = cardAt(cell);
if (placedCard != null) {
board[cell.index()] = new PlacedCard(owner, placedCard.card());
}
}
}
private BoardCell neighbor(BoardCell cell, CardSide side) {
@@ -167,4 +300,13 @@ public final class TriadMatch {
void forceActiveParticipant(MatchParticipant participant) {
this.activeParticipant = participant;
}
private record SideComparison(CardSide side, BoardCell cell, PlacedCard neighbor, int attackValue, int defendValue) {
}
private record SameCandidate(CardSide side, BoardCell cell, boolean matches) {
}
private record CaptureResolution(List<List<BoardCell>> captureWaves) {
}
}

View File

@@ -3,7 +3,8 @@ package com.trunksbomb.minetriad.game;
public record TriadRuleSet(
boolean openHands,
boolean sameRule,
boolean sameWallRule,
boolean plusRule,
boolean elementalRule) {
public static final TriadRuleSet CLASSIC_OPEN = new TriadRuleSet(true, false, false, false);
public static final TriadRuleSet CLASSIC_OPEN = new TriadRuleSet(true, true, true, true, false);
}

View File

@@ -2,8 +2,10 @@ 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.BoardLocalSpace;
import com.trunksbomb.minetriad.game.DuelSession;
import com.trunksbomb.minetriad.game.DuelSessionManager;
import com.trunksbomb.minetriad.game.MoveResult;
@@ -19,18 +21,28 @@ import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.ItemInteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.entity.Interaction;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.block.BaseEntityBlock;
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;
import net.minecraft.world.level.block.state.properties.DirectionProperty;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.Shapes;
import net.minecraft.world.phys.shapes.VoxelShape;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent;
public class DuelTableBlock extends BaseEntityBlock {
public static final MapCodec<DuelTableBlock> CODEC = simpleCodec(DuelTableBlock::new);
@@ -53,57 +65,60 @@ public class DuelTableBlock extends BaseEntityBlock {
}
try {
if (hitResult.getDirection() != Direction.UP) {
player.displayClientMessage(Component.literal("Click the top face of the Duel Table to target a board space.").withStyle(ChatFormatting.YELLOW), false);
return ItemInteractionResult.CONSUME;
}
Direction tableFacing = state.getValue(FACING);
DuelTableBlockEntity table = getTableEntity(level, pos);
if (table == null) {
player.displayClientMessage(Component.literal("Duel table storage is unavailable.").withStyle(ChatFormatting.RED), false);
return ItemInteractionResult.CONSUME;
}
Direction tableFacing = state.getValue(FACING);
DuelSession session = DuelSessionManager.get(player);
if (session != null && session.isInOverview()) {
if (tryTakeRewardCard(level, pos, player, hitResult, tableFacing)) {
return ItemInteractionResult.CONSUME;
}
if (hitResult.getDirection() != Direction.UP) {
player.displayClientMessage(Component.literal("Reward debug: no reward hit; clicked face " + hitResult.getDirection().getName()).withStyle(ChatFormatting.DARK_GRAY), false);
return ItemInteractionResult.CONSUME;
}
player.displayClientMessage(Component.literal("Reward debug: no reward hit; falling back to board close.").withStyle(ChatFormatting.DARK_GRAY), false);
completeOverview(level, pos, player, session);
return ItemInteractionResult.CONSUME;
}
if (hitResult.getDirection() != Direction.UP) {
player.displayClientMessage(Component.literal("Click the top face of the Duel Table to target a board space.").withStyle(ChatFormatting.YELLOW), false);
return ItemInteractionResult.CONSUME;
}
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());
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) {
DuelTableAnimationResolver.startAiTurn(table, session, serverPlayer);
}
} else {
player.displayClientMessage(session.startMessage().copy().withStyle(ChatFormatting.GOLD), false);
player.displayClientMessage(session.handSummary(), false);
}
return ItemInteractionResult.CONSUME;
}
if (session.isInOverview()) {
completeOverview(level, pos, player, session);
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()) {
return ItemInteractionResult.CONSUME;
}
if (!stack.is(TriadItems.TRIAD_CARD.get())) {
player.displayClientMessage(Component.literal("Hold a Triad Card from your duel hand to play your turn.").withStyle(ChatFormatting.YELLOW), false);
player.displayClientMessage(session.handSummary(), false);
return ItemInteractionResult.CONSUME;
}
@@ -116,8 +131,6 @@ public class DuelTableBlock extends BaseEntityBlock {
MoveResult playerMove = session.playPlayerCard(cardData.cardId(), hitResult, player.getAbilities().instabuild, tableFacing);
if (!playerMove.valid()) {
player.displayClientMessage(Component.literal("Move rejected: " + playerMove.errorMessage()).withStyle(ChatFormatting.RED), false);
player.displayClientMessage(session.boardSummary().copy().withStyle(ChatFormatting.DARK_GRAY), false);
player.displayClientMessage(session.handSummary(), false);
return ItemInteractionResult.CONSUME;
}
@@ -126,36 +139,14 @@ 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);
}
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);
}
session.beginPlayerAnimation();
table.updateHudState(session);
table.startMoveAnimation(playerMove, DuelTableBlockEntity.OWNER_FIRST);
return ItemInteractionResult.CONSUME;
} catch (Exception exception) {
MineTriad.LOGGER.error("Duel table interaction failed", exception);
@@ -203,11 +194,64 @@ public class DuelTableBlock extends BaseEntityBlock {
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
protected RenderShape getRenderShape(BlockState state) {
return RenderShape.MODEL;
}
@Override
protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) {
return overviewRewardShape(state, level, pos, super.getShape(state, level, pos, context));
}
@Override
protected VoxelShape getInteractionShape(BlockState state, BlockGetter level, BlockPos pos) {
return overviewRewardShape(state, level, pos, super.getInteractionShape(state, level, pos));
}
private VoxelShape overviewRewardShape(BlockState state, BlockGetter level, BlockPos pos, VoxelShape baseShape) {
VoxelShape shape = baseShape;
BlockEntity blockEntity = level.getBlockEntity(pos);
if (!(blockEntity instanceof DuelTableBlockEntity table) || table.rewardCardCount() <= 0) {
return shape;
}
Direction tableFacing = state.getValue(FACING);
int rewardCount = table.rewardCardCount();
VoxelShape rewardShape = Shapes.empty();
for (int index = 0; index < rewardCount; index++) {
BoardLocalSpace.SlotCenter center = BoardLocalSpace.rewardCenter(index, tableFacing, rewardCount);
double minX;
double maxX;
double minZ;
double maxZ;
if (tableFacing.getAxis() == Direction.Axis.Z) {
minX = center.x() - 0.11D;
maxX = center.x() + 0.11D;
minZ = center.z() - 0.05D;
maxZ = center.z() + 0.05D;
} else {
minX = center.x() - 0.05D;
maxX = center.x() + 0.05D;
minZ = center.z() - 0.11D;
maxZ = center.z() + 0.11D;
}
rewardShape = Shapes.or(rewardShape, Block.box(
minX * 16.0D,
20.0D,
minZ * 16.0D,
maxX * 16.0D,
31.5D,
maxZ * 16.0D));
}
return Shapes.or(shape, rewardShape);
}
@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(FACING);
@@ -225,33 +269,11 @@ 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);
}
}
private static DuelTableBlockEntity getTableEntity(Level level, BlockPos pos) {
BlockEntity blockEntity = level.getBlockEntity(pos);
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 +285,98 @@ 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();
}
DuelSessionManager.end(player, true);
player.displayClientMessage(Component.literal("Duel finished. All played cards have been returned.").withStyle(ChatFormatting.GREEN), false);
session.beginClosingAnimation();
table.updateHudState(session);
table.startClearWaveAnimation();
}
}
private static boolean tryTakeRewardCard(Level level, BlockPos pos, Player player, BlockHitResult hitResult, Direction tableFacing) {
DuelTableBlockEntity table = getTableEntity(level, pos);
if (table == null || table.rewardCardCount() <= 0) {
player.displayClientMessage(Component.literal("Reward debug: no reward cards available.").withStyle(ChatFormatting.DARK_GRAY), false);
return false;
}
double localX = hitResult.getLocation().x - hitResult.getBlockPos().getX();
double localY = hitResult.getLocation().y - hitResult.getBlockPos().getY();
double localZ = hitResult.getLocation().z - hitResult.getBlockPos().getZ();
int rewardIndex = com.trunksbomb.minetriad.game.BoardLocalSpace.rewardIndexForHit(hitResult, tableFacing, table.rewardCardCount());
if (rewardIndex < 0) {
player.displayClientMessage(Component.literal(String.format(
"Reward debug: miss at local %.3f, %.3f, %.3f with %d cards",
localX,
localY,
localZ,
table.rewardCardCount())).withStyle(ChatFormatting.DARK_GRAY), false);
return false;
}
int actualSlot = actualRewardSlot(table, rewardIndex);
if (actualSlot < 0) {
player.displayClientMessage(Component.literal("Reward debug: resolved visual index " + rewardIndex + " but found no matching slot.").withStyle(ChatFormatting.DARK_GRAY), false);
return false;
}
ItemStack reward = table.takeRewardCard(actualSlot);
if (reward.isEmpty()) {
player.displayClientMessage(Component.literal("Reward debug: slot " + actualSlot + " was empty when taking.").withStyle(ChatFormatting.DARK_GRAY), false);
return false;
}
player.displayClientMessage(Component.literal("Reward debug: took visual index " + rewardIndex + " from slot " + actualSlot + ".").withStyle(ChatFormatting.DARK_GRAY), false);
return true;
}
private static int actualRewardSlot(DuelTableBlockEntity table, int rewardIndex) {
int visibleIndex = 0;
for (int slot = 0; slot < DuelTableBlockEntity.REWARD_SLOT_COUNT; slot++) {
if (table.getRewardCard(slot).isEmpty()) {
continue;
}
if (visibleIndex == rewardIndex) {
return slot;
}
visibleIndex++;
}
return -1;
}
@EventBusSubscriber(modid = MineTriad.MOD_ID)
public static final class RewardInteractionEvents {
@SubscribeEvent
public static void onEntityInteract(PlayerInteractEvent.EntityInteractSpecific event) {
if (!(event.getTarget() instanceof Interaction interaction) || !DuelTableBlockEntity.isRewardProxy(interaction)) {
return;
}
BlockPos tablePos = DuelTableBlockEntity.rewardProxyPos(interaction);
if (tablePos == null) {
return;
}
Level level = event.getLevel();
DuelTableBlockEntity table = getTableEntity(level, tablePos);
DuelSession session = DuelSessionManager.getAt(tablePos);
if (table == null || session == null || !session.isInOverview()) {
interaction.discard();
return;
}
int slot = DuelTableBlockEntity.rewardProxySlot(interaction);
if (slot < 0) {
return;
}
ItemStack reward = table.takeRewardCard(slot);
if (reward.isEmpty()) {
interaction.discard();
return;
}
event.getEntity().displayClientMessage(Component.literal("Reward debug: entity take from slot " + slot + ".").withStyle(ChatFormatting.DARK_GRAY), false);
event.setCanceled(true);
event.setCancellationResult(InteractionResult.SUCCESS);
}
}
}