/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package websocket.drawboard;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.locks.ReentrantLock;
import javax.imageio.ImageIO;
import websocket.drawboard.wsmessages.BinaryWebsocketMessage;
import websocket.drawboard.wsmessages.StringWebsocketMessage;
/**
* A Room represents a drawboard where a number of
* users participate.<br><br>
*
* Note: Instance methods should only be invoked by calling
* {@link #invokeAndWait(Runnable)} to ensure access is correctly synchronized.
*/
public final class Room {
/**
* Specifies the type of a room message that is sent to a client.<br>
* Note: Currently we are sending simple string messages - for production
* apps, a JSON lib should be used for object-level messages.<br><br>
*
* The number (single char) will be prefixed to the string when sending
* the message.
*/
public static enum MessageType {
/**
* '0': Error: contains error message.
*/
ERROR('0'),
/**
* '1': DrawMessage: contains serialized DrawMessage(s) prefixed
* with the current Player's {@link Player#lastReceivedMessageId}
* and ",".<br>
* Multiple draw messages are concatenated with "|" as separator.
*/
DRAW_MESSAGE('1'),
/**
* '2': ImageMessage: Contains number of current players in this room.
* After this message a Binary Websocket message will follow,
* containing the current Room image as PNG.<br>
* This is the first message that a Room sends to a new Player.
*/
IMAGE_MESSAGE('2'),
/**
* '3': PlayerChanged: contains "+" or "-" which indicate a player
* was added or removed to this Room.
*/
PLAYER_CHANGED('3');
private final char flag;
private MessageType(char flag) {
this.flag = flag;
}
}
/**
* The lock used to synchronize access to this Room.
*/
private final ReentrantLock roomLock = new ReentrantLock();
/**
* Indicates if this room has already been shutdown.
*/
private volatile boolean closed = false;
/**
* If <code>true</code>, outgoing DrawMessages will be buffered until the
* drawmessageBroadcastTimer ticks. Otherwise they will be sent
* immediately.
*/
private static final boolean BUFFER_DRAW_MESSAGES = true;
/**
* A timer which sends buffered drawmessages to the client at once
* at a regular interval, to avoid sending a lot of very small
* messages which would cause TCP overhead and high CPU usage.
*/
private final Timer drawmessageBroadcastTimer = new Timer();
private static final int TIMER_DELAY = 30;
/**
* The current active broadcast timer task. If null, then no Broadcast task is scheduled.
* The Task will be scheduled if the first player enters the Room, and
* cancelled if the last player exits the Room, to avoid unnecessary timer executions.
*/
private TimerTask activeBroadcastTimerTask;
/**
* The current image of the room drawboard. DrawMessages that are
* received from Players will be drawn onto this image.
*/
private final BufferedImage roomImage =
new BufferedImage(900, 600, BufferedImage.TYPE_INT_RGB);
private final Graphics2D roomGraphics = roomImage.createGraphics();
/**
* The maximum number of players that can join this room.
*/
private static final int MAX_PLAYER_COUNT = 100;
/**
* List of all currently joined players.
*/
private final List<Player> players = new ArrayList<>();
public Room() {
roomGraphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
// Clear the image with white background.
roomGraphics.setBackground(Color.WHITE);
roomGraphics.clearRect(0, 0, roomImage.getWidth(),
roomImage.getHeight());
}
private TimerTask createBroadcastTimerTask() {
return new TimerTask() {
@Override
public void run() {
invokeAndWait(new Runnable() {
@Override
public void run() {
broadcastTimerTick();
}
});
}
};
}
/**
* Creates a Player from the given Client and adds it to this room.
*
* @param client the client
*
* @return The newly created player
*/
public Player createAndAddPlayer(Client client) {
if (players.size() >= MAX_PLAYER_COUNT) {
throw new IllegalStateException("Maximum player count ("
+ MAX_PLAYER_COUNT + ") has been reached.");
}
Player p = new Player(this, client);
// Broadcast to the other players that one player joined.
broadcastRoomMessage(MessageType.PLAYER_CHANGED, "+");
// Add the new player to the list.
players.add(p);
// If currently no Broadcast Timer Task is scheduled, then we need to create one.
if (activeBroadcastTimerTask == null) {
activeBroadcastTimerTask = createBroadcastTimerTask();
drawmessageBroadcastTimer.schedule(activeBroadcastTimerTask,
TIMER_DELAY, TIMER_DELAY);
}
// Send him the current number of players and the current room image.
String content = String.valueOf(players.size());
p.sendRoomMessage(MessageType.IMAGE_MESSAGE, content);
// Store image as PNG
ByteArrayOutputStream bout = new ByteArrayOutputStream();
try {
ImageIO.write(roomImage, "PNG", bout);
} catch (IOException e) { /* Should never happen */ }
// Send the image as binary message.
BinaryWebsocketMessage msg = new BinaryWebsocketMessage(
ByteBuffer.wrap(bout.toByteArray()));
p.getClient().sendMessage(msg);
return p;
}
/**
* @see Player#removeFromRoom()
* @param p
*/
private void internalRemovePlayer(Player p) {
boolean removed = players.remove(p);
assert removed;
// If the last player left the Room, we need to cancel the Broadcast Timer Task.
if (players.size() == 0) {
// Cancel the task.
// Note that it can happen that the TimerTask is just about to execute (from
// the Timer thread) but waits until all players are gone (or even until a new
// player is added to the list), and then executes. This is OK. To prevent it,
// a TimerTask subclass would need to have some boolean "cancel" instance variable and
// query it in the invocation of Room#invokeAndWait.
activeBroadcastTimerTask.cancel();
activeBroadcastTimerTask = null;
}
// Broadcast that one player is removed.
broadcastRoomMessage(MessageType.PLAYER_CHANGED, "-");
}
/**
* @see Player#handleDrawMessage(DrawMessage, long)
* @param p
* @param msg
* @param msgId
*/
private void internalHandleDrawMessage(Player p, DrawMessage msg,
long msgId) {
p.setLastReceivedMessageId(msgId);
// Draw the RoomMessage onto our Room Image.
msg.draw(roomGraphics);
// Broadcast the Draw Message.
broadcastDrawMessage(msg);
}
/**
* Broadcasts the given drawboard message to all connected players.<br>
* Note: For DrawMessages, please use
* {@link #broadcastDrawMessage(DrawMessage)}
* as this method will buffer them and prefix them with the correct
* last received Message ID.
* @param type
* @param content
*/
private void broadcastRoomMessage(MessageType type, String content) {
for (Player p : players) {
p.sendRoomMessage(type, content);
}
}
/**
* Broadcast the given DrawMessage. This will buffer the message
* and the {@link #drawmessageBroadcastTimer} will broadcast them
* at a regular interval, prefixing them with the player's current
* {@link Player#lastReceivedMessageId}.
* @param msg
*/
private void broadcastDrawMessage(DrawMessage msg) {
if (!BUFFER_DRAW_MESSAGES) {
String msgStr = msg.toString();
for (Player p : players) {
String s = String.valueOf(p.getLastReceivedMessageId())
+ "," + msgStr;
p.sendRoomMessage(MessageType.DRAW_MESSAGE, s);
}
} else {
for (Player p : players) {
p.getBufferedDrawMessages().add(msg);
}
}
}
/**
* Tick handler for the broadcastTimer.
*/
private void broadcastTimerTick() {
// For each Player, send all per Player buffered
// DrawMessages, prefixing each DrawMessage with the player's
// lastReceivedMessageId.
// Multiple messages are concatenated with "|".
for (Player p : players) {
StringBuilder sb = new StringBuilder();
List<DrawMessage> drawMessages = p.getBufferedDrawMessages();
if (drawMessages.size() > 0) {
for (int i = 0; i < drawMessages.size(); i++) {
DrawMessage msg = drawMessages.get(i);
String s = String.valueOf(p.getLastReceivedMessageId())
+ "," + msg.toString();
if (i > 0)
sb.append("|");
sb.append(s);
}
drawMessages.clear();
p.sendRoomMessage(MessageType.DRAW_MESSAGE, sb.toString());
}
}
}
/**
* A list of cached {@link Runnable}s to prevent recursive invocation of Runnables
* by one thread. This variable is only used by one thread at a time and then
* set to <code>null</code>.
*/
private List<Runnable> cachedRunnables = null;
/**
* Submits the given Runnable to the Room Executor and waits until it
* has been executed. Currently, this simply means that the Runnable
* will be run directly inside of a synchronized() block.<br>
* Note that if a runnable recursively calls invokeAndWait() with another
* runnable on this Room, it will not be executed recursively, but instead
* cached until the original runnable is finished, to keep the behavior of
* using a Executor.
*
* @param task The task to be executed
*/
public void invokeAndWait(Runnable task) {
// Check if the current thread already holds a lock on this room.
// If yes, then we must not directly execute the Runnable but instead
// cache it until the original invokeAndWait() has finished.
if (roomLock.isHeldByCurrentThread()) {
if (cachedRunnables == null) {
cachedRunnables = new ArrayList<>();
}
cachedRunnables.add(task);
} else {
roomLock.lock();
try {
// Explicitly overwrite value to ensure data consistency in
// current thread
cachedRunnables = null;
if (!closed) {
task.run();
}
// Run the cached runnables.
if (cachedRunnables != null) {
for (int i = 0; i < cachedRunnables.size(); i++) {
if (!closed) {
cachedRunnables.get(i).run();
}
}
cachedRunnables = null;
}
} finally {
roomLock.unlock();
}
}
}
/**
* Shuts down the roomExecutor and the drawmessageBroadcastTimer.
*/
public void shutdown() {
invokeAndWait(new Runnable() {
@Override
public void run() {
closed = true;
drawmessageBroadcastTimer.cancel();
roomGraphics.dispose();
}
});
}
/**
* A Player participates in a Room. It is the interface between the
* {@link Room} and the {@link Client}.<br><br>
*
* Note: This means a player object is actually a join between Room and
* Client.
*/
public final class Player {
/**
* The room to which this player belongs.
*/
private Room room;
/**
* The room buffers the last draw message ID that was received from
* this player.
*/
private long lastReceivedMessageId = 0;
private final Client client;
/**
* Buffered DrawMessages that will be sent by a Timer.
*/
private final List<DrawMessage> bufferedDrawMessages =
new ArrayList<>();
private List<DrawMessage> getBufferedDrawMessages() {
return bufferedDrawMessages;
}
private Player(Room room, Client client) {
this.room = room;
this.client = client;
}
public Room getRoom() {
return room;
}
public Client getClient() {
return client;
}
/**
* Removes this player from its room, e.g. when
* the client disconnects.
*/
public void removeFromRoom() {
if (room != null) {
room.internalRemovePlayer(this);
room = null;
}
}
private long getLastReceivedMessageId() {
return lastReceivedMessageId;
}
private void setLastReceivedMessageId(long value) {
lastReceivedMessageId = value;
}
/**
* Handles the given DrawMessage by drawing it onto this Room's
* image and by broadcasting it to the connected players.
*
* @param msg The draw message received
* @param msgId The ID for the draw message received
*/
public void handleDrawMessage(DrawMessage msg, long msgId) {
room.internalHandleDrawMessage(this, msg, msgId);
}
/**
* Sends the given room message.
* @param type
* @param content
*/
private void sendRoomMessage(MessageType type, String content) {
Objects.requireNonNull(content);
Objects.requireNonNull(type);
String completeMsg = String.valueOf(type.flag) + content;
client.sendMessage(new StringWebsocketMessage(completeMsg));
}
}
}