Subversion Repositories Integrator Subversion

Rev

Details | Last modification | View Log | RSS feed

Rev Author Line No. Line
771 blopes 1
/*
2
 *  Licensed to the Apache Software Foundation (ASF) under one or more
3
 *  contributor license agreements.  See the NOTICE file distributed with
4
 *  this work for additional information regarding copyright ownership.
5
 *  The ASF licenses this file to You under the Apache License, Version 2.0
6
 *  (the "License"); you may not use this file except in compliance with
7
 *  the License.  You may obtain a copy of the License at
8
 *
9
 *      http://www.apache.org/licenses/LICENSE-2.0
10
 *
11
 *  Unless required by applicable law or agreed to in writing, software
12
 *  distributed under the License is distributed on an "AS IS" BASIS,
13
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
 *  See the License for the specific language governing permissions and
15
 *  limitations under the License.
16
 */
17
package websocket.drawboard;
18
 
19
import java.awt.Color;
20
import java.awt.Graphics2D;
21
import java.awt.RenderingHints;
22
import java.awt.image.BufferedImage;
23
import java.io.ByteArrayOutputStream;
24
import java.io.IOException;
25
import java.nio.ByteBuffer;
26
import java.util.ArrayList;
27
import java.util.List;
28
import java.util.Objects;
29
import java.util.Timer;
30
import java.util.TimerTask;
31
import java.util.concurrent.locks.ReentrantLock;
32
 
33
import javax.imageio.ImageIO;
34
 
35
import websocket.drawboard.wsmessages.BinaryWebsocketMessage;
36
import websocket.drawboard.wsmessages.StringWebsocketMessage;
37
 
38
/**
39
 * A Room represents a drawboard where a number of
40
 * users participate.<br><br>
41
 *
42
 * Note: Instance methods should only be invoked by calling
43
 * {@link #invokeAndWait(Runnable)} to ensure access is correctly synchronized.
44
 */
45
public final class Room {
46
 
47
    /**
48
     * Specifies the type of a room message that is sent to a client.<br>
49
     * Note: Currently we are sending simple string messages - for production
50
     * apps, a JSON lib should be used for object-level messages.<br><br>
51
     *
52
     * The number (single char) will be prefixed to the string when sending
53
     * the message.
54
     */
55
    public enum MessageType {
56
        /**
57
         * '0': Error: contains error message.
58
         */
59
        ERROR('0'),
60
        /**
61
         * '1': DrawMessage: contains serialized DrawMessage(s) prefixed
62
         *      with the current Player's {@link Player#lastReceivedMessageId}
63
         *      and ",".<br>
64
         *      Multiple draw messages are concatenated with "|" as separator.
65
         */
66
        DRAW_MESSAGE('1'),
67
        /**
68
         * '2': ImageMessage: Contains number of current players in this room.
69
         *      After this message a Binary Websocket message will follow,
70
         *      containing the current Room image as PNG.<br>
71
         *      This is the first message that a Room sends to a new Player.
72
         */
73
        IMAGE_MESSAGE('2'),
74
        /**
75
         * '3': PlayerChanged: contains "+" or "-" which indicate a player
76
         *      was added or removed to this Room.
77
         */
78
        PLAYER_CHANGED('3');
79
 
80
        private final char flag;
81
 
82
        MessageType(char flag) {
83
            this.flag = flag;
84
        }
85
 
86
    }
87
 
88
 
89
    /**
90
     * The lock used to synchronize access to this Room.
91
     */
92
    private final ReentrantLock roomLock = new ReentrantLock();
93
 
94
    /**
95
     * Indicates if this room has already been shutdown.
96
     */
97
    private volatile boolean closed = false;
98
 
99
    /**
100
     * If <code>true</code>, outgoing DrawMessages will be buffered until the
101
     * drawmessageBroadcastTimer ticks. Otherwise they will be sent
102
     * immediately.
103
     */
104
    private static final boolean BUFFER_DRAW_MESSAGES = true;
105
 
106
    /**
107
     * A timer which sends buffered drawmessages to the client at once
108
     * at a regular interval, to avoid sending a lot of very small
109
     * messages which would cause TCP overhead and high CPU usage.
110
     */
111
    private final Timer drawmessageBroadcastTimer = new Timer();
112
 
113
    private static final int TIMER_DELAY = 30;
114
 
115
    /**
116
     * The current active broadcast timer task. If null, then no Broadcast task is scheduled.
117
     * The Task will be scheduled if the first player enters the Room, and
118
     * cancelled if the last player exits the Room, to avoid unnecessary timer executions.
119
     */
120
    private TimerTask activeBroadcastTimerTask;
121
 
122
 
123
    /**
124
     * The current image of the room drawboard. DrawMessages that are
125
     * received from Players will be drawn onto this image.
126
     */
127
    private final BufferedImage roomImage =
128
            new BufferedImage(900, 600, BufferedImage.TYPE_INT_RGB);
129
    private final Graphics2D roomGraphics = roomImage.createGraphics();
130
 
131
 
132
    /**
133
     * The maximum number of players that can join this room.
134
     */
135
    private static final int MAX_PLAYER_COUNT = 100;
136
 
137
    /**
138
     * List of all currently joined players.
139
     */
140
    private final List<Player> players = new ArrayList<>();
141
 
142
 
143
 
144
    public Room() {
145
        roomGraphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
146
                RenderingHints.VALUE_ANTIALIAS_ON);
147
 
148
        // Clear the image with white background.
149
        roomGraphics.setBackground(Color.WHITE);
150
        roomGraphics.clearRect(0, 0, roomImage.getWidth(),
151
                roomImage.getHeight());
152
    }
153
 
154
    private TimerTask createBroadcastTimerTask() {
155
        return new TimerTask() {
156
            @Override
157
            public void run() {
158
                invokeAndWait(new Runnable() {
159
                    @Override
160
                    public void run() {
161
                        broadcastTimerTick();
162
                    }
163
                });
164
            }
165
        };
166
    }
167
 
168
    /**
169
     * Creates a Player from the given Client and adds it to this room.
170
     *
171
     * @param client the client
172
     *
173
     * @return The newly created player
174
     */
175
    public Player createAndAddPlayer(Client client) {
176
        if (players.size() >= MAX_PLAYER_COUNT) {
177
            throw new IllegalStateException("Maximum player count ("
178
                    + MAX_PLAYER_COUNT + ") has been reached.");
179
        }
180
 
181
        Player p = new Player(this, client);
182
 
183
        // Broadcast to the other players that one player joined.
184
        broadcastRoomMessage(MessageType.PLAYER_CHANGED, "+");
185
 
186
        // Add the new player to the list.
187
        players.add(p);
188
 
189
        // If currently no Broadcast Timer Task is scheduled, then we need to create one.
190
        if (activeBroadcastTimerTask == null) {
191
            activeBroadcastTimerTask = createBroadcastTimerTask();
192
            drawmessageBroadcastTimer.schedule(activeBroadcastTimerTask,
193
                    TIMER_DELAY, TIMER_DELAY);
194
        }
195
 
196
        // Send the current number of players and the current room image.
197
        String content = String.valueOf(players.size());
198
        p.sendRoomMessage(MessageType.IMAGE_MESSAGE, content);
199
 
200
        // Store image as PNG
201
        ByteArrayOutputStream bout = new ByteArrayOutputStream();
202
        try {
203
            ImageIO.write(roomImage, "PNG", bout);
204
        } catch (IOException ignore) {
205
            // Should never happen
206
        }
207
 
208
 
209
        // Send the image as binary message.
210
        BinaryWebsocketMessage msg = new BinaryWebsocketMessage(
211
                ByteBuffer.wrap(bout.toByteArray()));
212
        p.getClient().sendMessage(msg);
213
 
214
        return p;
215
 
216
    }
217
 
218
    /**
219
     * @see Player#removeFromRoom()
220
     * @param p player to remove
221
     */
222
    private void internalRemovePlayer(Player p) {
223
        boolean removed = players.remove(p);
224
        assert removed;
225
 
226
        // If the last player left the Room, we need to cancel the Broadcast Timer Task.
227
        if (players.size() == 0) {
228
            // Cancel the task.
229
            // Note that it can happen that the TimerTask is just about to execute (from
230
            // the Timer thread) but waits until all players are gone (or even until a new
231
            // player is added to the list), and then executes. This is OK. To prevent it,
232
            // a TimerTask subclass would need to have some boolean "cancel" instance variable and
233
            // query it in the invocation of Room#invokeAndWait.
234
            activeBroadcastTimerTask.cancel();
235
            activeBroadcastTimerTask = null;
236
        }
237
 
238
        // Broadcast that one player is removed.
239
        broadcastRoomMessage(MessageType.PLAYER_CHANGED, "-");
240
    }
241
 
242
    /**
243
     * @see Player#handleDrawMessage(DrawMessage, long)
244
     * @param p player
245
     * @param msg message containing details of new shapes to draw
246
     * @param msgId message ID
247
     */
248
    private void internalHandleDrawMessage(Player p, DrawMessage msg,
249
            long msgId) {
250
        p.setLastReceivedMessageId(msgId);
251
 
252
        // Draw the RoomMessage onto our Room Image.
253
        msg.draw(roomGraphics);
254
 
255
        // Broadcast the Draw Message.
256
        broadcastDrawMessage(msg);
257
    }
258
 
259
 
260
    /**
261
     * Broadcasts the given drawboard message to all connected players.<br>
262
     * Note: For DrawMessages, please use
263
     * {@link #broadcastDrawMessage(DrawMessage)}
264
     * as this method will buffer them and prefix them with the correct
265
     * last received Message ID.
266
     * @param type message type
267
     * @param content message content
268
     */
269
    private void broadcastRoomMessage(MessageType type, String content) {
270
        for (Player p : players) {
271
            p.sendRoomMessage(type, content);
272
        }
273
    }
274
 
275
 
276
    /**
277
     * Broadcast the given DrawMessage. This will buffer the message
278
     * and the {@link #drawmessageBroadcastTimer} will broadcast them
279
     * at a regular interval, prefixing them with the player's current
280
     * {@link Player#lastReceivedMessageId}.
281
     * @param msg message to broadcast
282
     */
283
    private void broadcastDrawMessage(DrawMessage msg) {
284
        if (!BUFFER_DRAW_MESSAGES) {
285
            String msgStr = msg.toString();
286
 
287
            for (Player p : players) {
288
                String s = String.valueOf(p.getLastReceivedMessageId())
289
                        + "," + msgStr;
290
                p.sendRoomMessage(MessageType.DRAW_MESSAGE, s);
291
            }
292
        } else {
293
            for (Player p : players) {
294
                p.getBufferedDrawMessages().add(msg);
295
            }
296
        }
297
    }
298
 
299
 
300
    /**
301
     * Tick handler for the broadcastTimer.
302
     */
303
    private void broadcastTimerTick() {
304
        // For each Player, send all per Player buffered
305
        // DrawMessages, prefixing each DrawMessage with the player's
306
        // lastReceivedMessageId.
307
        // Multiple messages are concatenated with "|".
308
 
309
        for (Player p : players) {
310
 
311
            StringBuilder sb = new StringBuilder();
312
            List<DrawMessage> drawMessages = p.getBufferedDrawMessages();
313
 
314
            if (drawMessages.size() > 0) {
315
                for (int i = 0; i < drawMessages.size(); i++) {
316
                    DrawMessage msg = drawMessages.get(i);
317
 
318
                    String s = String.valueOf(p.getLastReceivedMessageId())
319
                            + "," + msg.toString();
320
                    if (i > 0) {
321
                        sb.append('|');
322
                    }
323
 
324
                    sb.append(s);
325
                }
326
                drawMessages.clear();
327
 
328
                p.sendRoomMessage(MessageType.DRAW_MESSAGE, sb.toString());
329
            }
330
        }
331
    }
332
 
333
    /**
334
     * A list of cached {@link Runnable}s to prevent recursive invocation of Runnables
335
     * by one thread. This variable is only used by one thread at a time and then
336
     * set to <code>null</code>.
337
     */
338
    private List<Runnable> cachedRunnables = null;
339
 
340
    /**
341
     * Submits the given Runnable to the Room Executor and waits until it
342
     * has been executed. Currently, this simply means that the Runnable
343
     * will be run directly inside of a synchronized() block.<br>
344
     * Note that if a runnable recursively calls invokeAndWait() with another
345
     * runnable on this Room, it will not be executed recursively, but instead
346
     * cached until the original runnable is finished, to keep the behavior of
347
     * using an Executor.
348
     *
349
     * @param task The task to be executed
350
     */
351
    public void invokeAndWait(Runnable task)  {
352
 
353
        // Check if the current thread already holds a lock on this room.
354
        // If yes, then we must not directly execute the Runnable but instead
355
        // cache it until the original invokeAndWait() has finished.
356
        if (roomLock.isHeldByCurrentThread()) {
357
 
358
            if (cachedRunnables == null) {
359
                cachedRunnables = new ArrayList<>();
360
            }
361
            cachedRunnables.add(task);
362
 
363
        } else {
364
 
365
            roomLock.lock();
366
            try {
367
                // Explicitly overwrite value to ensure data consistency in
368
                // current thread
369
                cachedRunnables = null;
370
 
371
                if (!closed) {
372
                    task.run();
373
                }
374
 
375
                // Run the cached runnables.
376
                if (cachedRunnables != null) {
377
                    for (Runnable cachedRunnable : cachedRunnables) {
378
                        if (!closed) {
379
                            cachedRunnable.run();
380
                        }
381
                    }
382
                    cachedRunnables = null;
383
                }
384
 
385
            } finally {
386
                roomLock.unlock();
387
            }
388
 
389
        }
390
 
391
    }
392
 
393
    /**
394
     * Shuts down the roomExecutor and the drawmessageBroadcastTimer.
395
     */
396
    public void shutdown() {
397
        invokeAndWait(new Runnable() {
398
            @Override
399
            public void run() {
400
                closed = true;
401
                drawmessageBroadcastTimer.cancel();
402
                roomGraphics.dispose();
403
            }
404
        });
405
    }
406
 
407
 
408
    /**
409
     * A Player participates in a Room. It is the interface between the
410
     * {@link Room} and the {@link Client}.<br><br>
411
     *
412
     * Note: This means a player object is actually a join between Room and
413
     * Client.
414
     */
415
    public static final class Player {
416
 
417
        /**
418
         * The room to which this player belongs.
419
         */
420
        private Room room;
421
 
422
        /**
423
         * The room buffers the last draw message ID that was received from
424
         * this player.
425
         */
426
        private long lastReceivedMessageId = 0;
427
 
428
        private final Client client;
429
 
430
        /**
431
         * Buffered DrawMessages that will be sent by a Timer.
432
         */
433
        private final List<DrawMessage> bufferedDrawMessages =
434
                new ArrayList<>();
435
 
436
        private List<DrawMessage> getBufferedDrawMessages() {
437
            return bufferedDrawMessages;
438
        }
439
 
440
        private Player(Room room, Client client) {
441
            this.room = room;
442
            this.client = client;
443
        }
444
 
445
        public Room getRoom() {
446
            return room;
447
        }
448
 
449
        public Client getClient() {
450
            return client;
451
        }
452
 
453
        /**
454
         * Removes this player from its room, e.g. when
455
         * the client disconnects.
456
         */
457
        public void removeFromRoom() {
458
            if (room != null) {
459
                room.internalRemovePlayer(this);
460
                room = null;
461
            }
462
        }
463
 
464
 
465
        private long getLastReceivedMessageId() {
466
            return lastReceivedMessageId;
467
        }
468
        private void setLastReceivedMessageId(long value) {
469
            lastReceivedMessageId = value;
470
        }
471
 
472
 
473
        /**
474
         * Handles the given DrawMessage by drawing it onto this Room's
475
         * image and by broadcasting it to the connected players.
476
         *
477
         * @param msg   The draw message received
478
         * @param msgId The ID for the draw message received
479
         */
480
        public void handleDrawMessage(DrawMessage msg, long msgId) {
481
            room.internalHandleDrawMessage(this, msg, msgId);
482
        }
483
 
484
 
485
        /**
486
         * Sends the given room message.
487
         * @param type message type
488
         * @param content message content
489
         */
490
        private void sendRoomMessage(MessageType type, String content) {
491
            Objects.requireNonNull(content);
492
            Objects.requireNonNull(type);
493
 
494
            String completeMsg = String.valueOf(type.flag) + content;
495
 
496
            client.sendMessage(new StringWebsocketMessage(completeMsg));
497
        }
498
    }
499
}