diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/tetris/game/Orientation.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/tetris/game/Orientation.java new file mode 100644 index 0000000..efbc78b --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/tetris/game/Orientation.java @@ -0,0 +1,17 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.tetris.game; + +public enum Orientation { + NONE, + RIGHT, + LEFT, + UPSIDE_DOWN; + + public Orientation rotated(boolean clockwise) { + return switch(this) { + case NONE -> clockwise ? Orientation.RIGHT : Orientation.LEFT; + case RIGHT -> clockwise ? Orientation.UPSIDE_DOWN : Orientation.NONE; + case UPSIDE_DOWN -> clockwise ? Orientation.LEFT : Orientation.RIGHT; + case LEFT -> clockwise ? Orientation.NONE : Orientation.UPSIDE_DOWN; + }; + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/tetris/game/RotationChecker.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/tetris/game/RotationChecker.java new file mode 100644 index 0000000..3f755a0 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/tetris/game/RotationChecker.java @@ -0,0 +1,53 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.tetris.game; + +import java.util.Map; +import java.util.stream.IntStream; + +public final class RotationChecker { + private static final Map STANDARD_WALL_KICKS = Map.of( + Orientation.NONE, new int[][] {{0,0}, {0,0}, {0,0}, {0,0}, {0,0}}, + Orientation.RIGHT, new int[][] {{0,0}, {1,0}, {1,-1}, {0,2}, {1,2}}, + Orientation.UPSIDE_DOWN, new int[][] {{0,0}, {0,0}, {0,0}, {0,0}, {0,0}}, + Orientation.LEFT, new int[][] {{0,0}, {-1,0}, {-1,-1}, {0,2}, {-1,2}} + ); + + private static final Map I_WALL_KICKS = Map.of( + Orientation.NONE, new int[][] {{0,0}, {-1,0}, {2,0}, {-1,0}, {2,0}}, + Orientation.RIGHT, new int[][] {{-1,0}, {0,0}, {0,0}, {0,1}, {0,-2}}, + Orientation.UPSIDE_DOWN, new int[][] {{-1,1}, {1,1}, {-2,1}, {1,0}, {-2,0}}, + Orientation.LEFT, new int[][] {{0,1}, {0,1}, {0,1}, {0,-1}, {0,2}} + ); + + private static final Map O_WALL_KICKS = Map.of( + Orientation.NONE, new int[][] {{0,0}}, + Orientation.RIGHT, new int[][] {{0,-1}}, + Orientation.UPSIDE_DOWN, new int[][] {{-1,-1}}, + Orientation.LEFT, new int[][] {{-1,0}} + ); + + public static int[][] getKicksArray(Orientation from, Orientation to, Tetromino.Shape shape) { + int[][] firstOffsets = getOffsetData(from, shape); + int[][] secondOffsets = getOffsetData(to, shape); + int[][] result = new int[firstOffsets.length][2]; + + for(int i = 0; i < firstOffsets.length; i++) { + result[i] = subtractInt(firstOffsets[i], secondOffsets[i]); + } + + return result; + } + + private static int[] subtractInt(int[] first, int[] second) { + return IntStream.range(0, first.length) + .map(i -> first[i] - second[i]) + .toArray(); + } + + private static int[][] getOffsetData(Orientation orientation, Tetromino.Shape shape) { + return switch(shape) { + case J, L, S, T, Z -> STANDARD_WALL_KICKS.get(orientation); + case I -> I_WALL_KICKS.get(orientation); + case O -> O_WALL_KICKS.get(orientation); + }; + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/tetris/game/TetrisGame.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/tetris/game/TetrisGame.java index 44dbe5f..5098939 100644 --- a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/tetris/game/TetrisGame.java +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/tetris/game/TetrisGame.java @@ -2,10 +2,9 @@ package eu.mhsl.minenet.minigames.instance.game.stateless.types.tetris.game; import eu.mhsl.minenet.minigames.instance.game.stateless.StatelessGame; import net.kyori.adventure.text.Component; -import net.minestom.server.MinecraftServer; import net.minestom.server.coordinate.Pos; import net.minestom.server.scoreboard.Sidebar; -import net.minestom.server.timer.Scheduler; +import net.minestom.server.timer.Task; import net.minestom.server.timer.TaskSchedule; import java.util.*; @@ -23,6 +22,9 @@ public class TetrisGame { private final Map lastPresses = new HashMap<>(); private final List otherTetrisGames = new ArrayList<>(); private final Random random; + private Task tetrominoLockTask; + private int lockDelayResets; + private int currentTetrominoLowestY = Integer.MAX_VALUE; public boolean lost = false; public boolean paused = true; public Tetromino currentTetromino; @@ -54,24 +56,27 @@ public class TetrisGame { } public void pressedButton(Button button) { - final int standardButtonDelay = 100; - final int buttonDebounce = 70; + final int standardButtonDelay = 95; + final int wsButtonDebounce = 70; if(this.lastPresses.getOrDefault(button, 0L) >= System.currentTimeMillis() - standardButtonDelay) return; - this.lastPresses.put(button, System.currentTimeMillis()); - if(button == Button.W) this.lastPresses.put(button, System.currentTimeMillis() + buttonDebounce); - if(button == Button.S) this.lastPresses.put(button, System.currentTimeMillis() - buttonDebounce); + switch(button) { + case W -> this.lastPresses.put(button, System.currentTimeMillis() + wsButtonDebounce); + case S -> this.lastPresses.put(button, System.currentTimeMillis() - wsButtonDebounce); + case mouseLeft, mouseRight -> this.lastPresses.put(button, 0L); + default -> this.lastPresses.put(button, System.currentTimeMillis()); + } if(this.lost || this.paused) return; switch(button) { - case A -> this.currentTetromino.moveLeft(); + case A -> this.moveLeft(); case S -> this.moveDown(); - case D -> this.currentTetromino.moveRight(); + case D -> this.moveRight(); case W -> this.hardDrop(); - case mouseLeft -> this.currentTetromino.rotate(false); - case mouseRight -> this.currentTetromino.rotate(true); + case mouseLeft -> this.rotate(false); + case mouseRight -> this.rotate(true); case space -> this.switchHold(); } } @@ -82,8 +87,7 @@ public class TetrisGame { public void start() { this.paused = false; - Scheduler scheduler = MinecraftServer.getSchedulerManager(); - scheduler.submitTask(() -> { + this.instance.scheduler().submitTask(() -> { if(this.lost) return TaskSchedule.stop(); int standardTickDelay = 40; if(this.isFast) standardTickDelay = 20; @@ -111,9 +115,12 @@ public class TetrisGame { public void tick() { if(this.lost || this.paused) return; - if(!this.currentTetromino.moveDown()) { - this.setActiveTetrominoDown(); + if(this.currentTetromino.moveDown()) { + this.stopTetrominoLockTask(this.currentTetromino.getYPosition() < this.currentTetrominoLowestY, false); + } else { + this.scheduleTetrominoLock(); } + this.currentTetrominoLowestY = Math.min(this.currentTetrominoLowestY, this.currentTetromino.getYPosition()); } public int getScore() { @@ -139,33 +146,43 @@ public class TetrisGame { this.lost = true; } - private boolean moveDown() { + private void moveDown() { if(!this.currentTetromino.moveDown()) { - this.setActiveTetrominoDown(); - return false; + this.scheduleTetrominoLock(); + return; } + this.stopTetrominoLockTask(this.currentTetromino.getYPosition() < this.currentTetrominoLowestY, false); this.score += 1; this.updateInfo(); - return true; } - private boolean hardDrop() { + private void moveLeft() { + if(this.currentTetromino.moveLeft()) this.onSuccessfulMoveOrRotate(); + } + + private void moveRight() { + if(this.currentTetromino.moveRight()) this.onSuccessfulMoveOrRotate(); + } + + private void rotate(boolean clockwise) { + if(this.currentTetromino.rotate(clockwise)) this.onSuccessfulMoveOrRotate(); + } + + private void hardDrop() { if(!this.currentTetromino.moveDown()) { - this.setActiveTetrominoDown(); - return false; + this.lockActiveTetromino(); + return; } - this.score += 2; - this.updateInfo(); - while(this.currentTetromino.moveDown()) { + do { this.score += 2; - this.updateInfo(); - } - this.setActiveTetrominoDown(); - return true; + } while(this.currentTetromino.moveDown()); + this.updateInfo(); + this.lockActiveTetromino(); } - private boolean switchHold() { - if(!this.holdPossible) return false; + private void switchHold() { + if(!this.holdPossible) return; + this.stopTetrominoLockTask(true); Tetromino newCurrentTetromino; if(this.holdTetromino == null) { @@ -181,6 +198,7 @@ public class TetrisGame { this.holdTetromino = new Tetromino(this.instance, this.currentTetromino.getShape()); this.currentTetromino = newCurrentTetromino; + this.currentTetrominoLowestY = Integer.MAX_VALUE; this.currentTetromino.setPosition(this.tetrominoSpawnPosition); this.currentTetromino.draw(); if(!this.currentTetromino.moveDown()) this.loose(); @@ -189,7 +207,12 @@ public class TetrisGame { this.holdTetromino.setPosition(this.holdPosition.add(xChange, 0, 0)); this.holdTetromino.drawAsEntities(); this.holdPossible = false; - return true; + } + + private void onSuccessfulMoveOrRotate() { + if(!this.currentTetromino.isGrounded()) return; + this.stopTetrominoLockTask(false); + this.scheduleTetrominoLock(); } private void updateNextTetrominoes() { @@ -235,11 +258,39 @@ public class TetrisGame { this.sidebar.updateLineScore("2", this.level); } - private void setActiveTetrominoDown() { + private void scheduleTetrominoLock() { + if(this.tetrominoLockTask == null || !this.tetrominoLockTask.isAlive()) + this.tetrominoLockTask = this.instance.scheduler().scheduleTask(() -> { + if(this.currentTetromino.isGrounded()) { + this.lockActiveTetromino(); + } else { + this.stopTetrominoLockTask(false, false); + } + return TaskSchedule.stop(); + }, TaskSchedule.millis(500)); + } + + private void stopTetrominoLockTask(boolean resetHard) { + this.stopTetrominoLockTask(resetHard, true); + } + + private void stopTetrominoLockTask(boolean resetHard, boolean addToResets) { + if(resetHard) this.lockDelayResets = 0; + + if(this.lockDelayResets >= 15 && addToResets) { + this.lockActiveTetromino(); + return; + } + if(this.tetrominoLockTask != null) { + this.tetrominoLockTask.cancel(); + this.tetrominoLockTask = null; + if(!resetHard && addToResets) this.lockDelayResets++; + } + } + + private void lockActiveTetromino() { + this.stopTetrominoLockTask(true); this.currentTetromino.removeOwnEntities(); - this.currentTetromino = this.nextTetrominoes.removeFirst(); - this.currentTetromino.remove(); - this.updateNextTetrominoes(); int removedLines = this.playfield.removeFullLines(); int combatLines = 0; @@ -297,6 +348,11 @@ public class TetrisGame { this.updateInfo(); + this.currentTetromino = this.nextTetrominoes.removeFirst(); + this.currentTetromino.remove(); + this.updateNextTetrominoes(); + this.currentTetrominoLowestY = Integer.MAX_VALUE; + this.currentTetromino.setPosition(this.tetrominoSpawnPosition); this.currentTetromino.draw(); if(!this.currentTetromino.moveDown()) { diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/tetris/game/Tetromino.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/tetris/game/Tetromino.java index 7acf4fe..6d8b0b0 100644 --- a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/tetris/game/Tetromino.java +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/tetris/game/Tetromino.java @@ -8,14 +8,12 @@ import net.minestom.server.entity.metadata.other.FallingBlockMeta; import net.minestom.server.instance.block.Block; import net.minestom.server.tag.Tag; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.UUID; +import java.util.*; public class Tetromino { private final static EntityType ghostEntityType = EntityType.FALLING_BLOCK; private final static Tag uuidTag = Tag.String("uuid"); + private Orientation orientation = Orientation.NONE; private final Shape shape; private final StatelessGame instance; private final UUID uuid; @@ -28,10 +26,10 @@ public class Tetromino { this.uuid = UUID.randomUUID(); switch(this.shape) { - case I -> this.shapeArray = new int[][]{{0, 0, 0, 0}, {1, 1, 1, 1}, {0, 0, 0, 0}, {0, 0, 0, 0}}; + case I -> this.shapeArray = new int[][]{{0, 0, 0, 0, 0}, {0, 0, 0, 0, 0}, {0, 1, 1, 1, 1}, {0, 0, 0, 0, 0}, {0, 0, 0, 0, 0}}; case J -> this.shapeArray = new int[][]{{1, 0, 0}, {1, 1, 1}, {0, 0, 0}}; case L -> this.shapeArray = new int[][]{{0, 0, 1}, {1, 1, 1}, {0, 0, 0}}; - case O -> this.shapeArray = new int[][]{{1, 1}, {1, 1}}; + case O -> this.shapeArray = new int[][]{{0, 1, 1}, {0, 1, 1}, {0, 0, 0}}; case S -> this.shapeArray = new int[][]{{0, 1, 1}, {1, 1, 0}, {0, 0, 0}}; case T -> this.shapeArray = new int[][]{{0, 1, 0}, {1, 1, 1}, {0, 0, 0}}; case Z -> this.shapeArray = new int[][]{{1, 1, 0}, {0, 1, 1}, {0, 0, 0}}; @@ -43,12 +41,23 @@ public class Tetromino { } public void setPosition(Pos newPosition) { - this.position = newPosition; + this.position = new Pos(newPosition.x(), newPosition.y(), newPosition.z()); } public boolean rotate(boolean clockwise) { int[][] newShapeArray = this.getTurnedShapeArray(clockwise); - return this.checkCollisionAndMove(this.position, newShapeArray); + Orientation newOrientation = this.orientation.rotated(clockwise); + + int[][] kicksArray = RotationChecker.getKicksArray(this.orientation, newOrientation, this.shape); + for(int[] k : kicksArray) { + Pos candidate = new Pos(this.position.x() + k[0], this.position.y() + k[1], this.position.z()); + if(this.checkCollisionAndMove(candidate, newShapeArray)) { + this.orientation = newOrientation; + return true; + } + } + + return false; } public boolean moveDown() { @@ -73,7 +82,7 @@ public class Tetromino { public void draw(boolean withGhost) { if(withGhost) { Pos ghostPos = this.position; - while(!this.checkCollision(ghostPos.sub(0, 1, 0), this.shapeArray)) { + while(!this.hasCollision(ghostPos.sub(0, 1, 0), this.shapeArray)) { ghostPos = ghostPos.sub(0, 1, 0); } Pos positionChange = this.position.sub(ghostPos); @@ -182,7 +191,7 @@ public class Tetromino { private boolean isPartOfTetromino(Pos position) { return this.getBlockPositions().stream() - .anyMatch(pos -> pos.equals(position)); + .anyMatch(pos -> pos.sameBlock(position)); } private List getBlockPositions() { @@ -198,10 +207,10 @@ public class Tetromino { for(int x = 0; x < arrayLength; x++) { for(int y = 0; y < arrayLength; y++) { if(shapeArray[arrayLength - 1 - y][x] == 1) { - switch(this.shape) { - case I -> returnList.add(position.add(x - 1, y - 2, 0)); - case O -> returnList.add(position.add(x, y, 0)); - default -> returnList.add(position.add(x - 1, y - 1, 0)); + if(Objects.requireNonNull(this.shape) == Shape.I) { + returnList.add(position.add(x - 2, y - 2, 0)); + } else { + returnList.add(position.add(x - 1, y - 1, 0)); } } } @@ -210,7 +219,7 @@ public class Tetromino { return returnList; } - private boolean checkCollision(Pos newPosition, int[][] newShapeArray) { + private boolean hasCollision(Pos newPosition, int[][] newShapeArray) { List newBlockPositions = this.getBlockPositions(newPosition, newShapeArray); for(Pos pos : newBlockPositions) { @@ -222,15 +231,21 @@ public class Tetromino { return false; } + public boolean isGrounded() { + return this.hasCollision(this.position.sub(0, 1, 0), this.shapeArray); + } + private boolean checkCollisionAndMove(Pos newPosition, int[][] newShapeArray) { - if(!this.checkCollision(newPosition, newShapeArray)) { - this.remove(); - this.shapeArray = Arrays.stream(newShapeArray).map(int[]::clone).toArray(int[][]::new); - this.setPosition(newPosition); - this.draw(); - return true; - } - return false; + if(this.hasCollision(newPosition, newShapeArray)) return false; + this.remove(); + this.shapeArray = Arrays.stream(newShapeArray).map(int[]::clone).toArray(int[][]::new); + this.setPosition(newPosition); + this.draw(); + return true; + } + + public int getYPosition() { + return Math.toIntExact(Math.round(this.position.y())); } public enum Shape {