diff --git a/gradle.properties b/gradle.properties index 08e96a11b..da3dafe9f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,11 +8,11 @@ org.gradle.jvmargs=-Xmx2G minecraft_version_list=1.21.11 minecraft_version_list_presentable=1.21.11 loader_version=0.18.1 - loom_version=1.14-SNAPSHOT + loom_version=1.15-SNAPSHOT # check these on https://maven.parchmentmc.org/org/parchmentmc/data/ - parchment_mcversion=1.21.10 - parchment_version=2025.10.12 + parchment_mcversion=1.21.11 + parchment_version=2025.12.20 # Mod Properties mod_version=2.13.1 diff --git a/src/main/java/net/earthcomputer/clientcommands/ClientCommands.java b/src/main/java/net/earthcomputer/clientcommands/ClientCommands.java index d18ebb81b..e5e23955a 100644 --- a/src/main/java/net/earthcomputer/clientcommands/ClientCommands.java +++ b/src/main/java/net/earthcomputer/clientcommands/ClientCommands.java @@ -136,6 +136,7 @@ public static void registerCommands(CommandDispatcher CalcCommand.register(dispatcher); CalcStackCommand.register(dispatcher, context); CallbackCommand.register(dispatcher); + ChessCommand.register(dispatcher); CDebugCommand.register(dispatcher); CEnchantCommand.register(dispatcher, context); CFunctionCommand.register(dispatcher); diff --git a/src/main/java/net/earthcomputer/clientcommands/c2c/C2CPacketHandler.java b/src/main/java/net/earthcomputer/clientcommands/c2c/C2CPacketHandler.java index 4543ddade..60fdd679d 100644 --- a/src/main/java/net/earthcomputer/clientcommands/c2c/C2CPacketHandler.java +++ b/src/main/java/net/earthcomputer/clientcommands/c2c/C2CPacketHandler.java @@ -7,15 +7,22 @@ import com.mojang.logging.LogUtils; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; +import net.earthcomputer.clientcommands.c2c.chess.ChessGame; +import net.earthcomputer.clientcommands.c2c.packets.ChessDrawOfferC2CPacket; +import net.earthcomputer.clientcommands.c2c.packets.ChessMoveC2CPacket; +import net.earthcomputer.clientcommands.c2c.packets.ChessResignC2CPacket; import net.earthcomputer.clientcommands.c2c.packets.MessageC2CPacket; import net.earthcomputer.clientcommands.c2c.packets.PutConnectFourPieceC2CPacket; import net.earthcomputer.clientcommands.c2c.packets.PutTicTacToeMarkC2CPacket; import net.earthcomputer.clientcommands.c2c.packets.StartTwoPlayerGameC2CPacket; +import net.earthcomputer.clientcommands.c2c.packets.StopTwoPlayerGameC2CPacket; +import net.earthcomputer.clientcommands.command.ClientCommandHelper; import net.earthcomputer.clientcommands.command.ConnectFourCommand; import net.earthcomputer.clientcommands.command.ListenCommand; import net.earthcomputer.clientcommands.command.TicTacToeCommand; import net.earthcomputer.clientcommands.command.arguments.ExtendedMarkdownArgument; import net.earthcomputer.clientcommands.features.TwoPlayerGame; +import net.earthcomputer.clientcommands.util.CComponentUtil; import net.fabricmc.fabric.api.networking.v1.PacketByteBufs; import net.minecraft.ChatFormatting; import net.minecraft.SharedConstants; @@ -28,6 +35,7 @@ import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.ProtocolInfo; import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.ComponentUtils; import net.minecraft.network.chat.MutableComponent; import net.minecraft.network.chat.RemoteChatSession; import net.minecraft.network.protocol.Packet; @@ -52,8 +60,12 @@ public class C2CPacketHandler implements C2CPacketListener { public static final ProtocolInfo C2C = ProtocolInfoBuilder.clientboundProtocol(ConnectionProtocol.PLAY, builder -> builder .addPacket(MessageC2CPacket.ID, MessageC2CPacket.CODEC) .addPacket(StartTwoPlayerGameC2CPacket.ID, StartTwoPlayerGameC2CPacket.CODEC) + .addPacket(StopTwoPlayerGameC2CPacket.ID, StopTwoPlayerGameC2CPacket.CODEC) .addPacket(PutTicTacToeMarkC2CPacket.ID, PutTicTacToeMarkC2CPacket.CODEC) .addPacket(PutConnectFourPieceC2CPacket.ID, PutConnectFourPieceC2CPacket.CODEC) + .addPacket(ChessResignC2CPacket.ID, ChessResignC2CPacket.CODEC) + .addPacket(ChessMoveC2CPacket.ID, ChessMoveC2CPacket.CODEC) + .addPacket(ChessDrawOfferC2CPacket.ID, ChessDrawOfferC2CPacket.CODEC) ).bind(b -> (C2CFriendlyByteBuf) b); public static final String C2C_PACKET_HEADER = "CCΕNC:"; @@ -211,6 +223,11 @@ public void onStartTwoPlayerGameC2CPacket(StartTwoPlayerGameC2CPacket packet) { TwoPlayerGame.onStartTwoPlayerGame(packet); } + @Override + public void onStopTwoPlayerGameC2CPacket(StopTwoPlayerGameC2CPacket packet) { + TwoPlayerGame.onStopTwoPlayerGame(packet); + } + @Override public void onPutTicTacToeMarkC2CPacket(PutTicTacToeMarkC2CPacket packet) { TicTacToeCommand.onPutTicTacToeMarkC2CPacket(packet); @@ -221,6 +238,72 @@ public void onPutConnectFourPieceC2CPacket(PutConnectFourPieceC2CPacket packet) ConnectFourCommand.onPutConnectFourPieceC2CPacket(packet); } + @Override + public void onChessResignPacket(ChessResignC2CPacket packet) { + ChessGame activeGame = TwoPlayerGame.CHESS_TYPE.getActiveGame(packet.senderUUID()); + if (activeGame == null) { + return; + } + String opponent = activeGame.opponent.getProfile().name(); + TwoPlayerGame.CHESS_TYPE.removeActiveGame(packet.senderUUID()); + ClientCommandHelper.sendFeedback(Component.translatable("chessGame.opponentResigned", opponent)); + } + + @Override + public void onChessMovePacket(ChessMoveC2CPacket packet) { + ChessGame activeGame = TwoPlayerGame.CHESS_TYPE.getActiveGame(packet.senderUUID()); + if (activeGame == null) { + return; + } + + // validate move + if (activeGame.colorToMove == activeGame.yourColor) { + return; + } + if (!activeGame.legalMoves().contains(packet.move())) { + return; + } + + String moveNotation = activeGame.getAlgebraicNotation(packet.move()); + activeGame.makeMove(packet.move()); + + Component clickable = CComponentUtil.getCommandTextComponent("twoPlayerGame.clickToMakeYourMove", "/cchess open " + packet.sender()); + ClientCommandHelper.sendFeedback(Component.translatable("chessGame.opponentMoved", activeGame.opponent.getProfile().name(), moveNotation, ComponentUtils.wrapInSquareBrackets(clickable))); + + Component endCondition = activeGame.detectEndCondition(); + if (endCondition != null) { + TwoPlayerGame.CHESS_TYPE.removeActiveGame(packet.senderUUID()); + ClientCommandHelper.sendFeedback(endCondition); + } + } + + @Override + public void onChessDrawOfferPacket(ChessDrawOfferC2CPacket packet) { + ChessGame activeGame = TwoPlayerGame.CHESS_TYPE.getActiveGame(packet.senderUUID()); + if (activeGame == null) { + return; + } + + String opponent = activeGame.opponent.getProfile().name(); + + // TODO: there are possibilities of desyncs here, not really sure how to fix it tbh + switch (packet.operation()) { + case OFFER -> { + activeGame.drawOfferedBy = activeGame.yourColor.opposite(); + ClientCommandHelper.sendFeedback(Component.translatable("chessGame.drawOffered", opponent)); + } + case RETRACT -> { + activeGame.drawOfferedBy = null; + ClientCommandHelper.sendFeedback(Component.translatable("chessGame.drawOfferRetracted", opponent)); + } + case ACCEPT -> { + TwoPlayerGame.CHESS_TYPE.removeActiveGame(packet.senderUUID()); + ClientCommandHelper.sendFeedback(Component.translatable("chessGame.drawOfferAccepted", opponent)); + ClientCommandHelper.sendFeedback(Component.translatable("chessGame.draw.agreement")); + } + } + } + public static @Nullable C2CFriendlyByteBuf wrapByteBuf(ByteBuf buf, @Nullable String sender, @Nullable UUID senderUUID) { ClientPacketListener connection = Minecraft.getInstance().getConnection(); if (connection == null) { diff --git a/src/main/java/net/earthcomputer/clientcommands/c2c/C2CPacketListener.java b/src/main/java/net/earthcomputer/clientcommands/c2c/C2CPacketListener.java index 6ed723016..9a72f5034 100644 --- a/src/main/java/net/earthcomputer/clientcommands/c2c/C2CPacketListener.java +++ b/src/main/java/net/earthcomputer/clientcommands/c2c/C2CPacketListener.java @@ -1,9 +1,13 @@ package net.earthcomputer.clientcommands.c2c; +import net.earthcomputer.clientcommands.c2c.packets.ChessDrawOfferC2CPacket; +import net.earthcomputer.clientcommands.c2c.packets.ChessMoveC2CPacket; +import net.earthcomputer.clientcommands.c2c.packets.ChessResignC2CPacket; import net.earthcomputer.clientcommands.c2c.packets.MessageC2CPacket; import net.earthcomputer.clientcommands.c2c.packets.PutConnectFourPieceC2CPacket; import net.earthcomputer.clientcommands.c2c.packets.PutTicTacToeMarkC2CPacket; import net.earthcomputer.clientcommands.c2c.packets.StartTwoPlayerGameC2CPacket; +import net.earthcomputer.clientcommands.c2c.packets.StopTwoPlayerGameC2CPacket; import net.minecraft.network.ClientboundPacketListener; public interface C2CPacketListener extends ClientboundPacketListener { @@ -11,7 +15,15 @@ public interface C2CPacketListener extends ClientboundPacketListener { void onStartTwoPlayerGameC2CPacket(StartTwoPlayerGameC2CPacket packet); + void onStopTwoPlayerGameC2CPacket(StopTwoPlayerGameC2CPacket packet); + void onPutTicTacToeMarkC2CPacket(PutTicTacToeMarkC2CPacket packet); void onPutConnectFourPieceC2CPacket(PutConnectFourPieceC2CPacket packet); + + void onChessResignPacket(ChessResignC2CPacket packet); + + void onChessMovePacket(ChessMoveC2CPacket packet); + + void onChessDrawOfferPacket(ChessDrawOfferC2CPacket packet); } diff --git a/src/main/java/net/earthcomputer/clientcommands/c2c/chess/ChessColor.java b/src/main/java/net/earthcomputer/clientcommands/c2c/chess/ChessColor.java new file mode 100644 index 000000000..567d91c82 --- /dev/null +++ b/src/main/java/net/earthcomputer/clientcommands/c2c/chess/ChessColor.java @@ -0,0 +1,9 @@ +package net.earthcomputer.clientcommands.c2c.chess; + +public enum ChessColor { + WHITE, BLACK; + + public ChessColor opposite() { + return this == WHITE ? BLACK : WHITE; + } +} diff --git a/src/main/java/net/earthcomputer/clientcommands/c2c/chess/ChessGame.java b/src/main/java/net/earthcomputer/clientcommands/c2c/chess/ChessGame.java new file mode 100644 index 000000000..ca8223d37 --- /dev/null +++ b/src/main/java/net/earthcomputer/clientcommands/c2c/chess/ChessGame.java @@ -0,0 +1,531 @@ +package net.earthcomputer.clientcommands.c2c.chess; + +import net.minecraft.client.multiplayer.PlayerInfo; +import net.minecraft.network.chat.Component; +import org.joml.Vector2i; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public final class ChessGame { + public final PlayerInfo opponent; + public final ChessColor yourColor; + + private final @Nullable ChessPiece[] pieces = new ChessPiece[64]; + public boolean canWhiteCastleKingside = true; + public boolean canWhiteCastleQueenside = true; + public boolean canBlackCastleKingside = true; + public boolean canBlackCastleQueenside = true; + public ChessColor colorToMove = ChessColor.WHITE; + + private final List repetitionStates = new ArrayList<>(); + private int fiftyMoveCounter = 0; + @Nullable + public ChessColor drawOfferedBy = null; + + public final List moveList = new ArrayList<>(); + public final List algebraicMoveList = new ArrayList<>(); + public final List previousGameStates = new ArrayList<>(); + + public ChessGame(PlayerInfo opponent, ChessColor yourColor) { + this.opponent = opponent; + this.yourColor = yourColor; + + setPiece(0, 0, ChessPiece.WHITE_ROOK); + setPiece(1, 0, ChessPiece.WHITE_KNIGHT); + setPiece(2, 0, ChessPiece.WHITE_BISHOP); + setPiece(3, 0, ChessPiece.WHITE_QUEEN); + setPiece(4, 0, ChessPiece.WHITE_KING); + setPiece(5, 0, ChessPiece.WHITE_BISHOP); + setPiece(6, 0, ChessPiece.WHITE_KNIGHT); + setPiece(7, 0, ChessPiece.WHITE_ROOK); + setPiece(0, 7, ChessPiece.BLACK_ROOK); + setPiece(1, 7, ChessPiece.BLACK_KNIGHT); + setPiece(2, 7, ChessPiece.BLACK_BISHOP); + setPiece(3, 7, ChessPiece.BLACK_QUEEN); + setPiece(4, 7, ChessPiece.BLACK_KING); + setPiece(5, 7, ChessPiece.BLACK_BISHOP); + setPiece(6, 7, ChessPiece.BLACK_KNIGHT); + setPiece(7, 7, ChessPiece.BLACK_ROOK); + + for (int x = 0; x < 8; x++) { + setPiece(x, 1, ChessPiece.WHITE_PAWN); + setPiece(x, 6, ChessPiece.BLACK_PAWN); + } + } + + public ChessGame(ChessGame other) { + this.opponent = other.opponent; + this.yourColor = other.yourColor; + System.arraycopy(other.pieces, 0, pieces, 0, pieces.length); + this.canWhiteCastleKingside = other.canWhiteCastleKingside; + this.canWhiteCastleQueenside = other.canWhiteCastleQueenside; + this.canBlackCastleKingside = other.canBlackCastleKingside; + this.canBlackCastleQueenside = other.canBlackCastleQueenside; + this.colorToMove = other.colorToMove; + this.repetitionStates.addAll(other.repetitionStates); + this.fiftyMoveCounter = other.fiftyMoveCounter; + this.drawOfferedBy = other.drawOfferedBy; + this.moveList.addAll(other.moveList); + this.algebraicMoveList.addAll(other.algebraicMoveList); + this.previousGameStates.addAll(other.previousGameStates); + } + + @Nullable + public ChessPiece getPiece(int x, int y) { + return pieces[x + y * 8]; + } + + public void setPiece(int x, int y, @Nullable ChessPiece piece) { + pieces[x + y * 8] = piece; + } + + @Nullable + public ChessPiece getPieceInPreviousPosition(int x, int y) { + return repetitionStates.isEmpty() ? null : repetitionStates.getLast().board[x + y * 8]; + } + + public boolean isCheck() { + // find the king + ChessPiece kingPiece = colorToMove == ChessColor.WHITE ? ChessPiece.WHITE_KING : ChessPiece.BLACK_KING; + int kingX; + int kingY = -1; + kingLoop: + for (kingX = 0; kingX < 8; kingX++) { + for (kingY = 0; kingY < 8; kingY++) { + if (getPiece(kingX, kingY) == kingPiece) { + break kingLoop; + } + } + } + + ChessGame oppositeToMoveGame = new ChessGame(this); + oppositeToMoveGame.colorToMove = colorToMove.opposite(); + for (ChessMove move : oppositeToMoveGame.semiLegalMoves()) { + if (move.to().x == kingX && move.to().y == kingY) { + return true; + } + } + + return false; + } + + public boolean isCheckmate() { + return isCheck() && legalMoves().isEmpty(); + } + + public boolean isStalemate() { + return !isCheck() && legalMoves().isEmpty(); + } + + public boolean isDrawByFiftyMoves() { + return fiftyMoveCounter >= 100; // 50 moves = 100 ply + } + + public boolean isDrawByRepetition() { + RepetitionState repetitionState = currentRepetitionState(); + int repetitions = 0; + for (int i = repetitionStates.size() - 1; i >= 0; i--) { + if (repetitionStates.get(i).equals(repetitionState)) { + repetitions++; + if (repetitions >= 2) { + return true; + } + } + } + + return false; + } + + public boolean isDrawByInsufficientMaterial() { + // https://www.reddit.com/r/chess/comments/se89db/a_writeup_on_definitions_of_insufficient_material/ + + int whiteKnights = 0; + int blackKnights = 0; + int whiteLightSquaredBishops = 0; + int whiteDarkSquaredBishops = 0; + int blackLightSquaredBishops = 0; + int blackDarkSquaredBishops = 0; + + for (int i = 0; i < pieces.length; i++) { + switch (pieces[i]) { + case WHITE_PAWN, BLACK_PAWN, WHITE_ROOK, BLACK_ROOK, WHITE_QUEEN, BLACK_QUEEN -> { + return false; + } + case WHITE_KNIGHT -> whiteKnights++; + case BLACK_KNIGHT -> blackKnights++; + case WHITE_BISHOP -> { + if (i % 2 == i / 8 % 2) { + whiteDarkSquaredBishops++; + } else { + whiteLightSquaredBishops++; + } + } + case BLACK_BISHOP -> { + if (i % 2 == i / 8 % 2) { + blackDarkSquaredBishops++; + } else { + blackLightSquaredBishops++; + } + } + case null, default -> { + } + } + } + + if (whiteKnights >= 2 || blackKnights >= 2) { + return false; + } + if (whiteLightSquaredBishops > 0 && whiteDarkSquaredBishops > 0) { + return false; + } + if (blackLightSquaredBishops > 0 && blackDarkSquaredBishops > 0) { + return false; + } + if (whiteKnights > 0 && (whiteLightSquaredBishops > 0 || whiteDarkSquaredBishops > 0)) { + return false; + } + if (blackKnights > 0 && (blackLightSquaredBishops > 0 || blackDarkSquaredBishops > 0)) { + return false; + } + if (whiteKnights > 0 && (blackKnights > 0 || blackLightSquaredBishops > 0 || blackDarkSquaredBishops > 0)) { + return false; + } + if (blackKnights > 0 && (whiteKnights > 0 || whiteLightSquaredBishops > 0 || whiteDarkSquaredBishops > 0)) { + return false; + } + + return true; + } + + @Nullable + public Component detectEndCondition() { + if (isCheckmate()) { + if (colorToMove == yourColor) { + return Component.translatable("chessGame.checkmate.lost"); + } else { + return Component.translatable("chessGame.checkmate.won"); + } + } + if (isStalemate()) { + return Component.translatable("chessGame.draw.stalemate"); + } + if (isDrawByInsufficientMaterial()) { + return Component.translatable("chessGame.draw.insufficientMaterial"); + } + if (isDrawByRepetition()) { + return Component.translatable("chessGame.draw.repetition"); + } + if (isDrawByFiftyMoves()) { + return Component.translatable("chessGame.draw.fiftyMoves"); + } + + return null; + } + + // Returns the list of legal moves, disregarding whether it puts your king in check (or through check if castling) + private List semiLegalMoves() { + List moves = new ArrayList<>(); + for (int x = 0; x < 8; x++) { + for (int y = 0; y < 8; y++) { + ChessPiece piece = getPiece(x, y); + if (piece != null && piece.color() == colorToMove) { + piece.type().generateMoves(this, x, y, moves); + } + } + } + return moves; + } + + public List legalMoves() { + List moves = semiLegalMoves(); + moves.removeIf(move -> !move.type().isLegalAccordingToCheck(this, move)); + return moves; + } + + public boolean isCapture(ChessMove move) { + ChessPiece targetPiece = getPiece(move.to().x, move.to().y); + return targetPiece != null || move.type() == ChessMove.Type.EN_PASSANT; + } + + private RepetitionState currentRepetitionState() { + boolean canEnPassant = false; + for (ChessMove testMove : semiLegalMoves()) { + if (testMove.type() == ChessMove.Type.EN_PASSANT) { + canEnPassant = true; + break; + } + } + + return new RepetitionState(pieces.clone(), canWhiteCastleKingside, canWhiteCastleQueenside, canBlackCastleKingside, canBlackCastleQueenside, canEnPassant); + } + + public void makeMove(ChessMove move) { + ChessPiece piece = getPiece(move.from().x, move.from().y); + if (piece == null) { + return; + } + + String algebraicNotation = getAlgebraicNotation(move); + ChessGame prevGameState = new ChessGame(this); + + if (piece.type() == ChessPieceType.PAWN || isCapture(move)) { + fiftyMoveCounter = 0; + } else { + fiftyMoveCounter++; + } + + repetitionStates.add(currentRepetitionState()); + + move.perform(this); + + if (piece == ChessPiece.WHITE_KING) { + canWhiteCastleKingside = false; + canWhiteCastleQueenside = false; + } else if (piece == ChessPiece.BLACK_KING) { + canBlackCastleKingside = false; + canBlackCastleQueenside = false; + } + + if ((move.from().x == 0 && move.from().y == 0) || (move.to().x == 0 && move.to().y == 0)) { + canWhiteCastleQueenside = false; + } else if ((move.from().x == 7 && move.from().y == 0) || (move.to().x == 7 && move.to().y == 0)) { + canWhiteCastleKingside = false; + } else if ((move.from().x == 0 && move.from().y == 7) || (move.to().x == 0 && move.to().y == 7)) { + canBlackCastleQueenside = false; + } else if ((move.from().x == 7 && move.from().y == 7) || (move.to().x == 7 && move.to().y == 7)) { + canBlackCastleKingside = false; + } + + colorToMove = colorToMove.opposite(); + + moveList.add(move); + algebraicMoveList.add(algebraicNotation); + previousGameStates.add(prevGameState); + } + + public static String getSquareName(int x, int y) { + return "" + (char) ('a' + x) + (y + 1); + } + + public String getAlgebraicNotation(ChessMove move) { + StringBuilder notation = new StringBuilder(); + + if (move.type() == ChessMove.Type.CASTLE) { + if (move.to().x > 4) { + notation.append("0-0"); + } else { + notation.append("0-0-0"); + } + } else { + ChessPiece piece = getPiece(move.from().x, move.from().y); + ChessPieceType pieceType = piece == null ? null : piece.type(); + if (pieceType == ChessPieceType.PAWN) { + if (move.from().x != move.to().x) { + notation.append((char) ('a' + move.from().x)); + } + } else { + notation.append(pieceType == null ? '?' : pieceType.notation); + + boolean needsDisambiguation = false; + boolean anotherMoveFromSameX = false; + boolean anotherMoveFromSameY = false; + for (ChessMove legalMove : legalMoves()) { + if (legalMove.to().x != move.to().x || legalMove.to().y != move.to().y) { + continue; + } + if (legalMove.from().x == move.from().x && legalMove.from().y == move.from().y) { + continue; + } + ChessPiece otherPiece = getPiece(legalMove.from().x, legalMove.from().y); + if (otherPiece != piece) { + continue; + } + + needsDisambiguation = true; + if (legalMove.from().x == move.from().x) { + anotherMoveFromSameX = true; + } + if (legalMove.from().y == move.from().y) { + anotherMoveFromSameY = true; + } + } + + if (needsDisambiguation) { + if (!anotherMoveFromSameX) { + notation.append((char) ('a' + move.from().x)); + } else if (!anotherMoveFromSameY) { + notation.append(move.from().y + 1); + } else { + notation.append(getSquareName(move.from().x, move.from().y)); + } + } + } + + if (isCapture(move)) { + notation.append('x'); + } + + notation.append(getSquareName(move.to().x, move.to().y)); + + switch (move.type()) { + case PROMOTE_QUEEN -> notation.append("=Q"); + case PROMOTE_ROOK -> notation.append("=R"); + case PROMOTE_BISHOP -> notation.append("=B"); + case PROMOTE_KNIGHT -> notation.append("=N"); + } + } + + ChessGame gameCopy = new ChessGame(this); + move.perform(gameCopy); + if (gameCopy.isCheckmate()) { + notation.append('#'); + } else if (gameCopy.isCheck()) { + notation.append('+'); + } + + return notation.toString(); + } + + public String getFen() { + StringBuilder fen = new StringBuilder(); + + for (int y = 7; y >= 0; y--) { + int emptyCounter = 0; + for (int x = 0; x < 8; x++) { + ChessPiece piece = getPiece(x, y); + if (piece == null) { + emptyCounter++; + } else { + if (emptyCounter > 0) { + fen.append(emptyCounter); + emptyCounter = 0; + } + if (piece.color() == ChessColor.WHITE) { + fen.append(piece.type().notation); + } else { + fen.append(Character.toLowerCase(piece.type().notation)); + } + } + } + + if (emptyCounter > 0) { + fen.append(emptyCounter); + } + + if (y != 0) { + fen.append('/'); + } + } + + fen.append(' '); + + if (colorToMove == ChessColor.WHITE) { + fen.append('w'); + } else { + fen.append('b'); + } + + fen.append(' '); + + if (!canWhiteCastleKingside && !canWhiteCastleQueenside && !canBlackCastleKingside && !canBlackCastleQueenside) { + fen.append('-'); + } else { + if (canWhiteCastleKingside) { + fen.append('K'); + } + if (canWhiteCastleQueenside) { + fen.append('Q'); + } + if (canBlackCastleKingside) { + fen.append('k'); + } + if (canBlackCastleQueenside) { + fen.append('q'); + } + } + + fen.append(' '); + + Vector2i enPassantTargetSquare = null; + if (!moveList.isEmpty()) { + ChessPiece movedPiece = getPiece(moveList.getLast().to().x, moveList.getLast().to().y); + if (movedPiece != null && movedPiece.type() == ChessPieceType.PAWN && Math.abs(moveList.getLast().to().y - moveList.getLast().from().y) == 2) { + enPassantTargetSquare = new Vector2i(moveList.getLast().to().x, (moveList.getLast().to().y + moveList.getLast().from().y) / 2); + } + } + + if (enPassantTargetSquare == null) { + fen.append('-'); + } else { + fen.append(getSquareName(enPassantTargetSquare.x, enPassantTargetSquare.y)); + } + + fen.append(' ').append(fiftyMoveCounter).append(' ').append(moveList.size() / 2 + 1); + + return fen.toString(); + } + + public String getPgn() { + StringBuilder pgn = new StringBuilder(); + + for (int i = 0; i < moveList.size(); i += 2) { + if (!pgn.isEmpty()) { + pgn.append(' '); + } + pgn.append(i / 2 + 1).append(". "); + pgn.append(algebraicMoveList.get(i)); + + if (i + 1 < moveList.size()) { + pgn.append(' ').append(algebraicMoveList.get(i + 1)); + } + } + + return pgn.toString(); + } + + private record RepetitionState(@Nullable ChessPiece[] board, int flags) { + RepetitionState( + @Nullable ChessPiece[] board, + boolean canWhiteCastleKingside, + boolean canWhiteCastleQueenside, + boolean canBlackCastleKingside, + boolean canBlackCastleQueenside, + boolean canEnPassant + ) { + this(board, buildFlags(canWhiteCastleKingside, canWhiteCastleQueenside, canBlackCastleKingside, canBlackCastleQueenside, canEnPassant)); + } + + private static int buildFlags( + boolean canWhiteCastleKingside, + boolean canWhiteCastleQueenside, + boolean canBlackCastleKingside, + boolean canBlackCastleQueenside, + boolean canEnPassant + ) { + int flags = 0; + flags |= canWhiteCastleKingside ? 1 : 0; + flags |= canWhiteCastleQueenside ? 2 : 0; + flags |= canBlackCastleKingside ? 4 : 0; + flags |= canBlackCastleQueenside ? 8 : 0; + flags |= canEnPassant ? 16 : 0; + return flags; + } + + @Override + public int hashCode() { + return Arrays.hashCode(board) * 31 + flags; + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof RepetitionState(ChessPiece[] otherBoard, int otherFlags))) { + return false; + } + + return Arrays.equals(board, otherBoard) && flags == otherFlags; + } + } +} diff --git a/src/main/java/net/earthcomputer/clientcommands/c2c/chess/ChessMove.java b/src/main/java/net/earthcomputer/clientcommands/c2c/chess/ChessMove.java new file mode 100644 index 000000000..92e1637bf --- /dev/null +++ b/src/main/java/net/earthcomputer/clientcommands/c2c/chess/ChessMove.java @@ -0,0 +1,112 @@ +package net.earthcomputer.clientcommands.c2c.chess; + +import io.netty.buffer.ByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.util.ByIdMap; +import org.joml.Vector2i; + +import java.util.function.IntFunction; + +public record ChessMove(Vector2i from, Vector2i to, Type type) { + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.BYTE, + move -> (byte) ((move.from.x << 3) | move.from.y), + ByteBufCodecs.BYTE, + move -> (byte) ((move.to.x << 3) | move.to.y), + Type.STREAM_CODEC, + ChessMove::type, + (from, to, type) -> new ChessMove(new Vector2i((from >> 3) & 7, from & 7), new Vector2i((to >> 3) & 7, to & 7), type) + ); + + public ChessMove(Vector2i from, Vector2i to) { + this(from, to, Type.REGULAR); + } + + public void perform(ChessGame game) { + type.perform(game, this); + } + + public enum Type { + REGULAR { + @Override + public void perform(ChessGame game, ChessMove move) { + game.setPiece(move.to.x, move.to.y, game.getPiece(move.from.x, move.from.y)); + game.setPiece(move.from.x, move.from.y, null); + } + }, + CASTLE { + @Override + public void perform(ChessGame game, ChessMove move) { + REGULAR.perform(game, move); + ChessMove rookMove = move.to.x > 4 ? new ChessMove(new Vector2i(7, move.to.y), new Vector2i(5, move.to.y)) : new ChessMove(new Vector2i(0, move.to.y), new Vector2i(3, move.to.y)); + REGULAR.perform(game, rookMove); + } + + @Override + public boolean isLegalAccordingToCheck(ChessGame game, ChessMove move) { + if (!super.isLegalAccordingToCheck(game, move)) { + return false; + } + ChessGame throughCheckGame = new ChessGame(game); + ChessMove kingMove = move.to.x > 4 ? new ChessMove(move.from, new Vector2i(5, move.to.y)) : new ChessMove(move.from, new Vector2i(3, move.to.y)); + REGULAR.perform(throughCheckGame, kingMove); + return !throughCheckGame.isCheck(); + } + }, + EN_PASSANT { + @Override + public void perform(ChessGame game, ChessMove move) { + REGULAR.perform(game, move); + game.setPiece(move.to.x, move.from.y, null); + } + }, + PROMOTE_QUEEN { + @Override + public void perform(ChessGame game, ChessMove move) { + game.setPiece(move.from.x, move.from.y, null); + game.setPiece(move.to.x, move.to.y, game.colorToMove == ChessColor.WHITE ? ChessPiece.WHITE_QUEEN : ChessPiece.BLACK_QUEEN); + } + }, + PROMOTE_ROOK { + @Override + public void perform(ChessGame game, ChessMove move) { + game.setPiece(move.from.x, move.from.y, null); + game.setPiece(move.to.x, move.to.y, game.colorToMove == ChessColor.WHITE ? ChessPiece.WHITE_ROOK : ChessPiece.BLACK_ROOK); + } + }, + PROMOTE_BISHOP { + @Override + public void perform(ChessGame game, ChessMove move) { + game.setPiece(move.from.x, move.from.y, null); + game.setPiece(move.to.x, move.to.y, game.colorToMove == ChessColor.WHITE ? ChessPiece.WHITE_BISHOP : ChessPiece.BLACK_BISHOP); + } + }, + PROMOTE_KNIGHT { + @Override + public void perform(ChessGame game, ChessMove move) { + game.setPiece(move.from.x, move.from.y, null); + game.setPiece(move.to.x, move.to.y, game.colorToMove == ChessColor.WHITE ? ChessPiece.WHITE_KNIGHT : ChessPiece.BLACK_KNIGHT); + } + }, + ; + + private static final IntFunction BY_ID = ByIdMap.continuous(Type::ordinal, values(), ByIdMap.OutOfBoundsStrategy.ZERO); + public static final StreamCodec STREAM_CODEC = ByteBufCodecs.idMapper(BY_ID, Type::ordinal); + + public abstract void perform(ChessGame game, ChessMove move); + + public boolean isLegalAccordingToCheck(ChessGame game, ChessMove move) { + if (move.type() == Type.CASTLE && game.isCheck()) { + return false; + } + ChessGame gameCopy = new ChessGame(game); + perform(gameCopy, move); + return !gameCopy.isCheck(); + } + + public boolean isPromotion() { + return this == PROMOTE_QUEEN || this == PROMOTE_ROOK || this == PROMOTE_BISHOP || this == PROMOTE_KNIGHT; + } + } +} diff --git a/src/main/java/net/earthcomputer/clientcommands/c2c/chess/ChessPiece.java b/src/main/java/net/earthcomputer/clientcommands/c2c/chess/ChessPiece.java new file mode 100644 index 000000000..ba1ab4754 --- /dev/null +++ b/src/main/java/net/earthcomputer/clientcommands/c2c/chess/ChessPiece.java @@ -0,0 +1,41 @@ +package net.earthcomputer.clientcommands.c2c.chess; + +import net.minecraft.resources.Identifier; + +public enum ChessPiece { + WHITE_KING("white_king", 0.5f, 0), + WHITE_QUEEN("white_queen"), + WHITE_BISHOP("white_bishop"), + WHITE_KNIGHT("white_knight"), + WHITE_ROOK("white_rook"), + WHITE_PAWN("white_pawn"), + BLACK_KING("black_king", 0.5f, 0), + BLACK_QUEEN("black_queen"), + BLACK_BISHOP("black_bishop"), + BLACK_KNIGHT("black_knight"), + BLACK_ROOK("black_rook"), + BLACK_PAWN("black_pawn"), + ; + + public final Identifier texture; + public final float u; + public final float v; + + ChessPiece(String texture) { + this(texture, 0, 0); + } + + ChessPiece(String texture, float u, float v) { + this.texture = Identifier.fromNamespaceAndPath("clientcommands", "textures/chess/" + texture + ".png"); + this.u = u; + this.v = v; + } + + public ChessColor color() { + return ordinal() < 6 ? ChessColor.WHITE : ChessColor.BLACK; + } + + public ChessPieceType type() { + return ChessPieceType.VALUES[ordinal() % 6]; + } +} diff --git a/src/main/java/net/earthcomputer/clientcommands/c2c/chess/ChessPieceType.java b/src/main/java/net/earthcomputer/clientcommands/c2c/chess/ChessPieceType.java new file mode 100644 index 000000000..42414dba9 --- /dev/null +++ b/src/main/java/net/earthcomputer/clientcommands/c2c/chess/ChessPieceType.java @@ -0,0 +1,171 @@ +package net.earthcomputer.clientcommands.c2c.chess; + +import org.joml.Vector2i; + +import java.util.List; + +public enum ChessPieceType { + KING('K') { + @Override + public void generateMoves(ChessGame game, int x, int y, List moves) { + for (int dx = -1; dx <= 1; dx++) { + for (int dy = -1; dy <= 1; dy++) { + if (dx != 0 || dy != 0) { + tryAddMove(game, x, y, dx, dy, moves); + } + } + } + + if (game.colorToMove == ChessColor.WHITE) { + if (game.canWhiteCastleKingside) { + if (game.getPiece(5, 0) == null && game.getPiece(6, 0) == null) { + moves.add(new ChessMove(new Vector2i(4, 0), new Vector2i(6, 0), ChessMove.Type.CASTLE)); + } + } + if (game.canWhiteCastleQueenside) { + if (game.getPiece(3, 0) == null && game.getPiece(2, 0) == null && game.getPiece(1, 0) == null) { + moves.add(new ChessMove(new Vector2i(4, 0), new Vector2i(2, 0), ChessMove.Type.CASTLE)); + } + } + } else { + if (game.canBlackCastleKingside) { + if (game.getPiece(5, 7) == null && game.getPiece(6, 7) == null) { + moves.add(new ChessMove(new Vector2i(4, 7), new Vector2i(6, 7), ChessMove.Type.CASTLE)); + } + } + if (game.canBlackCastleQueenside) { + if (game.getPiece(3, 7) == null && game.getPiece(2, 7) == null && game.getPiece(1, 7) == null) { + moves.add(new ChessMove(new Vector2i(4, 7), new Vector2i(2, 7), ChessMove.Type.CASTLE)); + } + } + } + } + }, + QUEEN('Q') { + @Override + public void generateMoves(ChessGame game, int x, int y, List moves) { + BISHOP.generateMoves(game, x, y, moves); + ROOK.generateMoves(game, x, y, moves); + } + }, + BISHOP('B') { + @Override + public void generateMoves(ChessGame game, int x, int y, List moves) { + slide(game, x, y, -1, -1, moves); + slide(game, x, y, 1, -1, moves); + slide(game, x, y, -1, 1, moves); + slide(game, x, y, 1, 1, moves); + } + }, + KNIGHT('N') { + @Override + public void generateMoves(ChessGame game, int x, int y, List moves) { + tryAddMove(game, x, y, -2, -1, moves); + tryAddMove(game, x, y, -2, 1, moves); + tryAddMove(game, x, y, 2, -1, moves); + tryAddMove(game, x, y, 2, 1, moves); + tryAddMove(game, x, y, -1, -2, moves); + tryAddMove(game, x, y, -1, 2, moves); + tryAddMove(game, x, y, 1, -2, moves); + tryAddMove(game, x, y, 1, 2, moves); + } + }, + ROOK('R') { + @Override + public void generateMoves(ChessGame game, int x, int y, List moves) { + slide(game, x, y, -1, 0, moves); + slide(game, x, y, 1, 0, moves); + slide(game, x, y, 0, -1, moves); + slide(game, x, y, 0, 1, moves); + } + }, + PAWN('P') { + @Override + public void generateMoves(ChessGame game, int x, int y, List moves) { + int dy = game.colorToMove == ChessColor.WHITE ? 1 : -1; + + for (int dx = -1; dx <= 1; dx += 2) { + if (x + dx < 0 || x + dx >= 8) { + continue; + } + + // captures + ChessPiece diagonalPiece = game.getPiece(x + dx, y + dy); + if (diagonalPiece != null && diagonalPiece.color() != game.colorToMove) { + addPromotableMove(x, y, x + dx, y + dy, moves); + } + + // en passant + ChessPiece adjacentPiece = game.getPiece(x + dx, y); + if (adjacentPiece != null && adjacentPiece.type() == PAWN && adjacentPiece.color() != game.colorToMove) { + if (!game.moveList.isEmpty()) { + ChessMove lastMove = game.moveList.getLast(); + if (lastMove.to().x == x + dx && game.moveList.getLast().to().y == y && Math.abs(game.moveList.getLast().to().y - game.moveList.getLast().from().y) == 2) { + moves.add(new ChessMove(new Vector2i(x, y), new Vector2i(x + dx, y + dy), ChessMove.Type.EN_PASSANT)); + } + } + } + } + + // forward moves + if (game.getPiece(x, y + dy) == null) { + addPromotableMove(x, y, x, y + dy, moves); + + // first move + if (game.colorToMove == ChessColor.WHITE ? y == 1 : y == 6) { + if (game.getPiece(x, y + dy * 2) == null) { + moves.add(new ChessMove(new Vector2i(x, y), new Vector2i(x, y + dy * 2))); + } + } + } + } + + private static void addPromotableMove(int x, int y, int toX, int toY, List moves) { + if (toY == 0 || toY == 7) { + moves.add(new ChessMove(new Vector2i(x, y), new Vector2i(toX, toY), ChessMove.Type.PROMOTE_QUEEN)); + moves.add(new ChessMove(new Vector2i(x, y), new Vector2i(toX, toY), ChessMove.Type.PROMOTE_ROOK)); + moves.add(new ChessMove(new Vector2i(x, y), new Vector2i(toX, toY), ChessMove.Type.PROMOTE_BISHOP)); + moves.add(new ChessMove(new Vector2i(x, y), new Vector2i(toX, toY), ChessMove.Type.PROMOTE_KNIGHT)); + } else { + moves.add(new ChessMove(new Vector2i(x, y), new Vector2i(toX, toY))); + } + } + }, + ; + + static final ChessPieceType[] VALUES = values(); + + public final char notation; + + ChessPieceType(char notation) { + this.notation = notation; + } + + public abstract void generateMoves(ChessGame game, int x, int y, List moves); + + private static void slide(ChessGame game, int x, int y, int dx, int dy, List moves) { + for (int toX = x + dx, toY = y + dy; toX >= 0 && toX < 8 && toY >= 0 && toY < 8; toX += dx, toY += dy) { + ChessPiece toPiece = game.getPiece(toX, toY); + if (toPiece != null && toPiece.color() == game.colorToMove) { + break; + } + moves.add(new ChessMove(new Vector2i(x, y), new Vector2i(toX, toY))); + if (toPiece != null) { + break; + } + } + } + + private static void tryAddMove(ChessGame game, int x, int y, int dx, int dy, List moves) { + int toX = x + dx; + int toY = y + dy; + if (toX < 0 || toX >= 8 || toY < 0 || toY >= 8) { + return; + } + + ChessPiece toPiece = game.getPiece(toX, toY); + if (toPiece == null || toPiece.color() != game.colorToMove) { + moves.add(new ChessMove(new Vector2i(x, y), new Vector2i(toX, toY))); + } + } +} diff --git a/src/main/java/net/earthcomputer/clientcommands/c2c/chess/ChessScreen.java b/src/main/java/net/earthcomputer/clientcommands/c2c/chess/ChessScreen.java new file mode 100644 index 000000000..8c01b8bf4 --- /dev/null +++ b/src/main/java/net/earthcomputer/clientcommands/c2c/chess/ChessScreen.java @@ -0,0 +1,878 @@ +package net.earthcomputer.clientcommands.c2c.chess; + +import com.mojang.authlib.GameProfile; +import com.mojang.blaze3d.platform.InputConstants; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import net.earthcomputer.clientcommands.c2c.C2CPacketHandler; +import net.earthcomputer.clientcommands.c2c.packets.ChessDrawOfferC2CPacket; +import net.earthcomputer.clientcommands.c2c.packets.ChessMoveC2CPacket; +import net.earthcomputer.clientcommands.c2c.packets.ChessResignC2CPacket; +import net.earthcomputer.clientcommands.command.ClientCommandHelper; +import net.earthcomputer.clientcommands.features.TwoPlayerGame; +import net.earthcomputer.clientcommands.render.ColoredTriangleRenderState; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.AbstractButton; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.ContainerObjectSelectionList; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.narration.NarratableEntry; +import net.minecraft.client.gui.render.TextureSetup; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.multiplayer.ClientPacketListener; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.resources.sounds.SimpleSoundInstance; +import net.minecraft.client.resources.sounds.SoundInstance; +import net.minecraft.core.Holder; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.ARGB; +import net.minecraft.util.CommonColors; +import net.minecraft.util.Mth; +import org.jetbrains.annotations.UnknownNullability; +import org.joml.Matrix3x2f; +import org.joml.Vector2i; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +public final class ChessScreen extends Screen { + private static final Identifier BOARD_TEXTURE = Identifier.fromNamespaceAndPath("clientcommands", "textures/chess/board.png"); + private static final Identifier SQUARE_HIGHLIGHT_TEXTURE = Identifier.fromNamespaceAndPath("clientcommands", "textures/chess/square_highlight.png"); + private static final Identifier CAPTURE_HIGHLIGHT_TEXTURE = Identifier.fromNamespaceAndPath("clientcommands", "textures/chess/capture_highlight.png"); + private static final Identifier CHECK_TEXTURE = Identifier.fromNamespaceAndPath("clientcommands", "textures/chess/check.png"); + + private static final int SQUARE_SIZE = 36; + private static final int BOARD_SIZE = 8 * SQUARE_SIZE; + private static final int PIECE_SIZE = 32; + private static final int PADDING = (SQUARE_SIZE - PIECE_SIZE) / 2; + private static final int ARROW_THICKNESS = 8; + private static final int ARROW_HALF_THICKNESS = ARROW_THICKNESS / 2; + private static final int ARROW_HEAD_SIZE = 10; + private static final int BOARD_EDGE_PADDING = 15; + private static final int RANK_FILE_LABEL_PADDING = 1; + + private static final int BLUE_HIGHLIGHT = 0xc0_03cafc; + private static final int RED_HIGHLIGHT = 0xc0_f21b42; + private static final int GREEN_HIGHLIGHT = 0xc0_03fc3d; + private static final int ORANGE_HIGHLIGHT = 0xc0_f0870e; + private static final int SELECTED_HIGHLIGHT_COLOR = 0xc0_f2d51b; + + private static final int MOVE_ANIMATION_LENGTH = 3; + + private static final SoundEvent MOVE_SOUND = SoundEvents.WOODEN_PRESSURE_PLATE_CLICK_OFF; + private static final float MOVE_VOLUME = 1; + private static final float MOVE_PITCH = 1; + private static final SoundEvent CAPTURE_SOUND = SoundEvents.ITEM_PICKUP; + private static final float CAPTURE_VOLUME = 0.75f; + private static final float CAPTURE_PITCH = 1; + private static final SoundEvent CASTLING_SOUND = SoundEvents.PISTON_CONTRACT; + private static final float CASTLING_VOLUME = 1; + private static final float CASTLING_PITCH = 1; + private static final Holder CHECK_SOUND = SoundEvents.NOTE_BLOCK_BELL; + private static final float CHECK_VOLUME = 1; + private static final float CHECK_PITCH = 1; + private static final SoundEvent GAME_END_SOUND = SoundEvents.PLAYER_LEVELUP; + private static final float GAME_END_VOLUME = 1; + private static final float GAME_END_PITCH = 1.5f; + + private static final ChessPiece[] WHITE_PROMOTION_PIECES = { ChessPiece.WHITE_QUEEN, ChessPiece.WHITE_ROOK, ChessPiece.WHITE_BISHOP, ChessPiece.WHITE_KNIGHT }; + private static final ChessPiece[] BLACK_PROMOTION_PIECES = { ChessPiece.BLACK_QUEEN, ChessPiece.BLACK_ROOK, ChessPiece.BLACK_BISHOP, ChessPiece.BLACK_KNIGHT }; + private static final ChessMove.Type[] PROMOTION_MOVE_TYPES = { ChessMove.Type.PROMOTE_QUEEN, ChessMove.Type.PROMOTE_ROOK, ChessMove.Type.PROMOTE_BISHOP, ChessMove.Type.PROMOTE_KNIGHT }; + + private final ChessGame game; + @Nullable + private Vector2i selectedSquare; + private boolean dragging = false; + private boolean isSecondClick = false; + private final Map squareHighlights = new HashMap<>(); + private final Map arrows = new LinkedHashMap<>(); + @Nullable + private Vector2i highlightStartSquare; + @Nullable + private ChessMove lastSeenMove; + private int moveAnimationTicks = -1; + private boolean isCastlingAnimatingRookOnly = false; + private boolean wasGameEnded = false; + + @Nullable + private ChessMove pendingPromotionMove; + @Nullable + private Vector2i promotionGuiOrigin; + + @UnknownNullability + private GameHistoryWidget gameHistory; + private int selectedMove; + @UnknownNullability + private Button resignButton; + @UnknownNullability + private Button drawOfferButton; + @UnknownNullability + private Button copyPgnButton; + + public ChessScreen(ChessGame game) { + super(Component.translatable("chessGame.title", game.opponent.getProfile().name())); + this.game = game; + this.selectedMove = game.moveList.size() - 1; + } + + @Override + protected void init() { + int startX = (this.width - BOARD_SIZE) / 2; + int startY = (this.height - BOARD_SIZE) / 2; + int buttonStartY = startY + BOARD_SIZE + BOARD_EDGE_PADDING; + + resignButton = Button.builder( + Component.translatable("chessGame.resign"), + button -> { + if (TwoPlayerGame.CHESS_TYPE.getActiveGame(game.opponent.getProfile().id()) != game) { + return; + } + + try { + ClientPacketListener connection = minecraft.getConnection(); + assert connection != null; + GameProfile gameProfile = connection.getLocalGameProfile(); + C2CPacketHandler.getInstance().sendPacket(new ChessResignC2CPacket(gameProfile.name(), gameProfile.id()), game.opponent); + } catch (CommandSyntaxException e) { + ClientCommandHelper.sendFeedback(Component.translationArg(e.getRawMessage())); + } + TwoPlayerGame.CHESS_TYPE.removeActiveGame(game.opponent.getProfile().id()); + ClientCommandHelper.sendFeedback(Component.translatable("chessGame.youResigned")); + } + ) + .bounds(startX, buttonStartY, BOARD_SIZE / 2 - 2, 20) + .build(); + resignButton.active = TwoPlayerGame.CHESS_TYPE.getActiveGame(game.opponent.getProfile().id()) == game; + addRenderableWidget(resignButton); + + + drawOfferButton = Button.builder( + Component.empty(), + button -> { + ChessDrawOfferC2CPacket.Operation operation; + if (game.drawOfferedBy == null) { + game.drawOfferedBy = game.yourColor; + operation = ChessDrawOfferC2CPacket.Operation.OFFER; + } else if (game.drawOfferedBy == game.yourColor) { + game.drawOfferedBy = null; + operation = ChessDrawOfferC2CPacket.Operation.RETRACT; + } else { + TwoPlayerGame.CHESS_TYPE.removeActiveGame(game.opponent.getProfile().id()); + operation = ChessDrawOfferC2CPacket.Operation.ACCEPT; + ClientCommandHelper.sendFeedback(Component.translatable("chessGame.draw.agreement")); + } + + try { + ClientPacketListener connection = minecraft.getConnection(); + assert connection != null; + GameProfile gameProfile = connection.getLocalGameProfile(); + C2CPacketHandler.getInstance().sendPacket(new ChessDrawOfferC2CPacket(gameProfile.name(), gameProfile.id(), operation), game.opponent); + } catch (CommandSyntaxException e) { + ClientCommandHelper.sendFeedback(Component.translationArg(e.getRawMessage())); + } + } + ) + .bounds(startX + BOARD_SIZE / 2 + 2, buttonStartY, BOARD_SIZE / 2 - 2, 20) + .build(); + updateDrawOfferText(); + drawOfferButton.active = resignButton.active; + addRenderableWidget(drawOfferButton); + + Button copyFenButton = Button.builder( + Component.translatable("chessGame.copyFen"), + button -> { + ChessGame displayingGame = selectedMove + 1 == game.moveList.size() ? game : game.previousGameStates.get(selectedMove + 1); + String fen = displayingGame.getFen(); + minecraft.keyboardHandler.setClipboard(fen); + ClientCommandHelper.sendFeedback(Component.translatable("chessGame.copyFen.success", fen)); + } + ) + .bounds(startX, buttonStartY + 24, BOARD_SIZE / 2 - 2, 20) + .build(); + addRenderableWidget(copyFenButton); + + copyPgnButton = Button.builder( + Component.translatable("chessGame.copyPgn"), + button -> { + String pgn = game.getPgn(); + minecraft.keyboardHandler.setClipboard(pgn); + ClientCommandHelper.sendFeedback(Component.translatable("chessGame.copyPgn.success", pgn)); + } + ) + .bounds(startX + BOARD_SIZE / 2 + 2, buttonStartY + 24, BOARD_SIZE / 2 - 2, 20) + .build(); + copyPgnButton.active = !game.moveList.isEmpty(); + addRenderableWidget(copyPgnButton); + + int gameHistoryWidth = calcGameHistoryWidth(); + gameHistory = new GameHistoryWidget(minecraft, this.width - 5 - gameHistoryWidth, 10, gameHistoryWidth, this.height - 20, 20); + gameHistory.refresh(); + addRenderableWidget(gameHistory); + } + + private void updateDrawOfferText() { + if (game.drawOfferedBy == null) { + drawOfferButton.setMessage(Component.translatable("chessGame.offerDraw")); + } else if (game.drawOfferedBy == game.yourColor) { + drawOfferButton.setMessage(Component.translatable("chessGame.retractDrawOffer")); + } else { + drawOfferButton.setMessage(Component.translatable("chessGame.acceptDraw")); + } + } + + private int calcGameHistoryWidth() { + return 2 * (maxWidthOf(Arrays.stream(ChessPieceType.VALUES).filter(piece -> piece != ChessPieceType.PAWN).map(piece -> piece.notation)) + + font.width("x") + + 2 * (maxWidthOf(Stream.of('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h')) + maxWidthOf(Stream.of('1', '2', '3', '4', '5', '6', '7', '8'))) + + maxWidthOf(Stream.of('+', '#')) + + AbstractButton.TEXT_MARGIN * 4); + } + + private int maxWidthOf(Stream chars) { + return chars.mapToInt(c -> font.width(c.toString())).max().orElseThrow(); + } + + @Override + public void tick() { + if (!game.moveList.isEmpty() && lastSeenMove != null && !lastSeenMove.equals(game.moveList.getLast())) { + playSound(getMoveSound(game.moveList.getLast())); + gameHistory.refresh(); + moveAnimationTicks = 0; + isCastlingAnimatingRookOnly = false; + } else if (moveAnimationTicks != -1) { + moveAnimationTicks++; + if (moveAnimationTicks >= MOVE_ANIMATION_LENGTH) { + moveAnimationTicks = -1; + isCastlingAnimatingRookOnly = false; + } + } + + lastSeenMove = game.moveList.isEmpty() ? null : game.moveList.getLast(); + + if (!wasGameEnded && TwoPlayerGame.CHESS_TYPE.getActiveGame(game.opponent.getProfile().id()) != game) { + playSound(new ChessSound(GAME_END_SOUND, GAME_END_VOLUME, GAME_END_PITCH)); + resignButton.active = false; + drawOfferButton.active = false; + wasGameEnded = true; + } + + updateDrawOfferText(); + copyPgnButton.active = !game.moveList.isEmpty(); + } + + @Override + public void renderBackground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + super.renderBackground(graphics, mouseX, mouseY, partialTick); + + ChessGame displayingGame = selectedMove + 1 == game.moveList.size() ? game : game.previousGameStates.get(selectedMove + 1); + + int startX = (this.width - BOARD_SIZE) / 2; + int startY = (this.height - BOARD_SIZE) / 2; + + graphics.drawString(this.font, this.title, startX, startY - BOARD_EDGE_PADDING - font.lineHeight, 0xff_ffffff); + graphics.blit(RenderPipelines.GUI_TEXTURED, BOARD_TEXTURE, startX, startY, 0, 0, BOARD_SIZE, BOARD_SIZE, BOARD_SIZE, BOARD_SIZE); + + for (int i = 0; i < 8; i++) { + char rank = game.yourColor == ChessColor.WHITE ? (char) ('8' - i) : (char) ('1' + i); + char file = game.yourColor == ChessColor.WHITE ? (char) ('a' + i) : (char) ('h' - i); + + graphics.drawCenteredString(this.font, String.valueOf(file), startX + SQUARE_SIZE * i + SQUARE_SIZE / 2, startY - RANK_FILE_LABEL_PADDING - font.lineHeight, 0xff_ffffff); + graphics.drawCenteredString(this.font, String.valueOf(file), startX + SQUARE_SIZE * i + SQUARE_SIZE / 2, startY + BOARD_SIZE + RANK_FILE_LABEL_PADDING, 0xff_ffffff); + graphics.drawString(this.font, String.valueOf(rank), startX - RANK_FILE_LABEL_PADDING - font.width(String.valueOf(rank)), startY + SQUARE_SIZE * i + SQUARE_SIZE / 2 - font.lineHeight / 2, 0xff_ffffff); + graphics.drawString(this.font, String.valueOf(rank), startX + BOARD_SIZE + RANK_FILE_LABEL_PADDING, startY + SQUARE_SIZE * i + SQUARE_SIZE / 2 - font.lineHeight / 2, 0xff_ffffff); + } + + Set regularMoveSquares = new HashSet<>(); + Set captureMoveSquares = new HashSet<>(); + if (selectedSquare != null && game.colorToMove == game.yourColor) { + ChessPiece piece = game.getPiece(selectedSquare.x, selectedSquare.y); + if (piece != null) { + List moves = new ArrayList<>(); + piece.type().generateMoves(game, selectedSquare.x, selectedSquare.y, moves); + for (ChessMove move : moves) { + if (move.type().isLegalAccordingToCheck(game, move)) { + if (game.isCapture(move)) { + captureMoveSquares.add(move.to()); + } else { + regularMoveSquares.add(move.to()); + } + } + } + } + } + + boolean isCheck = displayingGame.isCheck(); + + // keep pieces on their initial squares pre-animation to stop them jumping to the end and then back to the start + boolean isPreAnimation = moveAnimationTicks == -1 && lastSeenMove != null && !game.moveList.isEmpty() && !lastSeenMove.equals(game.moveList.getLast()); + ChessMove lastMove = selectedMove == -1 ? null : game.moveList.get(selectedMove); + + for (int x = 0; x < 8; x++) { + for (int y = 0; y < 8; y++) { + ChessPiece piece = displayingGame.getPiece(x, y); + + Vector2i squareStartPos = squareToStartScreenPos(x, y); + int squareStartX = squareStartPos.x; + int squareStartY = squareStartPos.y; + + int textureStartX = squareStartX + PADDING; + int textureStartY = squareStartY + PADDING; + + if (selectedSquare != null && selectedSquare.x == x && selectedSquare.y == y) { + graphics.fill(squareStartX, squareStartY, squareStartX + SQUARE_SIZE, squareStartY + SQUARE_SIZE, SELECTED_HIGHLIGHT_COLOR); + } else if (regularMoveSquares.contains(new Vector2i(x, y))) { + graphics.blit(RenderPipelines.GUI_TEXTURED, SQUARE_HIGHLIGHT_TEXTURE, textureStartX, textureStartY, 0, 0, PIECE_SIZE, PIECE_SIZE, PIECE_SIZE, PIECE_SIZE); + } else if (captureMoveSquares.contains(new Vector2i(x, y))) { + graphics.blit(RenderPipelines.GUI_TEXTURED, CAPTURE_HIGHLIGHT_TEXTURE, textureStartX, textureStartY, 0, 0, PIECE_SIZE, PIECE_SIZE, PIECE_SIZE, PIECE_SIZE); + } else if (squareHighlights.containsKey(new Vector2i(x, y))) { + graphics.fill(squareStartX, squareStartY, squareStartX + SQUARE_SIZE, squareStartY + SQUARE_SIZE, squareHighlights.get(new Vector2i(x, y))); + } else if (lastMove != null && ((lastMove.from().x == x && lastMove.from().y == y) || (lastMove.to().x == x && lastMove.to().y == y))) { + graphics.fill(squareStartX, squareStartY, squareStartX + SQUARE_SIZE, squareStartY + SQUARE_SIZE, SELECTED_HIGHLIGHT_COLOR); + } + + boolean renderPiece = true; + if (dragging && selectedSquare != null && selectedSquare.x == x && selectedSquare.y == y) { + renderPiece = false; + } else if ((isPreAnimation || moveAnimationTicks != -1) && !game.moveList.isEmpty()) { + ChessMove animatingMove = game.moveList.getLast(); + if (!isCastlingAnimatingRookOnly && animatingMove.to().x == x && animatingMove.to().y == y) { + renderPiece = false; + } else if (animatingMove.type() == ChessMove.Type.CASTLE && animatingMove.to().y == y && Math.abs(animatingMove.to().x - x) == 1) { + renderPiece = false; + } + } + + if (renderPiece && piece != null) { + if (isCheck && piece.type() == ChessPieceType.KING && piece.color() == displayingGame.colorToMove) { + graphics.blit(RenderPipelines.GUI_TEXTURED, CHECK_TEXTURE, textureStartX, textureStartY, 0, 0, PIECE_SIZE, PIECE_SIZE, PIECE_SIZE, PIECE_SIZE); + } + + graphics.blit(RenderPipelines.GUI_TEXTURED, piece.texture, textureStartX, textureStartY, piece.u, piece.v, PIECE_SIZE, PIECE_SIZE, PIECE_SIZE, PIECE_SIZE); + } + } + } + + if ((isPreAnimation || moveAnimationTicks != -1) && !game.moveList.isEmpty()) { + ChessMove animatingMove = game.moveList.getLast(); + if (!isCastlingAnimatingRookOnly) { + ChessPiece piece = game.getPiece(animatingMove.to().x, animatingMove.to().y); + + if (piece != null) { + if (animatingMove.type().isPromotion()) { + piece = piece.color() == ChessColor.WHITE ? ChessPiece.WHITE_PAWN : ChessPiece.BLACK_PAWN; + } + + Vector2i fromPos = squareToStartScreenPos(animatingMove.from().x, animatingMove.from().y).add(PADDING, PADDING); + Vector2i toPos = squareToStartScreenPos(animatingMove.to().x, animatingMove.to().y).add(PADDING, PADDING); + Vector2i pos = interpolateMove(fromPos, toPos, (moveAnimationTicks + partialTick) / MOVE_ANIMATION_LENGTH); + graphics.blit(RenderPipelines.GUI_TEXTURED, piece.texture, pos.x, pos.y, piece.u, piece.v, PIECE_SIZE, PIECE_SIZE, PIECE_SIZE, PIECE_SIZE); + } + } + + if (animatingMove.type() == ChessMove.Type.CASTLE) { + // animate the rook too + int rookFromX, rookToX; + if (animatingMove.to().x > 4) { + rookFromX = 7; + rookToX = 5; + } else { + rookFromX = 0; + rookToX = 3; + } + + ChessPiece piece = animatingMove.to().y == 0 ? ChessPiece.WHITE_ROOK : ChessPiece.BLACK_ROOK; + Vector2i fromPos = squareToStartScreenPos(rookFromX, animatingMove.from().y).add(PADDING, PADDING); + Vector2i toPos = squareToStartScreenPos(rookToX, animatingMove.to().y).add(PADDING, PADDING); + Vector2i pos = interpolateMove(fromPos, toPos, (moveAnimationTicks + partialTick) / MOVE_ANIMATION_LENGTH); + graphics.blit(RenderPipelines.GUI_TEXTURED, piece.texture, pos.x, pos.y, piece.u, piece.v, PIECE_SIZE, PIECE_SIZE, PIECE_SIZE, PIECE_SIZE); + } + } + + if (dragging && selectedSquare != null) { + ChessPiece piece = game.getPiece(selectedSquare.x, selectedSquare.y); + if (piece != null) { + int textureStartX = mouseX - PIECE_SIZE / 2; + int textureStartY = mouseY - PIECE_SIZE / 2; + graphics.blit(RenderPipelines.GUI_TEXTURED, piece.texture, textureStartX, textureStartY, piece.u, piece.v, PIECE_SIZE, PIECE_SIZE, PIECE_SIZE, PIECE_SIZE); + } + } + + arrows.forEach((arrow, color) -> drawArrow(graphics, arrow, color)); + + if (promotionGuiOrigin != null) { + int guiOriginX = promotionGuiOrigin.x; + int guiOriginY = promotionGuiOrigin.y; + + if (guiOriginX + 2 * SQUARE_SIZE > width) { + guiOriginX -= 2 * SQUARE_SIZE; + } + if (guiOriginY + SQUARE_SIZE > height) { + guiOriginY -= 2 * SQUARE_SIZE; + } + + graphics.fill(guiOriginX, guiOriginY, guiOriginX + 2 * SQUARE_SIZE, guiOriginY + 2 * SQUARE_SIZE, CommonColors.WHITE); + if (mouseX >= guiOriginX && mouseY >= guiOriginY && mouseX < guiOriginX + 2 * SQUARE_SIZE && mouseY < guiOriginY + 2 * SQUARE_SIZE) { + int highlightX = (mouseX - guiOriginX) / SQUARE_SIZE * SQUARE_SIZE + guiOriginX; + int highlightY = (mouseY - guiOriginY) / SQUARE_SIZE * SQUARE_SIZE + guiOriginY; + graphics.fill(highlightX, highlightY, highlightX + SQUARE_SIZE, highlightY + SQUARE_SIZE, ARGB.opaque(SELECTED_HIGHLIGHT_COLOR)); + } + + for (int i = 0; i < 3; i++) { + graphics.hLine(guiOriginX, guiOriginX + 2 * SQUARE_SIZE, guiOriginY + i * SQUARE_SIZE, CommonColors.BLACK); + graphics.vLine(guiOriginX + i * SQUARE_SIZE, guiOriginY, guiOriginY + 2 * SQUARE_SIZE, CommonColors.BLACK); + } + + ChessPiece[] promotionPieces = game.yourColor == ChessColor.WHITE ? WHITE_PROMOTION_PIECES : BLACK_PROMOTION_PIECES; + for (int x = 0; x < 2; x++) { + for (int y = 0; y < 2; y++) { + ChessPiece promotionPiece = promotionPieces[x + y * 2]; + graphics.blit(RenderPipelines.GUI_TEXTURED, promotionPiece.texture, guiOriginX + x * SQUARE_SIZE + PADDING, guiOriginY + y * SQUARE_SIZE + PADDING, promotionPiece.u, promotionPiece.v, PIECE_SIZE, PIECE_SIZE, PIECE_SIZE, PIECE_SIZE); + } + } + } + } + + @Override + public boolean keyPressed(KeyEvent event) { + switch (event.key()) { + case InputConstants.KEY_LEFT -> selectMove(Math.max(-1, selectedMove - 1)); + case InputConstants.KEY_RIGHT -> selectMove(Math.min(game.moveList.size() - 1, selectedMove + 1)); + } + + return super.keyPressed(event); + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean isDoubleClick) { + if (promotionGuiOrigin != null) { + int guiOriginX = promotionGuiOrigin.x; + int guiOriginY = promotionGuiOrigin.y; + + if (guiOriginX + 2 * SQUARE_SIZE > width) { + guiOriginX -= 2 * SQUARE_SIZE; + } + if (guiOriginY + SQUARE_SIZE > height) { + guiOriginY -= 2 * SQUARE_SIZE; + } + + if (pendingPromotionMove != null && event.button() == InputConstants.MOUSE_BUTTON_LEFT && event.x() >= guiOriginX && event.y() >= guiOriginY && event.x() < guiOriginX + 2 * SQUARE_SIZE && event.y() < guiOriginY + 2 * SQUARE_SIZE) { + minecraft.getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1)); + int x = ((int) event.x() - guiOriginX) / SQUARE_SIZE; + int y = ((int) event.y() - guiOriginY) / SQUARE_SIZE; + doMove(new ChessMove(pendingPromotionMove.from(), pendingPromotionMove.to(), PROMOTION_MOVE_TYPES[x + y * 2]), true); + promotionGuiOrigin = null; + pendingPromotionMove = null; + return true; + } + + promotionGuiOrigin = null; + pendingPromotionMove = null; + } + + Vector2i square = screenPosToSquare((int) event.x(), (int) event.y()); + if (event.button() == InputConstants.MOUSE_BUTTON_LEFT && selectedMove + 1 == game.moveList.size()) { + isSecondClick = false; + if (square == null) { + selectedSquare = null; + } else { + ChessPiece piece = game.getPiece(square.x, square.y); + if (piece != null && piece.color() == game.yourColor) { + isSecondClick = square.equals(selectedSquare); + selectedSquare = square; + dragging = true; + } else if (selectedSquare != null) { + tryDoMove(square, (int) event.x(), (int) event.y(), true); + } + } + } else { + selectedSquare = null; + } + + if (event.button() == InputConstants.MOUSE_BUTTON_RIGHT) { + highlightStartSquare = square; + } else { + squareHighlights.clear(); + arrows.clear(); + highlightStartSquare = null; + } + + return super.mouseClicked(event, isDoubleClick); + } + + @Override + public boolean mouseReleased(MouseButtonEvent event) { + Vector2i square = screenPosToSquare((int) event.x(), (int) event.y()); + + switch (event.button()) { + case InputConstants.MOUSE_BUTTON_LEFT -> { + if (dragging) { + dragging = false; + if (square != null) { + if (!square.equals(selectedSquare)) { + tryDoMove(square, (int) event.x(), (int) event.y(), false); + } else if (isSecondClick) { + selectedSquare = null; + } + } + } + } + case InputConstants.MOUSE_BUTTON_RIGHT -> { + if (highlightStartSquare != null) { + if (square != null) { + boolean isArrow = !square.equals(highlightStartSquare); + + int color; + if (event.hasAltDown()) { + color = BLUE_HIGHLIGHT; + } else if (event.hasControlDown()) { + color = isArrow ? RED_HIGHLIGHT : ORANGE_HIGHLIGHT; + } else if (event.hasShiftDown()) { + color = GREEN_HIGHLIGHT; + } else { + color = isArrow ? ORANGE_HIGHLIGHT : RED_HIGHLIGHT; + } + + if (isArrow) { + arrows.merge(new Arrow(highlightStartSquare, square), color, (oldCol, col) -> col.equals(oldCol) ? null : col); + } else { + squareHighlights.merge(square, color, (oldCol, col) -> col.equals(oldCol) ? null : col); + } + } + highlightStartSquare = null; + } + } + } + + return super.mouseReleased(event); + } + + private void tryDoMove(Vector2i square, int mouseX, int mouseY, boolean animate) { + if (selectedSquare == null) { + return; + } + + if (game.colorToMove == game.yourColor && TwoPlayerGame.CHESS_TYPE.getActiveGame(game.opponent.getProfile().id()) == game) { + ChessPiece piece = game.getPiece(selectedSquare.x, selectedSquare.y); + + List moves = new ArrayList<>(); + if (piece != null) { + piece.type().generateMoves(game, selectedSquare.x, selectedSquare.y, moves); + } + + ChessMove legalMove = null; + for (ChessMove move : moves) { + if (move.to().equals(square) && move.type().isLegalAccordingToCheck(game, move)) { + legalMove = move; + break; + } + } + + if (legalMove != null) { + if (legalMove.type().isPromotion()) { + promotionGuiOrigin = new Vector2i(mouseX, mouseY); + pendingPromotionMove = legalMove; + } else { + doMove(legalMove, animate); + } + } + } + + selectedSquare = null; + } + + private void doMove(ChessMove legalMove, boolean animate) { + String moveNotation = game.getAlgebraicNotation(legalMove); + game.makeMove(legalMove); + lastSeenMove = legalMove; + + playSound(getMoveSound(legalMove)); + gameHistory.refresh(); + + if (animate) { + moveAnimationTicks = 0; + isCastlingAnimatingRookOnly = false; + } else if (legalMove.type() == ChessMove.Type.CASTLE) { + moveAnimationTicks = 0; + isCastlingAnimatingRookOnly = true; + } else { + moveAnimationTicks = -1; + isCastlingAnimatingRookOnly = false; + } + + try { + ClientPacketListener connection = Minecraft.getInstance().getConnection(); + assert connection != null; + GameProfile gameProfile = connection.getLocalGameProfile(); + C2CPacketHandler.getInstance().sendPacket(new ChessMoveC2CPacket(gameProfile.name(), gameProfile.id(), legalMove), game.opponent); + } catch (CommandSyntaxException e) { + ClientCommandHelper.sendError(Component.translationArg(e.getRawMessage())); + } + + ClientCommandHelper.sendFeedback(Component.translatable("chessGame.youMoved", moveNotation)); + + Component endCondition = game.detectEndCondition(); + if (endCondition != null) { + ClientCommandHelper.sendFeedback(endCondition); + TwoPlayerGame.CHESS_TYPE.removeActiveGame(game.opponent.getProfile().id()); + } + } + + private ChessSound getMoveSound(ChessMove move) { + if (game.isCheck()) { + return new ChessSound(CHECK_SOUND, CHECK_VOLUME, CHECK_PITCH); + } else if (move.type() == ChessMove.Type.CASTLE) { + return new ChessSound(CASTLING_SOUND, CASTLING_VOLUME, CASTLING_PITCH); + } else if (move.type() == ChessMove.Type.EN_PASSANT || game.getPieceInPreviousPosition(move.to().x, move.to().y) != null) { + return new ChessSound(CAPTURE_SOUND, CAPTURE_VOLUME, CAPTURE_PITCH); + } else { + return new ChessSound(MOVE_SOUND, MOVE_VOLUME, MOVE_PITCH); + } + } + + private static void playSound(ChessSound sound) { + Minecraft.getInstance().getSoundManager().play(new SimpleSoundInstance(sound.sound.value().location(), SoundSource.UI, sound.volume, sound.pitch, SoundInstance.createUnseededRandom(), false, 0, SoundInstance.Attenuation.NONE, 0, 0, 0, true)); + } + + private void selectMove(int moveNumber) { + selectedMove = moveNumber; + dragging = false; + selectedSquare = null; + moveAnimationTicks = -1; + isCastlingAnimatingRookOnly = false; + } + + @Nullable + private Vector2i screenPosToSquare(int x, int y) { + int startX = (this.width - BOARD_SIZE) / 2; + int startY = (this.height - BOARD_SIZE) / 2; + if (x < startX || x >= startX + BOARD_SIZE || y < startY || y >= startY + BOARD_SIZE) { + return null; + } + + if (game.yourColor == ChessColor.WHITE) { + return new Vector2i((x - startX) / SQUARE_SIZE, 7 - (y - startY) / SQUARE_SIZE); + } else { + return new Vector2i(7 - (x - startX) / SQUARE_SIZE, (y - startY) / SQUARE_SIZE); + } + } + + private Vector2i squareToStartScreenPos(int x, int y) { + int startX = (this.width - BOARD_SIZE) / 2; + int startY = (this.height - BOARD_SIZE) / 2; + if (game.yourColor == ChessColor.WHITE) { + return new Vector2i(startX + SQUARE_SIZE * x, startY + SQUARE_SIZE * (7 - y)); + } else { + return new Vector2i(startX + SQUARE_SIZE * (7 - x), startY + SQUARE_SIZE * y); + } + } + + private void drawArrow(GuiGraphics graphics, Arrow arrow, int color) { + Vector2i fromPos = squareToStartScreenPos(arrow.from.x, arrow.from.y); + Vector2i toPos = squareToStartScreenPos(arrow.to.x, arrow.to.y); + if (Math.abs(arrow.to.x - arrow.from.x) + Math.abs(arrow.to.y - arrow.from.y) == 3 && arrow.from.x != arrow.to.x && arrow.from.y != arrow.to.y) { + // special case for knight move arrows + graphics.pose().pushMatrix(); + graphics.pose().translate(fromPos.x + (float) SQUARE_SIZE / 2, fromPos.y + (float) SQUARE_SIZE / 2); + + if (Math.abs(arrow.to.y - arrow.from.y) == 2) { + if (toPos.y < fromPos.y) { + graphics.pose().rotate(-Mth.HALF_PI); + } else { + graphics.pose().rotate(Mth.HALF_PI); + } + } else { + if (toPos.x < fromPos.x) { + graphics.pose().rotate(Mth.PI); + } + } + + graphics.fill(SQUARE_SIZE / 4, -ARROW_HALF_THICKNESS, SQUARE_SIZE * 2 + ARROW_HALF_THICKNESS, ARROW_HALF_THICKNESS, color); + + int dx = arrow.to.x - arrow.from.x; + int dy = arrow.to.y - arrow.from.y; + if ((dx == 2 && dy == 1) || (dx == 1 && dy == -2) || (dx == -2 && dy == -1) || (dx == -1 && dy == 2)) { + graphics.fill(SQUARE_SIZE * 2 - ARROW_HALF_THICKNESS, -SQUARE_SIZE * 3 / 4, SQUARE_SIZE * 2 + ARROW_HALF_THICKNESS, -ARROW_HALF_THICKNESS, color); + drawTriangle(graphics, SQUARE_SIZE * 2, -SQUARE_SIZE * 3 / 4 - ARROW_HEAD_SIZE, SQUARE_SIZE * 2 - ARROW_HEAD_SIZE, -SQUARE_SIZE * 3 / 4, SQUARE_SIZE * 2 + ARROW_HEAD_SIZE, -SQUARE_SIZE * 3 / 4, color); + } else { + graphics.fill(SQUARE_SIZE * 2 - ARROW_HALF_THICKNESS, ARROW_HALF_THICKNESS, SQUARE_SIZE * 2 + ARROW_HALF_THICKNESS, SQUARE_SIZE * 3 / 4, color); + drawTriangle(graphics, SQUARE_SIZE * 2 - ARROW_HEAD_SIZE, SQUARE_SIZE * 3 / 4, SQUARE_SIZE * 2, SQUARE_SIZE * 3 / 4 + ARROW_HEAD_SIZE, SQUARE_SIZE * 2 + ARROW_HEAD_SIZE, SQUARE_SIZE * 3 / 4, color); + } + graphics.pose().popMatrix(); + } else { + graphics.pose().pushMatrix(); + graphics.pose().translate(fromPos.x + (float) SQUARE_SIZE / 2, fromPos.y + (float) SQUARE_SIZE / 2); + graphics.pose().rotate((float) Math.atan2(toPos.y - fromPos.y, toPos.x - fromPos.x)); + int arrowLength = (int) Math.hypot(toPos.x - fromPos.x, toPos.y - fromPos.y) - SQUARE_SIZE / 4; + graphics.fill(SQUARE_SIZE / 4, -ARROW_HALF_THICKNESS, arrowLength, ARROW_HALF_THICKNESS, color); + drawTriangle(graphics, arrowLength, -ARROW_HEAD_SIZE, arrowLength, ARROW_HEAD_SIZE, arrowLength + ARROW_HEAD_SIZE, 0, color); + graphics.pose().popMatrix(); + } + } + + private static void drawTriangle(GuiGraphics graphics, int x0, int y0, int x1, int y1, int x2, int y2, int color) { + graphics.guiRenderState.submitGuiElement(new ColoredTriangleRenderState(RenderPipelines.GUI, TextureSetup.noTexture(), new Matrix3x2f(graphics.pose()), x0, y0, x1, y1, x2, y2, color, graphics.scissorStack.peek())); + } + + private static Vector2i interpolateMove(Vector2i from, Vector2i to, float t) { + return new Vector2i((int) Mth.lerp(sigmoid(t), from.x, to.x), (int) Mth.lerp(sigmoid(t), from.y, to.y)); + } + + private static float sigmoid(float t) { + // https://www.desmos.com/calculator/3zhzwbfrxd + class Constants { + static final float P = 0.5f; + static final float S = 0.2f; + static final float C = (2 / (1 - S)) - 1; + static final float P_POW_C_MINUS_ONE = (float) Math.pow(P, C - 1); + static final float ONE_MINUS_P_POW_C_MINUS_ONE = (float) Math.pow(1 - P, C - 1); + } + + t = Mth.clamp(t, 0, 1); + + if (t <= Constants.P) { + return (float) Math.pow(t, Constants.C) / Constants.P_POW_C_MINUS_ONE; + } else { + return 1 - (float) Math.pow(1 - t, Constants.C) / Constants.ONE_MINUS_P_POW_C_MINUS_ONE; + } + } + + private record Arrow(Vector2i from, Vector2i to) { + } + + private record ChessSound(Holder sound, float volume, float pitch) { + ChessSound(SoundEvent sound, float volume, float pitch) { + this(Holder.direct(sound), volume, pitch); + } + } + + private final class GameHistoryWidget extends ContainerObjectSelectionList { + public GameHistoryWidget(Minecraft minecraft, int x, int y, int width, int height, int entryHeight) { + super(minecraft, width, height, y, entryHeight); + setX(x); + } + + @Override + protected void renderListBackground(GuiGraphics guiGraphics) { + } + + @Override + protected void renderListSeparators(GuiGraphics guiGraphics) { + } + + @Override + public int getRowLeft() { + return getX(); + } + + @Override + public int getRowWidth() { + return getWidth() - SCROLLBAR_WIDTH; + } + + @Override + protected int scrollBarX() { + return getRowRight(); + } + + void refresh() { + List newEntries = new ArrayList<>(game.algebraicMoveList.size() / 2); + + for (int i = 0; i < game.moveList.size(); i += 2) { + AbstractButton whiteMove = makeButton(game.algebraicMoveList.get(i), i); + AbstractButton blackMove = i + 1 < game.algebraicMoveList.size() ? makeButton(game.algebraicMoveList.get(i + 1), i + 1) : null; + newEntries.add(new Entry(whiteMove, blackMove)); + } + + replaceEntries(newEntries); + + setScrollAmount(maxScrollAmount()); + selectMove(game.moveList.size() - 1); + } + + private AbstractButton makeButton(String move, int moveNumber) { + return new MoveButton( + moveNumber, + (getRowWidth() - Entry.CONTENT_PADDING * 2) / 2, + 20, + Component.literal(move), + button -> selectMove(moveNumber) + ); + } + + private static final class Entry extends ContainerObjectSelectionList.Entry { + private final AbstractButton whiteMove; + @Nullable + private final AbstractButton blackMove; + + Entry(AbstractButton whiteMove, @Nullable AbstractButton blackMove) { + this.whiteMove = whiteMove; + this.blackMove = blackMove; + } + + @Override + public List narratables() { + if (blackMove == null) { + return List.of(whiteMove); + } else { + return List.of(whiteMove, blackMove); + } + } + + @Override + public void renderContent(GuiGraphics guiGraphics, int mouseX, int mouseY, boolean isHovering, float partialTick) { + whiteMove.setX(getContentX()); + whiteMove.setY(getContentY()); + whiteMove.render(guiGraphics, mouseX, mouseY, partialTick); + + if (blackMove != null) { + blackMove.setX(getContentX() + getContentWidth() / 2); + blackMove.setY(getContentY()); + blackMove.render(guiGraphics, mouseX, mouseY, partialTick); + } + } + + @Override + public List children() { + if (blackMove == null) { + return List.of(whiteMove); + } else { + return List.of(whiteMove, blackMove); + } + } + } + + private final class MoveButton extends Button { + private final int moveNumber; + + MoveButton(int moveNumber, int width, int height, Component message, OnPress onPress) { + super(0, 0, width, height, message, onPress, DEFAULT_NARRATION); + this.moveNumber = moveNumber; + } + + @Override + protected void renderContents(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + this.renderDefaultLabel(graphics.textRendererForWidget(this, GuiGraphics.HoveredTextEffects.NONE)); + if (selectedMove == moveNumber) { + graphics.hLine(getX(), getRight() - 1, getY(), CommonColors.WHITE); + graphics.hLine(getX(), getRight() - 1, getBottom() - 1, CommonColors.WHITE); + graphics.vLine(getX(), getY(), getBottom() - 1, CommonColors.WHITE); + graphics.vLine(getRight() - 1, getY(), getBottom() - 1, CommonColors.WHITE); + } + } + } + } +} diff --git a/src/main/java/net/earthcomputer/clientcommands/c2c/chess/package-info.java b/src/main/java/net/earthcomputer/clientcommands/c2c/chess/package-info.java new file mode 100644 index 000000000..2dfd78cb7 --- /dev/null +++ b/src/main/java/net/earthcomputer/clientcommands/c2c/chess/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package net.earthcomputer.clientcommands.c2c.chess; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/net/earthcomputer/clientcommands/c2c/packets/ChessDrawOfferC2CPacket.java b/src/main/java/net/earthcomputer/clientcommands/c2c/packets/ChessDrawOfferC2CPacket.java new file mode 100644 index 000000000..5a1512e04 --- /dev/null +++ b/src/main/java/net/earthcomputer/clientcommands/c2c/packets/ChessDrawOfferC2CPacket.java @@ -0,0 +1,48 @@ +package net.earthcomputer.clientcommands.c2c.packets; + +import io.netty.buffer.ByteBuf; +import net.earthcomputer.clientcommands.c2c.C2CFriendlyByteBuf; +import net.earthcomputer.clientcommands.c2c.C2CPacket; +import net.earthcomputer.clientcommands.c2c.C2CPacketListener; +import net.earthcomputer.clientcommands.c2c.chess.ChessMove; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.PacketFlow; +import net.minecraft.network.protocol.PacketType; +import net.minecraft.resources.Identifier; +import net.minecraft.util.ByIdMap; +import org.jspecify.annotations.Nullable; + +import java.util.UUID; +import java.util.function.IntFunction; + +public record ChessDrawOfferC2CPacket(@Nullable String sender, @Nullable UUID senderUUID, Operation operation) implements C2CPacket { + public static final StreamCodec CODEC = Packet.codec(ChessDrawOfferC2CPacket::write, ChessDrawOfferC2CPacket::new); + public static final PacketType ID = new PacketType<>(PacketFlow.CLIENTBOUND, Identifier.fromNamespaceAndPath("clientcommands", "draw_offer")); + + private ChessDrawOfferC2CPacket(C2CFriendlyByteBuf buf) { + this(buf.getSender(), buf.getSenderUUID(), Operation.STREAM_CODEC.decode(buf)); + } + + @Override + public PacketType> type() { + return ID; + } + + private void write(C2CFriendlyByteBuf buf) { + Operation.STREAM_CODEC.encode(buf, operation); + } + + @Override + public void handle(C2CPacketListener handler) { + handler.onChessDrawOfferPacket(this); + } + + public enum Operation { + OFFER, RETRACT, ACCEPT; + + private static final IntFunction BY_ID = ByIdMap.continuous(Operation::ordinal, values(), ByIdMap.OutOfBoundsStrategy.ZERO); + public static final StreamCodec STREAM_CODEC = ByteBufCodecs.idMapper(BY_ID, Operation::ordinal); + } +} diff --git a/src/main/java/net/earthcomputer/clientcommands/c2c/packets/ChessMoveC2CPacket.java b/src/main/java/net/earthcomputer/clientcommands/c2c/packets/ChessMoveC2CPacket.java new file mode 100644 index 000000000..d7af277f6 --- /dev/null +++ b/src/main/java/net/earthcomputer/clientcommands/c2c/packets/ChessMoveC2CPacket.java @@ -0,0 +1,37 @@ +package net.earthcomputer.clientcommands.c2c.packets; + +import net.earthcomputer.clientcommands.c2c.C2CFriendlyByteBuf; +import net.earthcomputer.clientcommands.c2c.C2CPacket; +import net.earthcomputer.clientcommands.c2c.C2CPacketListener; +import net.earthcomputer.clientcommands.c2c.chess.ChessMove; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.PacketFlow; +import net.minecraft.network.protocol.PacketType; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +public record ChessMoveC2CPacket(@Nullable String sender, @Nullable UUID senderUUID, ChessMove move) implements C2CPacket { + public static final StreamCodec CODEC = Packet.codec(ChessMoveC2CPacket::write, ChessMoveC2CPacket::new); + public static final PacketType ID = new PacketType<>(PacketFlow.CLIENTBOUND, Identifier.fromNamespaceAndPath("clientcommands", "chess_move")); + + private ChessMoveC2CPacket(C2CFriendlyByteBuf buf) { + this(buf.getSender(), buf.getSenderUUID(), ChessMove.STREAM_CODEC.decode(buf)); + } + + @Override + public PacketType> type() { + return ID; + } + + private void write(C2CFriendlyByteBuf buf) { + ChessMove.STREAM_CODEC.encode(buf, move); + } + + @Override + public void handle(C2CPacketListener handler) { + handler.onChessMovePacket(this); + } +} diff --git a/src/main/java/net/earthcomputer/clientcommands/c2c/packets/ChessResignC2CPacket.java b/src/main/java/net/earthcomputer/clientcommands/c2c/packets/ChessResignC2CPacket.java new file mode 100644 index 000000000..9918a0294 --- /dev/null +++ b/src/main/java/net/earthcomputer/clientcommands/c2c/packets/ChessResignC2CPacket.java @@ -0,0 +1,32 @@ +package net.earthcomputer.clientcommands.c2c.packets; + +import net.earthcomputer.clientcommands.c2c.C2CFriendlyByteBuf; +import net.earthcomputer.clientcommands.c2c.C2CPacket; +import net.earthcomputer.clientcommands.c2c.C2CPacketListener; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.PacketFlow; +import net.minecraft.network.protocol.PacketType; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +public record ChessResignC2CPacket(@Nullable String sender, @Nullable UUID senderUUID) implements C2CPacket { + public static final StreamCodec CODEC = Packet.codec((packet, buf) -> {}, ChessResignC2CPacket::new); + public static final PacketType ID = new PacketType<>(PacketFlow.CLIENTBOUND, Identifier.fromNamespaceAndPath("clientcommands", "chess_resign")); + + private ChessResignC2CPacket(C2CFriendlyByteBuf buf) { + this(buf.getSender(), buf.getSenderUUID()); + } + + @Override + public PacketType> type() { + return ID; + } + + @Override + public void handle(C2CPacketListener handler) { + handler.onChessResignPacket(this); + } +} diff --git a/src/main/java/net/earthcomputer/clientcommands/c2c/packets/StartTwoPlayerGameC2CPacket.java b/src/main/java/net/earthcomputer/clientcommands/c2c/packets/StartTwoPlayerGameC2CPacket.java index bbb880d34..abcc15f53 100644 --- a/src/main/java/net/earthcomputer/clientcommands/c2c/packets/StartTwoPlayerGameC2CPacket.java +++ b/src/main/java/net/earthcomputer/clientcommands/c2c/packets/StartTwoPlayerGameC2CPacket.java @@ -18,7 +18,7 @@ public record StartTwoPlayerGameC2CPacket(@Nullable String sender, @Nullable UUI public static final PacketType ID = new PacketType<>(PacketFlow.CLIENTBOUND, Identifier.fromNamespaceAndPath("clientcommands", "start_two_player_game")); public StartTwoPlayerGameC2CPacket(C2CFriendlyByteBuf buf) { - this(buf.getSender(), buf.getSenderUUID(), buf.readBoolean(), getById(buf.readIdentifier())); + this(buf.getSender(), buf.getSenderUUID(), buf.readBoolean(), TwoPlayerGame.getByIdOrThrow(buf.readIdentifier())); } public void write(C2CFriendlyByteBuf buf) { @@ -35,12 +35,4 @@ public void handle(C2CPacketListener handler) { public PacketType> type() { return ID; } - - private static TwoPlayerGame getById(Identifier id) { - TwoPlayerGame game = TwoPlayerGame.getById(id); - if (game == null) { - throw new IllegalStateException("Unknown game type " + id); - } - return game; - } } diff --git a/src/main/java/net/earthcomputer/clientcommands/c2c/packets/StopTwoPlayerGameC2CPacket.java b/src/main/java/net/earthcomputer/clientcommands/c2c/packets/StopTwoPlayerGameC2CPacket.java new file mode 100644 index 000000000..350fad673 --- /dev/null +++ b/src/main/java/net/earthcomputer/clientcommands/c2c/packets/StopTwoPlayerGameC2CPacket.java @@ -0,0 +1,37 @@ +package net.earthcomputer.clientcommands.c2c.packets; + +import net.earthcomputer.clientcommands.c2c.C2CFriendlyByteBuf; +import net.earthcomputer.clientcommands.c2c.C2CPacket; +import net.earthcomputer.clientcommands.c2c.C2CPacketListener; +import net.earthcomputer.clientcommands.features.TwoPlayerGame; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.PacketFlow; +import net.minecraft.network.protocol.PacketType; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +public record StopTwoPlayerGameC2CPacket(@Nullable String sender, @Nullable UUID senderUUID, TwoPlayerGame game) implements C2CPacket { + public static final StreamCodec CODEC = Packet.codec(StopTwoPlayerGameC2CPacket::write, StopTwoPlayerGameC2CPacket::new); + public static final PacketType ID = new PacketType<>(PacketFlow.CLIENTBOUND, Identifier.fromNamespaceAndPath("clientcommands", "stop_two_player_game")); + + private StopTwoPlayerGameC2CPacket(C2CFriendlyByteBuf buf) { + this(buf.getSender(), buf.getSenderUUID(), TwoPlayerGame.getByIdOrThrow(buf.readIdentifier())); + } + + @Override + public PacketType> type() { + return ID; + } + + public void write(C2CFriendlyByteBuf buf) { + buf.writeIdentifier(game.getId()); + } + + @Override + public void handle(C2CPacketListener handler) { + handler.onStopTwoPlayerGameC2CPacket(this); + } +} diff --git a/src/main/java/net/earthcomputer/clientcommands/command/ChessCommand.java b/src/main/java/net/earthcomputer/clientcommands/command/ChessCommand.java new file mode 100644 index 000000000..9c1c69b98 --- /dev/null +++ b/src/main/java/net/earthcomputer/clientcommands/command/ChessCommand.java @@ -0,0 +1,11 @@ +package net.earthcomputer.clientcommands.command; + +import com.mojang.brigadier.CommandDispatcher; +import net.earthcomputer.clientcommands.features.TwoPlayerGame; +import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; + +public class ChessCommand { + public static void register(CommandDispatcher dispatcher) { + dispatcher.register(TwoPlayerGame.CHESS_TYPE.createCommandTree()); + } +} diff --git a/src/main/java/net/earthcomputer/clientcommands/features/TwoPlayerGame.java b/src/main/java/net/earthcomputer/clientcommands/features/TwoPlayerGame.java index 0db132eb0..a66efec2e 100644 --- a/src/main/java/net/earthcomputer/clientcommands/features/TwoPlayerGame.java +++ b/src/main/java/net/earthcomputer/clientcommands/features/TwoPlayerGame.java @@ -8,7 +8,11 @@ import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; import net.earthcomputer.clientcommands.c2c.C2CPacketHandler; +import net.earthcomputer.clientcommands.c2c.chess.ChessGame; +import net.earthcomputer.clientcommands.c2c.chess.ChessScreen; +import net.earthcomputer.clientcommands.c2c.chess.ChessColor; import net.earthcomputer.clientcommands.c2c.packets.StartTwoPlayerGameC2CPacket; +import net.earthcomputer.clientcommands.c2c.packets.StopTwoPlayerGameC2CPacket; import net.earthcomputer.clientcommands.command.ClientCommandHelper; import net.earthcomputer.clientcommands.command.ConnectFourCommand; import net.earthcomputer.clientcommands.command.TicTacToeCommand; @@ -21,15 +25,20 @@ import net.minecraft.client.multiplayer.ClientPacketListener; import net.minecraft.client.multiplayer.PlayerInfo; import net.minecraft.commands.SharedSuggestionProvider; -import net.minecraft.network.chat.ClickEvent; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.HoverEvent; import net.minecraft.network.chat.MutableComponent; import net.minecraft.resources.Identifier; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import java.time.Duration; +import java.util.AbstractMap; +import java.util.AbstractSet; import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; @@ -44,9 +53,11 @@ public class TwoPlayerGame { public static final Map> TYPE_BY_NAME = new LinkedHashMap<>(); private static final SimpleCommandExceptionType PLAYER_NOT_FOUND_EXCEPTION = new SimpleCommandExceptionType(Component.translatable("twoPlayerGame.playerNotFound")); private static final SimpleCommandExceptionType NO_GAME_WITH_PLAYER_EXCEPTION = new SimpleCommandExceptionType(Component.translatable("twoPlayerGame.noGameWithPlayer")); + private static final SimpleCommandExceptionType ALREADY_IN_GAME_EXCEPTION = new SimpleCommandExceptionType(Component.translatable("twoPlayerGame.alreadyInGame")); public static final TwoPlayerGame TIC_TAC_TOE_GAME_TYPE = register(new TwoPlayerGame<>("commands.ctictactoe.name", "ctictactoe", Identifier.fromNamespaceAndPath("clientcommands", "tictactoe"), (opponent, firstPlayer) -> new TicTacToeCommand.TicTacToeGame(opponent, firstPlayer ? TicTacToeCommand.TicTacToeGame.Mark.CROSS : TicTacToeCommand.TicTacToeGame.Mark.NOUGHT), TicTacToeCommand.TicTacToeGameScreen::new)); - public static final TwoPlayerGame CONNECT_FOUR_GAME_TYPE = register(new TwoPlayerGame<>("commands.cconnectfour.name", "cconnectfour", Identifier.fromNamespaceAndPath("clientcommands", "connectfour"), (opponent, firstPlayer) -> new ConnectFourCommand.ConnectFourGame(opponent, firstPlayer ? ConnectFourCommand.Piece.RED : ConnectFourCommand.Piece.YELLOW), ConnectFourCommand.ConnectFourGameScreen::new)); + public static final TwoPlayerGame CONNECT_FOUR_GAME_TYPE = register(new TwoPlayerGame<>("commands.cconnectfour.name", "cconnectfour", Identifier.fromNamespaceAndPath("clientcommands", "connectfour"), (opponent, firstPlayer) -> new ConnectFourCommand.ConnectFourGame(opponent, firstPlayer ? ConnectFourCommand.Piece.RED : ConnectFourCommand.Piece.YELLOW), ConnectFourCommand.ConnectFourGameScreen::new)); + public static final TwoPlayerGame CHESS_TYPE = register(new TwoPlayerGame<>("commands.cchess.name", "cchess", Identifier.fromNamespaceAndPath("clientcommands", "chess"), (opponent, firstPlayer) -> new ChessGame(opponent, firstPlayer ? ChessColor.WHITE : ChessColor.BLACK), ChessScreen::new)); private final Component translation; private final String command; @@ -61,7 +72,35 @@ public class TwoPlayerGame { this.command = command; this.id = id; this.pendingInvites = Collections.newSetFromMap(CacheBuilder.newBuilder().expireAfterWrite(Duration.ofMinutes(5)).build().asMap()); - this.activeGames = CacheBuilder.newBuilder().expireAfterWrite(Duration.ofMinutes(15)).build().asMap(); + this.activeGames = new AbstractMap<>() { + private final Map map = new HashMap<>(); + + @Override + public Set> entrySet() { + return map.entrySet(); + } + + @Override + public void clear() { + Thread.dumpStack(); + map.clear(); + } + + @Override + public T put(UUID key, T value) { + T old = map.put(key, value); + if (old != null) { + Thread.dumpStack(); + } + return old; + } + + @Override + public T remove(Object key) { + Thread.dumpStack(); + return map.remove(key); + } + }; this.gameFactory = gameFactory; this.screenFactory = screenFactory; } @@ -76,6 +115,14 @@ private static TwoPlayerGame register(TwoPlayerGame< return TYPE_BY_NAME.get(id); } + public static TwoPlayerGame getByIdOrThrow(Identifier id) { + TwoPlayerGame game = getById(id); + if (game == null) { + throw new IllegalStateException("Unknown game type " + id); + } + return game; + } + public static void onPlayerLeave(UUID opponentUUID) { for (TwoPlayerGame game : TYPE_BY_NAME.values()) { game.activeGames.remove(opponentUUID); @@ -108,8 +155,9 @@ public Map getActiveGames() { return this.activeGames; } + @Contract("null -> null") @Nullable - public T getActiveGame(UUID opponent) { + public T getActiveGame(@Nullable UUID opponent) { return this.activeGames.get(opponent); } @@ -129,19 +177,27 @@ public LiteralArgumentBuilder createCommandTree() { .then(literal("start") .then(argument("opponent", gameProfile(true)) .executes(ctx -> this.start(ctx.getSource(), getSingleProfileArgument(ctx, "opponent"))))) + .then(literal("stop") + .then(argument("opponent", word()) + .suggests((ctx, builder) -> SharedSuggestionProvider.suggest(this.getActiveGames().keySet().stream().flatMap(uuid -> Stream.ofNullable(connection.getPlayerInfo(uuid))).map(info -> info.getProfile().name()), builder)) + .executes(ctx -> this.stop(ctx.getSource(), getString(ctx, "opponent"))))) .then(literal("open") .then(argument("opponent", word()) .suggests((ctx, builder) -> SharedSuggestionProvider.suggest(this.getActiveGames().keySet().stream().flatMap(uuid -> Stream.ofNullable(connection.getPlayerInfo(uuid))).map(info -> info.getProfile().name()), builder)) .executes(ctx -> this.open(ctx.getSource(), getString(ctx, "opponent"))))); } - public int start(FabricClientCommandSource source, GameProfile player) throws CommandSyntaxException { + private int start(FabricClientCommandSource source, GameProfile player) throws CommandSyntaxException { PlayerInfo recipient = source.getClient().getConnection().getPlayerInfo(player.id()); if (recipient == null) { throw PLAYER_NOT_FOUND_EXCEPTION.create(); } - StartTwoPlayerGameC2CPacket packet = new StartTwoPlayerGameC2CPacket(player.name(), player.id(), false, this); + if (activeGames.containsKey(player.id())) { + throw ALREADY_IN_GAME_EXCEPTION.create(); + } + + StartTwoPlayerGameC2CPacket packet = new StartTwoPlayerGameC2CPacket(Minecraft.getInstance().getGameProfile().name(), Minecraft.getInstance().getGameProfile().id(), false, this); C2CPacketHandler.getInstance().sendPacket(packet, recipient); this.pendingInvites.add(player.id()); this.activeGames.remove(player.id()); @@ -149,7 +205,23 @@ public int start(FabricClientCommandSource source, GameProfile player) throws Co return Command.SINGLE_SUCCESS; } - public int open(FabricClientCommandSource source, String name) throws CommandSyntaxException { + private int stop(FabricClientCommandSource source, String name) throws CommandSyntaxException { + PlayerInfo opponent = source.getClient().getConnection().getPlayerInfo(name); + if (opponent == null) { + throw PLAYER_NOT_FOUND_EXCEPTION.create(); + } + + if (this.activeGames.remove(opponent.getProfile().id()) == null) { + throw NO_GAME_WITH_PLAYER_EXCEPTION.create(); + } + + StopTwoPlayerGameC2CPacket packet = new StopTwoPlayerGameC2CPacket(Minecraft.getInstance().getGameProfile().name(), Minecraft.getInstance().getGameProfile().id(), this); + C2CPacketHandler.getInstance().sendPacket(packet, opponent); + source.sendFeedback(Component.translatable("twoPlayerGame.stopped", translate(), name)); + return Command.SINGLE_SUCCESS; + } + + private int open(FabricClientCommandSource source, String name) throws CommandSyntaxException { PlayerInfo opponent = source.getClient().getConnection().getPlayerInfo(name); if (opponent == null) { throw PLAYER_NOT_FOUND_EXCEPTION.create(); @@ -184,12 +256,7 @@ public static void onStartTwoPlayerGame(StartTwoPlayerGameC2CPacket packet) { if (packet.accept() && game.getPendingInvites().remove(opponent.getProfile().id())) { packet.game().addNewGame(opponent, true); - MutableComponent clickable = Component.translatable("twoPlayerGame.clickToMakeYourMove"); - clickable.withStyle(style -> style - .withUnderlined(true) - .withColor(ChatFormatting.GREEN) - .withClickEvent(new ClickEvent.RunCommand("/" + game.command + " open " + sender)) - .withHoverEvent(new HoverEvent.ShowText(Component.literal("/" + game.command + " open " + sender)))); + Component clickable = CComponentUtil.getCommandTextComponent("twoPlayerGame.clickToMakeYourMove", "/" + game.command + " open " + sender); ClientCommandHelper.sendFeedback(Component.translatable("c2cpacket.startTwoPlayerGameC2CPacket.incoming.accepted", sender, game.translate()).append(" [").append(clickable).append("]")); } else { game.getActiveGames().remove(opponent.getProfile().id()); @@ -216,6 +283,13 @@ public static void onStartTwoPlayerGame(StartTwoPlayerGameC2CPacket packet) { } } + public static void onStopTwoPlayerGame(StopTwoPlayerGameC2CPacket packet) { + if (packet.senderUUID() != null) { + packet.game().removeActiveGame(packet.senderUUID()); + ClientCommandHelper.sendFeedback("c2cpacket.stopTwoPlayerGameC2CPacket.incoming", Component.nullToEmpty(packet.sender()), packet.game().translate()); + } + } + public void onWon(String sender, UUID senderUUID) { ClientCommandHelper.sendFeedback("twoPlayerGame.chat.won", translate(), sender); removeActiveGame(senderUUID); @@ -232,12 +306,7 @@ public void onLost(String sender, UUID senderUUID) { } public void onMove(String sender) { - MutableComponent clickable = Component.translatable("twoPlayerGame.clickToMakeYourMove"); - clickable.withStyle(style -> style - .withColor(ChatFormatting.GREEN) - .withUnderlined(true) - .withClickEvent(new ClickEvent.RunCommand("/" + command + " open " + sender)) - .withHoverEvent(new HoverEvent.ShowText(Component.literal("/" + command + " open " + sender)))); + Component clickable = CComponentUtil.getCommandTextComponent("twoPlayerGame.clickToMakeYourMove", "/" + command + " open " + sender); ClientCommandHelper.sendFeedback(Component.translatable("twoPlayerGame.incoming", sender, translate()).append(" [").append(clickable).append("]")); } diff --git a/src/main/java/net/earthcomputer/clientcommands/render/ColoredTriangleRenderState.java b/src/main/java/net/earthcomputer/clientcommands/render/ColoredTriangleRenderState.java new file mode 100644 index 000000000..2ba11e6b5 --- /dev/null +++ b/src/main/java/net/earthcomputer/clientcommands/render/ColoredTriangleRenderState.java @@ -0,0 +1,58 @@ +package net.earthcomputer.clientcommands.render; + +import com.mojang.blaze3d.pipeline.RenderPipeline; +import com.mojang.blaze3d.vertex.VertexConsumer; +import net.minecraft.client.gui.navigation.ScreenRectangle; +import net.minecraft.client.gui.render.TextureSetup; +import net.minecraft.client.gui.render.state.GuiElementRenderState; +import org.joml.Matrix3x2fc; +import org.jspecify.annotations.Nullable; + +public record ColoredTriangleRenderState( + RenderPipeline pipeline, + TextureSetup textureSetup, + Matrix3x2fc pose, + int x0, + int y0, + int x1, + int y1, + int x2, + int y2, + int color, + @Nullable ScreenRectangle scissorArea, + @Nullable ScreenRectangle bounds +) implements GuiElementRenderState { + public ColoredTriangleRenderState( + RenderPipeline pipeline, + TextureSetup textureSetup, + Matrix3x2fc pose, + int x0, + int y0, + int x1, + int y1, + int x2, + int y2, + int color, + @Nullable ScreenRectangle scissorArea + ) { + this(pipeline, textureSetup, pose, x0, y0, x1, y1, x2, y2, color, scissorArea, getBounds(x0, y0, x1, y1, x2, y2, pose, scissorArea)); + } + + @Override + public void buildVertices(VertexConsumer consumer) { + consumer.addVertexWith2DPose(pose, x0, y0).setColor(color); + consumer.addVertexWith2DPose(pose, x1, y1).setColor(color); + consumer.addVertexWith2DPose(pose, x1, y1).setColor(color); + consumer.addVertexWith2DPose(pose, x2, y2).setColor(color); + } + + @Nullable + private static ScreenRectangle getBounds(int x0, int y0, int x1, int y1, int x2, int y2, Matrix3x2fc pose, @Nullable ScreenRectangle scissorArea) { + int minX = Math.min(x0, Math.min(x1, x2)); + int minY = Math.min(y0, Math.min(y1, y2)); + int maxX = Math.max(x0, Math.max(x1, x2)); + int maxY = Math.max(y0, Math.max(y1, y2)); + ScreenRectangle bounds = new ScreenRectangle(minX, minY, maxX - minX, maxY - minY).transformMaxBounds(pose); + return scissorArea != null ? scissorArea.intersection(bounds) : bounds; + } +} diff --git a/src/main/java/net/earthcomputer/clientcommands/util/CComponentUtil.java b/src/main/java/net/earthcomputer/clientcommands/util/CComponentUtil.java index d7f22dcf8..5d87057c1 100644 --- a/src/main/java/net/earthcomputer/clientcommands/util/CComponentUtil.java +++ b/src/main/java/net/earthcomputer/clientcommands/util/CComponentUtil.java @@ -83,6 +83,7 @@ public static Component getCommandTextComponent(@Translatable String translation public static Component getCommandTextComponent(MutableComponent component, String command) { return component.withStyle(style -> style.applyFormat(ChatFormatting.UNDERLINE) + .withColor(ChatFormatting.GREEN) .withClickEvent(new ClickEvent.RunCommand(command)) .withHoverEvent(new HoverEvent.ShowText(Component.literal(command)))); } diff --git a/src/main/resources/assets/clientcommands/lang/en_us.json b/src/main/resources/assets/clientcommands/lang/en_us.json index 943ba578d..a3d9fd084 100644 --- a/src/main/resources/assets/clientcommands/lang/en_us.json +++ b/src/main/resources/assets/clientcommands/lang/en_us.json @@ -13,6 +13,31 @@ "c2cpacket.startTwoPlayerGameC2CPacket.incoming.accepted": "%s has accepted your invitation to %s", "c2cpacket.startTwoPlayerGameC2CPacket.outgoing.accept": "Accepted the invitation, your opponent will go first", "c2cpacket.startTwoPlayerGameC2CPacket.outgoing.invited": "You invited %s to a game of %s", + "c2cpacket.stopTwoPlayerGameC2CPacket.incoming": "%s has stopped the game of %s with you", + + "chessGame.acceptDraw": "Accept draw", + "chessGame.checkmate.lost": "Checkmate! You lost", + "chessGame.checkmate.won": "Checkmate! You win!", + "chessGame.copyFen": "Copy FEN", + "chessGame.copyFen.success": "Copied FEN: %s", + "chessGame.copyPgn": "Copy PGN", + "chessGame.copyPgn.success": "Copied PGN: %s", + "chessGame.draw.agreement": "The game ended in a draw by agreement", + "chessGame.draw.fiftyMoves": "The game ended in a draw by the fifty move rule", + "chessGame.draw.insufficientMaterial": "The game ended in a draw by insufficient material", + "chessGame.draw.repetition": "The game ended in a draw by repetition", + "chessGame.draw.stalemate": "Stalemate! The game ended in a draw", + "chessGame.drawOfferAccepted": "%s accepted your draw offer", + "chessGame.drawOffered": "%s offered a draw", + "chessGame.drawOfferRetracted": "%s retracted their draw offer", + "chessGame.offerDraw": "Offer draw", + "chessGame.opponentMoved": "%s has played %s. %s", + "chessGame.opponentResigned": "%s resigned, you win!", + "chessGame.resign": "Resign", + "chessGame.retractDrawOffer": "Retract draw offer", + "chessGame.title": "Chess against %s", + "chessGame.youMoved": "You played %s", + "chessGame.youResigned": "You resigned", "chorusManip.goalTooFar": "Goal is too far away!", "chorusManip.landing.failed": "Landing manipulation not possible", @@ -64,6 +89,8 @@ "commands.ccallback.failed": "Unknown or expired callback", + "commands.cchess.name": "Chess", + "commands.cconnectfour.name": "Connect Four", "commands.ccrackrng.failed": "Failed to crack player seed", @@ -410,11 +437,13 @@ "ticTacToeGame.playingWith": "You are playing with %s", "ticTacToeGame.title": "Tic-tac-toe against %s", + "twoPlayerGame.alreadyInGame": "You are already in a game with that player", "twoPlayerGame.chat.draw": "You've made a move and drawn against %s in %s", "twoPlayerGame.chat.lost": "%s has made a move in %s and won", "twoPlayerGame.chat.won": "You made a move in %s and beat %s!", "twoPlayerGame.clickToMakeYourMove": "Click here to make your move", "twoPlayerGame.incoming": "%s has made a move in %s", "twoPlayerGame.noGameWithPlayer": "Currently not playing a game with that player", - "twoPlayerGame.playerNotFound": "Player not found" + "twoPlayerGame.playerNotFound": "Player not found", + "twoPlayerGame.stopped": "You stopped playing the game of %s with %s" } diff --git a/src/main/resources/assets/clientcommands/textures/chess/black_bishop.png b/src/main/resources/assets/clientcommands/textures/chess/black_bishop.png new file mode 100644 index 000000000..d1637f52d Binary files /dev/null and b/src/main/resources/assets/clientcommands/textures/chess/black_bishop.png differ diff --git a/src/main/resources/assets/clientcommands/textures/chess/black_king.png b/src/main/resources/assets/clientcommands/textures/chess/black_king.png new file mode 100644 index 000000000..c7b65b828 Binary files /dev/null and b/src/main/resources/assets/clientcommands/textures/chess/black_king.png differ diff --git a/src/main/resources/assets/clientcommands/textures/chess/black_knight.png b/src/main/resources/assets/clientcommands/textures/chess/black_knight.png new file mode 100644 index 000000000..306db3687 Binary files /dev/null and b/src/main/resources/assets/clientcommands/textures/chess/black_knight.png differ diff --git a/src/main/resources/assets/clientcommands/textures/chess/black_pawn.png b/src/main/resources/assets/clientcommands/textures/chess/black_pawn.png new file mode 100644 index 000000000..67e16a3b1 Binary files /dev/null and b/src/main/resources/assets/clientcommands/textures/chess/black_pawn.png differ diff --git a/src/main/resources/assets/clientcommands/textures/chess/black_queen.png b/src/main/resources/assets/clientcommands/textures/chess/black_queen.png new file mode 100644 index 000000000..174ac0dfc Binary files /dev/null and b/src/main/resources/assets/clientcommands/textures/chess/black_queen.png differ diff --git a/src/main/resources/assets/clientcommands/textures/chess/black_rook.png b/src/main/resources/assets/clientcommands/textures/chess/black_rook.png new file mode 100644 index 000000000..1620be4af Binary files /dev/null and b/src/main/resources/assets/clientcommands/textures/chess/black_rook.png differ diff --git a/src/main/resources/assets/clientcommands/textures/chess/board.png b/src/main/resources/assets/clientcommands/textures/chess/board.png new file mode 100644 index 000000000..b655a89b3 Binary files /dev/null and b/src/main/resources/assets/clientcommands/textures/chess/board.png differ diff --git a/src/main/resources/assets/clientcommands/textures/chess/capture_highlight.png b/src/main/resources/assets/clientcommands/textures/chess/capture_highlight.png new file mode 100644 index 000000000..607fa8a0e Binary files /dev/null and b/src/main/resources/assets/clientcommands/textures/chess/capture_highlight.png differ diff --git a/src/main/resources/assets/clientcommands/textures/chess/check.png b/src/main/resources/assets/clientcommands/textures/chess/check.png new file mode 100644 index 000000000..eef9fef4e Binary files /dev/null and b/src/main/resources/assets/clientcommands/textures/chess/check.png differ diff --git a/src/main/resources/assets/clientcommands/textures/chess/square_highlight.png b/src/main/resources/assets/clientcommands/textures/chess/square_highlight.png new file mode 100644 index 000000000..e26a86841 Binary files /dev/null and b/src/main/resources/assets/clientcommands/textures/chess/square_highlight.png differ diff --git a/src/main/resources/assets/clientcommands/textures/chess/white_bishop.png b/src/main/resources/assets/clientcommands/textures/chess/white_bishop.png new file mode 100644 index 000000000..cf89c9e98 Binary files /dev/null and b/src/main/resources/assets/clientcommands/textures/chess/white_bishop.png differ diff --git a/src/main/resources/assets/clientcommands/textures/chess/white_king.png b/src/main/resources/assets/clientcommands/textures/chess/white_king.png new file mode 100644 index 000000000..0f189a8d3 Binary files /dev/null and b/src/main/resources/assets/clientcommands/textures/chess/white_king.png differ diff --git a/src/main/resources/assets/clientcommands/textures/chess/white_knight.png b/src/main/resources/assets/clientcommands/textures/chess/white_knight.png new file mode 100644 index 000000000..7df7a5742 Binary files /dev/null and b/src/main/resources/assets/clientcommands/textures/chess/white_knight.png differ diff --git a/src/main/resources/assets/clientcommands/textures/chess/white_pawn.png b/src/main/resources/assets/clientcommands/textures/chess/white_pawn.png new file mode 100644 index 000000000..88d1375f2 Binary files /dev/null and b/src/main/resources/assets/clientcommands/textures/chess/white_pawn.png differ diff --git a/src/main/resources/assets/clientcommands/textures/chess/white_queen.png b/src/main/resources/assets/clientcommands/textures/chess/white_queen.png new file mode 100644 index 000000000..81d8c3f8a Binary files /dev/null and b/src/main/resources/assets/clientcommands/textures/chess/white_queen.png differ diff --git a/src/main/resources/assets/clientcommands/textures/chess/white_rook.png b/src/main/resources/assets/clientcommands/textures/chess/white_rook.png new file mode 100644 index 000000000..363c7a17c Binary files /dev/null and b/src/main/resources/assets/clientcommands/textures/chess/white_rook.png differ diff --git a/src/main/resources/clientcommands.aw b/src/main/resources/clientcommands.aw index 0664db2c5..0bbfdfe7c 100644 --- a/src/main/resources/clientcommands.aw +++ b/src/main/resources/clientcommands.aw @@ -3,6 +3,9 @@ accessWidener v2 named # c2c accessible field net/minecraft/client/multiplayer/AccountProfileKeyPairManager keyPair Ljava/util/concurrent/CompletableFuture; +# cchess +accessible field net/minecraft/client/gui/components/AbstractButton TEXT_MARGIN I + # Command Handling accessible field net/minecraft/client/gui/Gui overlayMessageTime I accessible field net/minecraft/client/gui/components/CommandSuggestions ARGUMENT_STYLES Ljava/util/List;