[FEAT] Several new usability features

Keyboard controls
Current piece and following piece now shown, with ability to swap between them
Animation to fade out game blocks when clearing
Pieces can be rotated before playing, with keyboard or mouse controls
Currently-focussed block is highlighted
Indicator shown on middle block of current piece board
This commit is contained in:
2023-04-06 20:13:14 +01:00
parent 9f0026b4ef
commit ba2cc8e6ef
9 changed files with 394 additions and 32 deletions

View File

@@ -1,5 +1,6 @@
package uk.mgrove.ac.soton.comp1206.component;
import javafx.animation.AnimationTimer;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.value.ObservableValue;
@@ -63,6 +64,16 @@ public class GameBlock extends Canvas {
*/
private final IntegerProperty value = new SimpleIntegerProperty(0);
/**
* Whether the block should appear as focussed
*/
private boolean isFocussed = false;
/**
* Whether indicator should be shown in the middle of the block
*/
private boolean showMiddleIndicator = false;
/**
* Create a new single Game Block
* @param gameBoard the board this block belongs to
@@ -110,12 +121,18 @@ public class GameBlock extends Canvas {
//If the block is not empty, paint with the colour represented by the value
paintColor(COLOURS[value.get()]);
}
// paint focus overlay if required
if (isFocussed) paintFocusOverlay();
// paint middle indicator if required
if (showMiddleIndicator) paintMiddleIndicator();
}
/**
* Paint this canvas empty
*/
private void paintEmpty() {
logger.info("Painting empty block at x: {}, y: {}", getX(), getY());
var gc = getGraphicsContext2D();
//Clear
@@ -132,23 +149,111 @@ public class GameBlock extends Canvas {
/**
* Paint this canvas with the given colour
* @param colour the colour to paint
* @param color the colour to paint
*/
private void paintColor(Paint colour) {
private void paintColor(Color color) {
logger.info("Painting color block at x: {}, y: {}", getX(), getY());
var gc = getGraphicsContext2D();
//Clear
gc.clearRect(0,0,width,height);
//Colour fill
gc.setFill(colour);
gc.setFill(color);
gc.fillRect(0,0, width, height);
gc.setFill(color.deriveColor(0,1,0.8,1));
gc.fillPolygon(new double[]{
width,
width,
0.0
}, new double[]{
0,
height,
0
}, 3
);
//Border
gc.setStroke(Color.GRAY);
gc.strokeRect(0,0,width,height);
}
/**
* Paint indicator in middle of block
*/
private void paintMiddleIndicator() {
logger.info("Painting indicator on block at x: {}, y: {}", getX(), getY());
var gc = getGraphicsContext2D();
gc.setFill(Color.WHITE.deriveColor(0,1,1,0.7));
gc.fillOval(width/4, height/4, width/2, height/2);
}
/**
* Paint overlay to indicate focus
*/
private void paintFocusOverlay() {
logger.info("Painting focus overlay on block at x: {}, y: {}", getX(), getY());
var gc = getGraphicsContext2D();
gc.setFill(Color.WHITE.deriveColor(0,1,1,0.5));
gc.fillRect(0,0,width,height);
}
/**
* Set whether the block should appear as focussed
* @param value whether block should appear as focussed
*/
public void setFocussed(boolean value) {
logger.info("Block at x: {}, y: {} has been set to focus: {}", getX(), getY(), value);
isFocussed = value;
// TODO: add/remove overlay
paint();
}
/**
* Set whether an indicator should be shown in the middle of the block
* @param value whether indicator should be shown
*/
public void showMiddleIndicator(boolean value) {
logger.info("Setting middle indicator on block at x: {}, y: {} to: {}", getX(), getY(), value);
showMiddleIndicator = value;
paint();
}
/**
* Fade out block
*/
public void fadeOut() {
logger.info("Fading out block at x: {}, y: {}", getX(), getY());
AnimationTimer timer = new AnimationTimer() {
Color color = COLOURS[getValue()];
int iterations = 0;
@Override
public void handle(long now) {
if (iterations < 12) {
color = color.deriveColor(0,1,1.2,1);
paintColor(color);
logger.info("New color brightness is {}", color.getBrightness());
if (isFocussed) paintFocusOverlay();
} else if (color.getOpacity() > 0.1) {
color = color.deriveColor(0,1,1,0.95);
paintColor(color);
logger.info("New color opacity is {}", color.getOpacity());
if (isFocussed) paintFocusOverlay();
} else {
paintEmpty();
if (isFocussed) paintFocusOverlay();
logger.info("Stopping timer at x: {}, y: {}", getX(), getY());
stop();
}
iterations++;
}
};
timer.start();
}
/**
* Get the column of this block
* @return column number

View File

@@ -1,11 +1,16 @@
package uk.mgrove.ac.soton.comp1206.component;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.GridPane;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import uk.mgrove.ac.soton.comp1206.event.BlockClickedListener;
import uk.mgrove.ac.soton.comp1206.event.MouseClickListener;
import uk.mgrove.ac.soton.comp1206.game.Grid;
import uk.mgrove.ac.soton.comp1206.util.Multimedia;
import java.util.Set;
/**
* A GameBoard is a visual component to represent the visual GameBoard.
@@ -56,6 +61,25 @@ public class GameBoard extends GridPane {
*/
private BlockClickedListener blockClickedListener;
/**
* Listener to call when board is right-clicked
*/
protected MouseClickListener rightClickListener;
/**
* Listener for board being left-clicked
*/
protected MouseClickListener leftClickListener;
/**
* Currently focussed game block
*/
private GameBlock focussedBlock;
/**
* Whether focus overlays should be shown
*/
protected boolean enableFocus = true;
/**
* Create a new GameBoard, based off a given grid, with a visual width and height.
@@ -122,6 +146,15 @@ public class GameBoard extends GridPane {
createBlock(x,y);
}
}
setOnMouseClicked((event) -> {
logger.debug("Mouse button clicked: {}", event.getButton());
if (event.getButton().equals(MouseButton.SECONDARY) && rightClickListener != null) {
rightClickListener.action();
} else if (event.getButton().equals(MouseButton.PRIMARY) && leftClickListener != null) {
leftClickListener.action();
}
});
}
/**
@@ -130,6 +163,7 @@ public class GameBoard extends GridPane {
* @param y row
*/
protected GameBlock createBlock(int x, int y) {
logger.info("Creating new block at x: {}, y: {}", x, y);
var blockWidth = width / cols;
var blockHeight = height / rows;
@@ -146,7 +180,12 @@ public class GameBoard extends GridPane {
block.bind(grid.getGridProperty(x,y));
//Add a mouse click handler to the block to trigger GameBoard blockClicked method
block.setOnMouseClicked((e) -> blockClicked(e, block));
block.setOnMouseClicked((e) -> {
if (e.getButton().equals(MouseButton.PRIMARY)) blockClicked(e, block);
});
block.hoverProperty().addListener(((observable, oldValue, newValue) -> {
if (newValue) blockHovered(block);
}));
return block;
}
@@ -172,4 +211,71 @@ public class GameBoard extends GridPane {
}
}
public void blockHovered(GameBlock gameBlock) {
if (enableFocus) {
logger.info("Block hovered: {}", gameBlock);
if (focussedBlock != null) focussedBlock.setFocussed(false);
focussedBlock = gameBlock;
focussedBlock.setFocussed(true);
}
}
/**
* Change current x-coordinate focus by given amount
* @param amount amount to change by
*/
public void changeXFocus(int amount) {
logger.info("Change of {} requested to x-coordinate of focussed block", amount);
if (focussedBlock == null) blockHovered(getBlock(0,0));
else if (focussedBlock.getX() + amount >= 0 && focussedBlock.getX() + amount < cols) blockHovered(getBlock(focussedBlock.getX() + amount, focussedBlock.getY()));
else Multimedia.playAudio("sounds/fail.wav");
}
/**
* Change current y-coordinate focus by given amount
* @param amount amount to change by
*/
public void changeYFocus(int amount) {
logger.info("Change of {} requested to y-coordinate of focussed block", amount);
if (focussedBlock == null) blockHovered(getBlock(0,0));
else if (focussedBlock.getY() + amount >= 0 && focussedBlock.getY() + amount < rows) blockHovered(getBlock(focussedBlock.getX(), focussedBlock.getY() + amount));
else Multimedia.playAudio("sounds/fail.wav");
}
/**
* Fade out all blocks located at a set of coordinates
* @param blockCoordinates block coordinates
*/
public void fadeOut(Set<GameBlockCoordinate> blockCoordinates) {
for (var block : blockCoordinates) {
getBlock(block.getX(), block.getY()).fadeOut();
}
}
/**
* Get the x-coordinate of the currently-focussed block
* @return x-coordinate of block
*/
public int getXFocus() {
if (focussedBlock != null) return focussedBlock.getX();
else return 0;
}
/**
* Get the y-coordinate of the currently-focussed block
* @return y-coordinate of block
*/
public int getYFocus() {
if (focussedBlock != null) return focussedBlock.getY();
else return 0;
}
public void setOnRightClicked(MouseClickListener listener) {
rightClickListener = listener;
}
public void setOnLeftClicked(MouseClickListener listener) {
leftClickListener = listener;
}
}

View File

@@ -1,12 +1,18 @@
package uk.mgrove.ac.soton.comp1206.component;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import uk.mgrove.ac.soton.comp1206.game.GamePiece;
import uk.mgrove.ac.soton.comp1206.game.Grid;
public class PieceBoard extends GameBoard {
/**
* Create a new GameBoard with it's own internal grid, specifying the number of columns and rows, along with the
* Logger
*/
private static final Logger logger = LogManager.getLogger(PieceBoard.class);
/**
* Create a new GameBoard with its own internal grid, specifying the number of columns and rows, along with the
* visual width and height.
*
* @param width the visual width
@@ -14,11 +20,21 @@ public class PieceBoard extends GameBoard {
*/
public PieceBoard(double width, double height) {
super(3, 3, width, height);
enableFocus = false;
}
public void displayPiece(GamePiece piece) {
logger.info("Displaying new piece: {}", piece.toString());
grid.clearGrid();
grid.playPiece(piece,1,1);
}
/**
* Set whether an indicator should be shown on the middle block of the grid
* @param value whether indicator should be shown
*/
public void showMiddleIndicator(boolean value) {
getBlock(1,1).showMiddleIndicator(value);
}
}

View File

@@ -0,0 +1,11 @@
package uk.mgrove.ac.soton.comp1206.event;
import uk.mgrove.ac.soton.comp1206.component.GameBlockCoordinate;
import java.util.Set;
public interface LineClearedListener {
public void clearLine(Set<GameBlockCoordinate> blockCoordinates);
}

View File

@@ -0,0 +1,13 @@
package uk.mgrove.ac.soton.comp1206.event;
/**
* Listener for a node being right-clicked
*/
public interface MouseClickListener {
/**
* Handle node being right-clicked
*/
public void action();
}

View File

@@ -9,7 +9,8 @@ public interface NextPieceListener {
/**
* Handle a new piece being received by the game
* @param piece the piece that was received
* @param currentPiece the piece that was received
* @param followingPiece the piece that was received for the next iteration
*/
public void nextPiece(GamePiece piece);
public void nextPiece(GamePiece currentPiece, GamePiece followingPiece);
}

View File

@@ -5,7 +5,10 @@ import javafx.beans.property.SimpleIntegerProperty;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import uk.mgrove.ac.soton.comp1206.component.GameBlock;
import uk.mgrove.ac.soton.comp1206.component.GameBlockCoordinate;
import uk.mgrove.ac.soton.comp1206.event.LineClearedListener;
import uk.mgrove.ac.soton.comp1206.event.NextPieceListener;
import uk.mgrove.ac.soton.comp1206.util.Multimedia;
import java.util.*;
@@ -45,6 +48,11 @@ public class Game {
*/
private GamePiece currentPiece;
/**
* Next game piece for player to use
*/
private GamePiece followingPiece;
/**
* Player's current score
*/
@@ -67,6 +75,11 @@ public class Game {
*/
private NextPieceListener nextPieceListener;
/**
* Listener for when lines of blocks are cleared
*/
private LineClearedListener lineClearedListener;
/**
* Create a new game with the specified rows and columns. Creates a corresponding grid model.
* @param cols number of columns
@@ -93,12 +106,24 @@ public class Game {
* @return the next piece
*/
public GamePiece nextPiece() {
currentPiece = spawnPiece();
if (nextPieceListener != null) nextPieceListener.nextPiece(currentPiece);
logger.info("Next piece is: {}", currentPiece);
currentPiece = followingPiece;
followingPiece = spawnPiece();
if (nextPieceListener != null) nextPieceListener.nextPiece(currentPiece, followingPiece);
logger.info("Next piece is: {} and following piece is: {}", currentPiece, followingPiece);
return currentPiece;
}
/**
* Swap the current and next game pieces
*/
public void swapPieces() {
logger.info("Swapping current and next pieces");
var tmp = currentPiece;
currentPiece = followingPiece;
followingPiece = tmp;
if (nextPieceListener != null) nextPieceListener.nextPiece(currentPiece, followingPiece);
}
/**
* Create a new game piece
* @return newly created piece
@@ -115,6 +140,7 @@ public class Game {
*/
public void initialiseGame() {
logger.info("Initialising game");
followingPiece = spawnPiece();
nextPiece();
}
@@ -127,12 +153,27 @@ public class Game {
int x = gameBlock.getX();
int y = gameBlock.getY();
dropPiece(x,y);
}
/**
* Play current piece at given coordinates
* @param x x-coordinate
* @param y y-coordinate
*/
public void dropPiece(int x, int y) {
if (grid.canPlayPiece(currentPiece,x,y)) {
logger.info("Playing piece at x: {}, y: {}", x, y);
grid.playPiece(currentPiece,x,y);
afterPiece();
nextPiece();
Multimedia.playAudio("sounds/place.wav");
} else {
// can't play the piece
logger.info("Couldn't play piece at x: {}, y: {}", x, y);
Multimedia.playAudio("sounds/fail.wav");
}
}
@@ -143,20 +184,22 @@ public class Game {
logger.info("Checking for columns and rows that need clearing");
Set<IntegerProperty> blocksToRemove = new HashSet<>();
Set<GameBlockCoordinate> blockCoordinatesToRemove = new HashSet<>();
int linesToRemove = 0;
for (var x=0; x < grid.getCols(); x++) {
List<IntegerProperty> columnBlocksToRemove = new ArrayList<>();
Map<GameBlockCoordinate, IntegerProperty> columnBlocksToRemove = new HashMap<>();
for (var y=0; y < grid.getRows(); y++) {
// if column isn't full then move to next column
if (grid.get(x,y) <= 0) break;
columnBlocksToRemove.add(grid.getGridProperty(x,y));
columnBlocksToRemove.put(new GameBlockCoordinate(x,y), grid.getGridProperty(x,y));
}
// if column is full then store blocks to reset
if (columnBlocksToRemove.size() == grid.getRows()) {
for (var block : columnBlocksToRemove) {
blocksToRemove.add(block);
for (var coordinates : columnBlocksToRemove.keySet()) {
blocksToRemove.add(columnBlocksToRemove.get(coordinates));
blockCoordinatesToRemove.add(coordinates);
linesToRemove++;
}
}
@@ -164,22 +207,28 @@ public class Game {
// do the same for rows
for (var y=0; y < grid.getRows(); y++) {
List<IntegerProperty> rowBlocksToRemove = new ArrayList<>();
Map<GameBlockCoordinate, IntegerProperty> rowBlocksToRemove = new HashMap<>();
for (var x=0; x < grid.getCols(); x++) {
// if row isn't full then move to next row
if (grid.get(x,y) <= 0) break;
rowBlocksToRemove.add(grid.getGridProperty(x,y));
rowBlocksToRemove.put(new GameBlockCoordinate(x,y), grid.getGridProperty(x,y));
}
// if row is full then store blocks to reset
if (rowBlocksToRemove.size() == grid.getCols()) {
for (var block : rowBlocksToRemove) {
blocksToRemove.add(block);
for (var coordinates : rowBlocksToRemove.keySet()) {
blocksToRemove.add(rowBlocksToRemove.get(coordinates));
blockCoordinatesToRemove.add(coordinates);
linesToRemove++;
}
}
}
if (linesToRemove > 0) {
Multimedia.playAudio("sounds/clear.wav");
if (lineClearedListener != null) lineClearedListener.clearLine(blockCoordinatesToRemove);
}
// update score and multiplier
score(linesToRemove,blocksToRemove.size());
@@ -209,6 +258,28 @@ public class Game {
level.set((int) Math.floor((double) score.get() / 1000));
}
/**
* Rotate the current piece clockwise
*/
public void rotateCurrentPiece() {
logger.info("Rotating current piece clockwise");
currentPiece.rotate();
Multimedia.playAudio("sounds/rotate.wav");
if (nextPieceListener != null) nextPieceListener.nextPiece(currentPiece, followingPiece);
}
/**
* Rotate the current piece anticlockwise
*/
public void rotateCurrentPieceAnticlockwise() {
logger.info("Rotating current piece anticlockwise");
currentPiece.rotate(3);
Multimedia.playAudio("sounds/rotate.wav");
if (nextPieceListener != null) nextPieceListener.nextPiece(currentPiece, followingPiece);
}
/**
* Get the grid model inside this game representing the game state of the board
* @return game grid model
@@ -333,4 +404,12 @@ public class Game {
nextPieceListener = listener;
}
/**
* Set line cleared listener
* @param listener listener to set
*/
public void setLineClearedListener(LineClearedListener listener) {
lineClearedListener = listener;
}
}

View File

@@ -1,13 +1,12 @@
package uk.mgrove.ac.soton.comp1206.scene;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import uk.mgrove.ac.soton.comp1206.component.GameBlock;
import uk.mgrove.ac.soton.comp1206.component.GameBoard;
import uk.mgrove.ac.soton.comp1206.component.PieceBoard;
import uk.mgrove.ac.soton.comp1206.event.NextPieceListener;
import uk.mgrove.ac.soton.comp1206.game.Game;
import uk.mgrove.ac.soton.comp1206.game.GamePiece;
import uk.mgrove.ac.soton.comp1206.ui.GamePane;
@@ -28,6 +27,16 @@ public class ChallengeScene extends BaseScene {
*/
private PieceBoard currentPieceBoard;
/**
* PieceBoard to display next piece
*/
private PieceBoard followingPieceBoard;
/**
* GameBoard for main game
*/
private GameBoard board;
/**
* Create a new Single Player challenge scene
* @param gameWindow the Game Window
@@ -57,15 +66,19 @@ public class ChallengeScene extends BaseScene {
var mainPane = new BorderPane();
challengePane.getChildren().add(mainPane);
var board = new GameBoard(game.getGrid(),gameWindow.getWidth()/2,gameWindow.getWidth()/2);
board = new GameBoard(game.getGrid(),gameWindow.getWidth()/2,gameWindow.getWidth()/2);
board.setOnRightClicked(game::rotateCurrentPiece);
mainPane.setCenter(board);
var statsMenu = new StatsMenu(game.scoreProperty(),game.levelProperty(),game.livesProperty(),game.multiplierProperty());
currentPieceBoard = new PieceBoard(100, 100);
currentPieceBoard.showMiddleIndicator(true);
currentPieceBoard.setOnLeftClicked(game::rotateCurrentPiece);
followingPieceBoard = new PieceBoard(72, 72);
var rightMenu = new VBox();
rightMenu.getChildren().addAll(statsMenu,currentPieceBoard);
rightMenu.getChildren().addAll(statsMenu,currentPieceBoard,followingPieceBoard);
mainPane.setRight(rightMenu);
@@ -83,6 +96,24 @@ public class ChallengeScene extends BaseScene {
game.blockClicked(gameBlock);
}
/**
* Handle keypress for game keyboard controls
* @param event the keypress
*/
private void handleKeyboardControls(KeyEvent event) {
switch (event.getCode()) {
case ESCAPE -> returnToMenu();
case ENTER, X -> game.dropPiece(board.getXFocus(),board.getYFocus());
case SPACE, R -> game.swapPieces();
case OPEN_BRACKET, Q, Z -> game.rotateCurrentPieceAnticlockwise();
case CLOSE_BRACKET, E, C -> game.rotateCurrentPiece();
case LEFT, A -> board.changeXFocus(-1);
case RIGHT, D -> board.changeXFocus(1);
case DOWN, S -> board.changeYFocus(1);
case UP, W -> board.changeYFocus(-1);
}
}
/**
* Set up the game object and model
*/
@@ -100,15 +131,12 @@ public class ChallengeScene extends BaseScene {
public void initialise() {
logger.info("Initialising Challenge");
// exit challenge when escape key pressed
scene.setOnKeyPressed((event) -> {
if (event.getCode() == KeyCode.ESCAPE) {
returnToMenu();
}
});
scene.setOnKeyPressed(this::handleKeyboardControls);
game.setNextPieceListener(this::setCurrentPiece);
game.setLineClearedListener(board::fadeOut);
game.start();
}
@@ -119,8 +147,9 @@ public class ChallengeScene extends BaseScene {
gameWindow.startMenu();
}
private void setCurrentPiece(GamePiece piece) {
currentPieceBoard.displayPiece(piece);
private void setCurrentPiece(GamePiece currentPiece, GamePiece followingPiece) {
currentPieceBoard.displayPiece(currentPiece);
followingPieceBoard.displayPiece(followingPiece);
}
}

View File

@@ -39,7 +39,7 @@ public class InstructionsScene extends BaseScene {
public void initialise() {
logger.info("Initialising Instructions");
// exit challenge when escape key pressed
// exit instructions when escape key pressed
scene.setOnKeyPressed((event) -> {
if (event.getCode() == KeyCode.ESCAPE) {
gameWindow.startMenu();
@@ -52,6 +52,8 @@ public class InstructionsScene extends BaseScene {
*/
@Override
public void build() {
// TODO: styling and insets
logger.info("Building " + this.getClass().getName());
root = new GamePane(gameWindow.getWidth(),gameWindow.getHeight());