-
Notifications
You must be signed in to change notification settings - Fork 1
#018 Mouse Targeting
Although roguelike games generally require users to memorize certain combinations of symbols and colors to differentiate enemies, items, and other elements of the world, we can make things easier and more convenient by supporting mouse targeting. In other words, we are going to display the information of any entity that a player clicks on.
The goals of the mouse targeting feature are as follows.
- When the player presses the left mouse button within the map and the position of the mouse button...
- ...intersects an entity, then the target information area will display the entity's information.
- ...does not intersect an entity, then the target information area is cleared.
- When the player presses the left mouse button outside the map, then nothing happens to the target information area.
In order to allow different types of entities to display different types of information on their information panel, the getInformationPanel() function has been made non-static. This allows subclasses of the entity class to have their own overridden version of the getInformationPanel() function.
package com.valkryst.VTerminal_Tutorial.entity;
import com.valkryst.VTerminal.Tile;
import com.valkryst.VTerminal.builder.LabelBuilder;
import com.valkryst.VTerminal.component.Label;
import com.valkryst.VTerminal.component.Layer;
import com.valkryst.VTerminal.printer.RectanglePrinter;
import com.valkryst.VTerminal_Tutorial.LineOfSight;
import com.valkryst.VTerminal_Tutorial.Sprite;
import com.valkryst.VTerminal_Tutorial.action.Action;
import com.valkryst.VTerminal_Tutorial.action.MoveAction;
import com.valkryst.VTerminal_Tutorial.gui.controller.GameController;
import com.valkryst.VTerminal_Tutorial.item.Inventory;
import com.valkryst.VTerminal_Tutorial.statistic.BoundStat;
import com.valkryst.VTerminal_Tutorial.statistic.Stat;
import lombok.Getter;
import lombok.Setter;
import java.awt.*;
import java.util.HashMap;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
public class Entity extends Layer {
/** The sprite. */
@Getter private Sprite sprite;
/** The name. */
@Getter @Setter private String name;
/** The stats. */
private final HashMap<String, Stat> stats = new HashMap<>();
/** The inventory. */
@Getter private Inventory inventory = new Inventory(26);
/** The actions to perform. */
private final Queue<Action> actions = new ConcurrentLinkedQueue<>();
/** The line of sight. */
@Getter @Setter private LineOfSight lineOfSight;
/**
* Constructs a new Entity.
*
* @param sprite
* The sprite.
*
* Defaults to UNKNOWN if the sprite is null.
*
* @param position
* The position of the entity within a map.
*
* Defaults to (0, 0) if the position is null or if either part of the coordinate is negative.
*
* @param name
* The name.
*
* Defaults to NoNameSet if the name is null or empty.
*/
public Entity(final Sprite sprite, final Point position, final String name) {
super(new Dimension(1, 1));
if (sprite == null) {
setSprite(Sprite.UNKNOWN);
} else {
setSprite(sprite);
}
if (position == null || position.x < 0 || position.y < 0) {
super.getTiles().setPosition(new Point(0, 0));
} else {
super.getTiles().setPosition(position);
}
if (name == null || name.isEmpty()) {
this.name = "NoNameSet";
} else {
this.name = name;
}
lineOfSight = new LineOfSight(4, position);
// Set Core Stats
final BoundStat health = new BoundStat("Health", 0, 100);
final BoundStat level = new BoundStat("Level", 1, 1, 60);
final BoundStat experience = new BoundStat("Experience", 0, 0, 100);
addStat(health);
addStat(level);
addStat(experience);
}
/**
* Constructs a new Entity.
*
* @param sprite
* The sprite.
*
* Defaults to UNKNOWN if the sprite is null.
*
* @param position
* The position of the entity within a map.
*
* Defaults to (0, 0) if the position is null or if either part of the coordinate is negative.
*
* @param name
* The name.
*
* Defaults to NoNameSet if the name is null or empty.
*
* @param inventory
* The inventory.
*
* Defaults to an empty inventory if null.
*/
public Entity(final Sprite sprite, final Point position, final String name, final Inventory inventory) {
this(sprite, position, name);
if (inventory == null) {
this.inventory = new Inventory(26);
} else {
this.inventory = inventory;
}
}
/**
* Performs all of the entity's actions.
*
* @param controller
* The game controller.
*/
public void performActions(final GameController controller) {
for (final Action action : actions) {
action.perform(controller, this);
}
actions.clear();
}
/**
* Adds an action to the entity.
*
* @param action
* The action.
*/
public void addAction(final Action action) {
if (action == null) {
return;
}
actions.add(action);
}
/**
* Adds a stat to the entity.
*
* @param stat
* The stat.
*/
public void addStat(final Stat stat) {
if (stat == null) {
return;
}
stats.putIfAbsent(stat.getName().toLowerCase(), stat);
}
/**
* Removes a stat, by name, from the entity.
*
* @param name
* The name of the stat.
*/
public void removeStat(final String name) {
if (name == null) {
return;
}
stats.remove(name.toLowerCase());
}
/**
* Adds a move action to the entity, to move it to a new position relative to it's current position.
*
* @param dx
* The change in x-axis position.
*
* @param dy
* The change in y-axis position.
*/
public void move(final int dx, final int dy) {
actions.add(new MoveAction(this.getPosition(), dx, dy));
}
/**
* Sets a new sprite for the entity.
*
* @param sprite
* The sprite.
*/
public void setSprite(Sprite sprite) {
if (sprite == null) {
sprite = Sprite.UNKNOWN;
}
final Tile tile = super.getTileAt(0, 0);
tile.setCharacter(sprite.getCharacter());
tile.setForegroundColor(sprite.getForegroundColor());
tile.setBackgroundColor(sprite.getBackgroundColor());
this.sprite = sprite;
}
/**
* Retrieves the entity's position.
*
* @return
* The entity's position.
*/
public Point getPosition() {
return new Point(super.getTiles().getXPosition(), super.getTiles().getYPosition());
}
/**
* Adds a move action to the entity, to move it to a new position.
*
* Ignores null and negative positions.
*
* @param position
* The new position.
*/
public void setPosition(final Point position) {
if (position == null || position.x < 0 || position.y < 0) {
return;
}
final Point currentPosition = this.getPosition();
final int xDifference = position.x - currentPosition.x;
final int yDifference = position.y - currentPosition.y;
actions.add(new MoveAction(currentPosition, xDifference, yDifference));
}
/**
* Retrieves a stat, by name, from the entity.
*
* @param name
* The name of the stat.
*
* @return
* The stat.
* If the name is null, then null is returned.
* If the entity has no stat that uses the specified name, then null is returned.
*/
public Stat getStat(final String name) {
if (name == null) {
return null;
}
return stats.get(name.toLowerCase());
}
/**
+ * Constructs an information panel, containing a number of important statistics, for the entity.
*
* @return
* The layer containing all of the information.
*/
+ public Layer getInformationPanel() {
final Layer layer = new Layer(new Dimension(40, 8));
// Print border
final RectanglePrinter rectanglePrinter = new RectanglePrinter();
rectanglePrinter.setWidth(40);
rectanglePrinter.setHeight(8);
+ rectanglePrinter.setTitle(this.getName());
rectanglePrinter.print(layer.getTiles(), new Point(0, 0));
// Color name on the border
+ final Color color = sprite.getForegroundColor();
+ final Tile[] nameTiles = layer.getTiles().getRowSubset(0, 2, name.length());
for (final Tile tile : nameTiles) {
tile.setForegroundColor(color);
}
// Retrieve Stats
+ final BoundStat health = (BoundStat) this.getStat("Health");
+ final BoundStat level = (BoundStat) this.getStat("Level");
+ final BoundStat experience = (BoundStat) this.getStat("Experience");
// Create runnable functions, used to add/update labels.
final Runnable add_level = () -> {
+ layer.getComponentsByID(name + "-" + level.getName()).forEach(layer::removeComponent);
final Label label = level.getLabel();
+ label.setId(name + "-" + label.getId());
label.getTiles().setPosition(1, 1);
layer.addComponent(label);
};
final Runnable add_xp = () -> {
+ layer.getComponentsByID(name + "-" + experience.getName()).forEach(layer::removeComponent);
final Label label = experience.getBoundLabel();
+ label.setId(name + "-" + label.getId());
label.getTiles().setPosition(1, 2);
layer.addComponent(label);
};
final Runnable add_health = () -> {
+ layer.getComponentsByID(name + "-" + health.getName()).forEach(layer::removeComponent);
final Label label;
if (health.getValue() > 0) {
label = health.getBoundLabel();
label.getTiles().setPosition(1, 3);
} else {
final LabelBuilder builder = new LabelBuilder();
builder.setText("Health: Deceased");
builder.setPosition(1, 3);
label = builder.build();
}
+ label.setId(name + "-" + label.getId());
layer.addComponent(label);
};
// Add runnable functions to their associated stat.
level.getRunnables().add(add_level);
experience.getRunnables().add(add_xp);
health.getRunnables().add(add_health);
// Run the runnable functions in order to add the labels to the layer.
add_level.run();
add_xp.run();
add_health.run();
return layer;
}
}
Because a container entity cannot attack or be attacked, we don't want its information panel to display the same information as a regular entity. In order to give container entities their own unique information panels, the getInformationPanel() function has been overrided and the total number of items within the container is displayed.
package com.valkryst.VTerminal_Tutorial.entity;
import com.valkryst.VTerminal.Tile;
import com.valkryst.VTerminal.builder.LabelBuilder;
import com.valkryst.VTerminal.component.Layer;
import com.valkryst.VTerminal.printer.RectanglePrinter;
import com.valkryst.VTerminal_Tutorial.Sprite;
import com.valkryst.VTerminal_Tutorial.item.Inventory;
import java.awt.*;
public class Container extends Entity {
/**
* Constructs a new Container.
*
* @param position
* The position of the container within a map.
*
* Defaults to (0, 0) if the position is null or if either part of the coordinate is negative.
*
* @param inventory
* The inventory.
*
* Defaults to an empty inventory if null.
*/
public Container(final Point position, final Inventory inventory) {
super(Sprite.CONTAINER, position, ((inventory == null || inventory.getSize() == 0) ? "Empty Container" : "Container"), inventory);
}
+
+ @Override
+ public Layer getInformationPanel() {
+ final Layer layer = new Layer(new Dimension(40, 8));
+
+ // Print border
+ final RectanglePrinter rectanglePrinter = new RectanglePrinter();
+ rectanglePrinter.setWidth(40);
+ rectanglePrinter.setHeight(8);
+ rectanglePrinter.setTitle(this.getName());
+ rectanglePrinter.print(layer.getTiles(), new Point(0, 0));
+
+ // Color name on the border
+ final Color color = super.getSprite().getForegroundColor();
+ final Tile[] nameTiles = layer.getTiles().getRowSubset(0, 2, super.getName().length());
+ for (final Tile tile : nameTiles) {
+ tile.setForegroundColor(color);
+ }
+
+ // Display Inventory Information
+ final int totalItems = super.getInventory().getTotalItems();
+
+ final LabelBuilder labelBuilder = new LabelBuilder();
+ labelBuilder.setPosition(1, 1);
+
+ if (totalItems == 1) {
+ labelBuilder.setText("There is " + super.getInventory().getTotalItems() + " item here.");
+ } else {
+ labelBuilder.setText("There are " + super.getInventory().getTotalItems() + " items here.");
+ }
+
+ layer.addComponent(labelBuilder.build());
+
+ return layer;
+ }
}
Although unnecessary until this point, we must update the bounding box location of each entity whenever the entity moves. If we neglect to do this, then the mouse targeting code will be unable to determine the correct location of entities on the map.
The bounding box location (bounding box origin) is a variable within VTerminal's Label class, which the Entity class extends.
package com.valkryst.VTerminal_Tutorial.action;
import com.valkryst.VTerminal_Tutorial.LineOfSight;
import com.valkryst.VTerminal_Tutorial.Map;
import com.valkryst.VTerminal_Tutorial.entity.Container;
import com.valkryst.VTerminal_Tutorial.entity.Entity;
import com.valkryst.VTerminal_Tutorial.entity.Player;
import com.valkryst.VTerminal_Tutorial.gui.controller.GameController;
import java.awt.*;
import java.util.List;
public class MoveAction extends Action {
/** The original position of the entity being moved. */
private final Point originalPosition;
/** The position being moved to. */
private final Point newPosition;
/** The change applied to the original x-axis position. */
private final int dx;
/** The change applied to the original y-axis position. */
private final int dy;
/**
* Constructs a new MoveAction.
*
* @param position
* The current position of the entity to move.
*
* @param dx
* The change to apply to the x-axis position.
*
* @param dy
* The change to apply to the y-axis position.
*/
public MoveAction(final Point position, final int dx, final int dy) {
originalPosition = position;
newPosition = new Point(dx + position.x, dy + position.y);
this.dx = dx;
this.dy = dy;
}
@Override
public void perform(final GameController controller, final Entity self) {
final Map map = controller.getModel().getMap();
if (map == null || self == null) {
return;
}
// Attack any enemies at new location:
for (final Entity target : map.getEntities()) {
if (target.getPosition().equals(newPosition)) {
if (target instanceof Container) {
continue;
}
// If the Entity being moved is the player, then we attack non-player entities.
// Else if the Entity being moved isn't the player, then we attack player entities.
if (self instanceof Player) {
if (target instanceof Player == false) {
new AttackAction(target).perform(controller, self);
return;
}
} else {
if (target instanceof Player) {
new AttackAction(target).perform(controller, self);
return;
}
}
}
}
// Move to the new location:
if (map.isPositionFree(newPosition)) {
super.perform(controller, self);
self.getTiles().setPosition(newPosition);
+ self.setBoundingBoxOrigin(newPosition.x, newPosition.y);
if (self instanceof Player) {
final LineOfSight los = self.getLineOfSight();
los.hideLOSOnMap(map);
los.move(dx, dy);
los.showLOSOnMap(map);
} else {
self.getLineOfSight().move(dx, dy);
}
}
}
}
In order to detect mouse clicks on the map, we must add a MouseListener to the GameController's initializeEventHandlers function. Then, within the mouseReleased function of the mouse listener, we'll run our code to update the target information area.
The code is fairly self-explanatory, but feel free to raise a question about it on Discord if you're having trouble understanding it.
package com.valkryst.VTerminal_Tutorial.gui.controller;
import com.valkryst.VTerminal.Screen;
import com.valkryst.VTerminal_Tutorial.Map;
import com.valkryst.VTerminal_Tutorial.Message;
import com.valkryst.VTerminal_Tutorial.entity.Container;
import com.valkryst.VTerminal_Tutorial.entity.Entity;
import com.valkryst.VTerminal_Tutorial.entity.Player;
import com.valkryst.VTerminal_Tutorial.gui.model.GameModel;
import com.valkryst.VTerminal_Tutorial.gui.view.GameView;
import com.valkryst.VTerminal_Tutorial.item.Inventory;
import lombok.Getter;
import javax.swing.*;
import java.awt.*;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
+import java.awt.event.MouseEvent;
+import java.awt.event.MouseListener;
public class GameController extends Controller<GameView, GameModel> {
/** The timer which runs the game loop. */
@Getter private final Timer timer;
/** The controller for the inventory view. */
private final InventoryController inventoryController;
/**
* Constructs a new GameController.
*
* @param screen
* The screen on which the view is displayed.
*/
public GameController(final Screen screen) {
super(new GameView(screen), new GameModel(screen));
initializeEventHandlers(screen);
super.view.addModelComponents(model);
// Create inventory controller
inventoryController = new InventoryController(screen, this);
// Create and start the game-loop timer.
final Map map = model.getMap();
timer = new Timer(16, e -> {
for (final Entity entity : map.getEntities()) {
entity.performActions(this);
}
map.updateLayerTiles();
screen.draw();
});
timer.setInitialDelay(0);
timer.start();
}
/**
* Creates any event handlers required by the view.
*
* @param screen
* The screen on which the view is displayed.
*/
private void initializeEventHandlers(final Screen screen) {
final Player player = super.model.getPlayer();
+ final MouseListener mouseListener = new MouseListener() {
+ @Override
+ public void mouseClicked(final MouseEvent e) {}
+
+ @Override
+ public void mousePressed(final MouseEvent e) {}
+
+ @Override
+ public void mouseReleased(final MouseEvent e) {
+ if (e.getButton() == MouseEvent.BUTTON1) {
+ final Point mousePos = screen.getMousePosition();
+ final Map map = model.getMap();
+
+ // Check if the click was outside the map area.
+ if (mousePos.x < 0 || mousePos.x >= map.getViewWidth()) {
+ return;
+ }
+
+ if (mousePos.y < 0 || mousePos.y >= map.getViewHeight()) {
+ return;
+ }
+
+ // Check if an entity was clicked.
+ for (final Entity entity : map.getEntities()) {
+ if (entity.intersects(mousePos)) {
+ view.displayTargetInformation(entity);
+ return;
+ }
+ }
+
+ view.displayTargetInformation(null);
+ }
+ }
+
+ @Override
+ public void mouseEntered(final MouseEvent e) {}
+
+ @Override
+ public void mouseExited(final MouseEvent e) {}
+ };
final KeyListener keyListener = new KeyListener() {
@Override
public void keyTyped(final KeyEvent e) {
}
@Override
public void keyPressed(final KeyEvent e) {
}
@Override
public void keyReleased(final KeyEvent e) {
switch (e.getKeyCode()) {
case KeyEvent.VK_W:
case KeyEvent.VK_UP: {
player.move(0, -1);
break;
}
case KeyEvent.VK_S:
case KeyEvent.VK_DOWN: {
player.move(0, 1);
break;
}
case KeyEvent.VK_A:
case KeyEvent.VK_LEFT: {
player.move(-1, 0);
break;
}
case KeyEvent.VK_D:
case KeyEvent.VK_RIGHT: {
player.move(1, 0);
break;
}
case KeyEvent.VK_I: {
// Remove this view from the screen.
GameController.super.removeFromScreen(screen);
// If there's a container below the player, open it for looting.
Inventory lootInventory = null;
for (final Entity entity : model.getMap().getEntities()) {
if (entity instanceof Container == false) {
continue;
}
final Point containerPosition = entity.getPosition();
final Point playerPosition = model.getPlayer().getPosition();
if (containerPosition.equals(playerPosition)) {
lootInventory = entity.getInventory();
removeEntityFromMap(entity);
break;
}
}
// Add the new view to the screen.
inventoryController.getModel().setPlayerInventory(model.getPlayer().getInventory());
inventoryController.getModel().setLootInventory(lootInventory);
inventoryController.addToScreen(screen);
break;
}
}
}
};
+ super.getModel().getEventListeners().add(mouseListener);
super.getModel().getEventListeners().add(keyListener);
}
/**
* Adds an entity to the map.
*
* @param entity
* The entity to add.
*/
public void addEntityToMap(final Entity entity) {
if (entity == null) {
return;
}
final Map map = model.getMap();
if (map.getEntities().contains(entity) == false) {
map.getEntities().add(entity);
view.addComponent(entity);
}
}
/**
* Removes an entity from the map.
*
* @param entity
* The entity to remove.
*/
public void removeEntityFromMap(final Entity entity) {
if (entity == null) {
return;
}
final Map map = model.getMap();
map.getEntities().remove(entity);
view.removeComponent(entity);
}
/**
* Adds a message to the message box.
*
* @param message
* The message.
*/
public void displayMessage(final Message message) {
if (message != null) {
view.getMessageBox().appendText(message.getMessage());
}
}
}
Now that mouse targeting has been set-up, we can set the initial target to nothing in the constructor and update all calls to the getInformationPanel() function.
package com.valkryst.VTerminal_Tutorial.gui.view;
import com.valkryst.VTerminal.Screen;
import com.valkryst.VTerminal.builder.TextAreaBuilder;
import com.valkryst.VTerminal.component.Layer;
import com.valkryst.VTerminal.component.TextArea;
import com.valkryst.VTerminal.printer.RectanglePrinter;
import com.valkryst.VTerminal_Tutorial.Map;
import com.valkryst.VTerminal_Tutorial.Sprite;
import com.valkryst.VTerminal_Tutorial.entity.Entity;
import com.valkryst.VTerminal_Tutorial.entity.Player;
import com.valkryst.VTerminal_Tutorial.gui.model.GameModel;
import com.valkryst.VTerminal_Tutorial.item.Equipment;
import com.valkryst.VTerminal_Tutorial.item.EquipmentSlot;
import com.valkryst.VTerminal_Tutorial.statistic.BoundStat;
import com.valkryst.VTerminal_Tutorial.statistic.Stat;
import lombok.Getter;
import java.awt.*;
public class GameView extends View {
/** The area in which messages are displayed. */
@Getter private TextArea messageBox;
/** The currently displayed player information. */
private Layer playerInfoView;
/** The currently displayed target information. */
private Layer targetInfoView;
/**
* Constructs a new GameView.
*
* @param screen
* The screen on which the view is displayed.
*/
public GameView(final Screen screen) {
super(screen);
}
/**
* Adds any components, defined in the model, to the view.
*
* @param model
* The model.
*/
public void addModelComponents(final GameModel model) {
initializeComponents();
final Map map = model.getMap();
final Player player = model.getPlayer();
final Entity enemy = new Entity(Sprite.ENEMY, new Point(15, 12), "Gary");
displayPlayerInformation(player);
+ displayTargetInformation(null);
map.getEntities().add(enemy);
this.addComponent(map);
this.addComponent(player);
player.getLineOfSight().showLOSOnMap(map);
this.addComponent(enemy);
this.addComponent(messageBox);
// Add Equipment to Player
final Equipment sword = new Equipment(Sprite.UNKNOWN, "Sword", "A Sword", null, EquipmentSlot.MAIN_HAND);
sword.addStat(new BoundStat("Damage", 1, 10));
player.getInventory().equip(sword);
// Add Armor to Target
final Equipment shield = new Equipment(Sprite.UNKNOWN, "Shield", "A Shield", null, EquipmentSlot.OFF_HAND);
shield.addStat(new Stat("Armor", 3));
enemy.getInventory().equip(shield);
}
/** Initializes the components. */
private void initializeComponents() {
// Message Box
final TextAreaBuilder builder = new TextAreaBuilder();
builder.setPosition(0, 40);
builder.setWidth(80);
builder.setHeight(5);
builder.setEditable(false);
messageBox = builder.build();
}
/**
* Displays the information of a player entity.
*
* @param player
* The player.
*/
public void displayPlayerInformation(final Player player) {
// Set the information panel.
+ Layer layer = player.getInformationPanel();
layer.getTiles().setPosition(80, 0);
if (playerInfoView != null) {
super.removeComponent(playerInfoView);
}
playerInfoView = layer;
super.addComponent(playerInfoView);
}
/**
* Displays the information of a targeted entity.
*
* @param entity
* The target.
*/
public void displayTargetInformation(final Entity entity) {
final Layer layer;
if (entity == null) {
layer = new Layer(new Dimension(40, 8));
// Print border
final RectanglePrinter rectanglePrinter = new RectanglePrinter();
rectanglePrinter.setWidth(40);
rectanglePrinter.setHeight(8);
rectanglePrinter.setTitle("No Target");
rectanglePrinter.print(layer.getTiles(), new Point(0, 0));
} else {
+ layer = entity.getInformationPanel();
}
layer.getTiles().setPosition(80, 8);
if (targetInfoView != null) {
super.removeComponent(targetInfoView);
}
targetInfoView = layer;
super.addComponent(targetInfoView);
}
}