Redesign with game rooms

This commit is contained in:
Charles Gould 2017-01-17 22:17:54 -05:00
parent 9dd9a212a5
commit cde596132c
15 changed files with 802 additions and 336 deletions

View File

@ -0,0 +1,35 @@
package lingo.client.api;
public class Destinations {
public static final String CHAT = topicDestination("chat");
public static final String GAME_CLOSED = topicDestination("gameClosed");
public static final String GAME_HOSTED = topicDestination("gameHosted");
public static final String GAME_JOINED = topicDestination("gameJoined");
public static final String GAME_LEFT = topicDestination("gameLeft");
public static final String GAME_STARTED = topicDestination("gameStarted");
public static final String OPPONENT_JOINED = topicDestination("opponentJoined");
public static final String OPPONENT_REPORTS = topicDestination("opponentReports");
public static final String PLAYER_REPORTS = topicDestination("playerReports");
public static final String PRACTICE_GAME = topicDestination("practiceGame");
public static final String PRACTICE_REPORTS = topicDestination("practiceReports");
public static final String SESSION_USERNAME = topicDestination("sessionUsername");
public static final String USER_JOINED = topicDestination("userJoined");
private static String topicDestination(String suffix) {
return "/topic/" + suffix;
}
}

View File

@ -1,27 +0,0 @@
package lingo.client.api;
public class StompTopics {
public static final String CHAT = createTopicName("chat");
public static final String GAME_STARTED = createTopicName("gameStarted");
public static final String OPPONENT_JOINED = createTopicName("opponentJoined");
public static final String OPPONENT_LEFT = createTopicName("opponentLeft");
public static final String OPPONENT_REPORTS = createTopicName("opponentReports");
public static final String PLAYER_REPORTS = createTopicName("playerReports");
public static final String PRACTICE_GAME = createTopicName("practiceGame");
public static final String PRACTICE_REPORTS = createTopicName("practiceReports");
public static final String USER_JOINED = createTopicName("userJoined");
private static String createTopicName(String suffix) {
return "/topic/lingo/" + suffix;
}
}

View File

@ -29,7 +29,7 @@ import javafx.scene.paint.Color;
import javafx.scene.text.Font; import javafx.scene.text.Font;
import javafx.scene.text.TextAlignment; import javafx.scene.text.TextAlignment;
import javafx.scene.web.WebView; import javafx.scene.web.WebView;
import lingo.client.api.StompTopics; import lingo.client.api.Destinations;
import lingo.client.util.FxmlController; import lingo.client.util.FxmlController;
import lingo.client.view.Board; import lingo.client.view.Board;
import lingo.client.view.OpponentBoard; import lingo.client.view.OpponentBoard;
@ -154,13 +154,13 @@ public class MultiplayerPresenter implements FxmlController {
@PostConstruct @PostConstruct
private void postConstruct() { private void postConstruct() {
executorService.execute(() -> { executorService.execute(() -> {
stompTemplate.subscribe("/user" + StompTopics.OPPONENT_JOINED, new OpponentJoinedHandler(), stompTemplate.subscribe("/user" + Destinations.OPPONENT_JOINED, new OpponentJoinedHandler(),
subscription -> subscriptionsLatch.countDown()); subscription -> subscriptionsLatch.countDown());
stompTemplate.subscribe("/user" + StompTopics.OPPONENT_LEFT, new OpponentLeftHandler(), stompTemplate.subscribe("/user" + Destinations.OPPONENT_LEFT, new OpponentLeftHandler(),
subscription -> subscriptionsLatch.countDown()); subscription -> subscriptionsLatch.countDown());
stompTemplate.subscribe("/user" + StompTopics.OPPONENT_REPORTS, new OpponentReportHandler(), stompTemplate.subscribe("/user" + Destinations.OPPONENT_REPORTS, new OpponentReportHandler(),
subscription -> subscriptionsLatch.countDown()); subscription -> subscriptionsLatch.countDown());
stompTemplate.subscribe("/user" + StompTopics.PLAYER_REPORTS, new PlayerReportHandler(), stompTemplate.subscribe("/user" + Destinations.PLAYER_REPORTS, new PlayerReportHandler(),
subscription -> subscriptionsLatch.countDown()); subscription -> subscriptionsLatch.countDown());
}); });
} }

View File

@ -3,6 +3,7 @@ package lingo.common;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
public class Game { public class Game {
@ -12,23 +13,25 @@ public class Game {
public static final int WORD_LENGTH = 5; public static final int WORD_LENGTH = 5;
public static final int[] INVALID_GUESS = new int[] { 9, 9, 9, 9, 9 }; public static final int[] INVALID_GUESS = new int[] { 9, 9, 9, 9, 9 };
public final String playerOne; private static final AtomicInteger idCounter = new AtomicInteger(0);
public final String playerTwo; public final int id;
private final Set<String> acceptableGuesses; private Player host;
private final List<String> possibleWords; private Player challenger;
private Set<String> acceptableGuesses;
private List<String> possibleWords;
private String word; private String word;
private int wordIndex = 0; private int wordIndex = 0;
public Game(String playerOne, String playerTwo, List<String> possibleWords, Set<String> acceptableGuesses) { public Game(Player host) {
this.playerOne = playerOne; this.id = idCounter.incrementAndGet();
this.playerTwo = playerTwo; this.host = host;
this.possibleWords = possibleWords;
this.acceptableGuesses = acceptableGuesses;
} }
private static int indexOf(char[] array, char searchTerm) { private static int indexOf(char[] array, char searchTerm) {
@ -77,6 +80,14 @@ public class Game {
return result; return result;
} }
public Player getChallenger() {
return challenger;
}
public Player getHost() {
return host;
}
public String newGame() { public String newGame() {
Collections.shuffle(possibleWords); Collections.shuffle(possibleWords);
wordIndex = 0; wordIndex = 0;
@ -88,4 +99,20 @@ public class Game {
return word; return word;
} }
public void setAcceptableGuesses(Set<String> value) {
this.acceptableGuesses = value;
}
public void setChallenger(Player value) {
this.challenger = value;
}
public void setHost(Player value) {
this.host = value;
}
public void setPossibleWords(List<String> value) {
this.possibleWords = value;
}
} }

View File

@ -0,0 +1,32 @@
package lingo.common;
public class GameLeftMessage {
private Game game;
private Player gameLeaver;
public GameLeftMessage() {}
public GameLeftMessage(Game game, Player gameLeaver) {
this.game = game;
this.gameLeaver = gameLeaver;
}
public Game getGame() {
return game;
}
public Player getGameLeaver() {
return gameLeaver;
}
public void setGame(Game game) {
this.game = game;
}
public void setGameLeaver(Player gameLeaver) {
this.gameLeaver = gameLeaver;
}
}

View File

@ -0,0 +1,25 @@
package lingo.common;
public class Player {
private final String sessionId;
private String username;
public Player(String sessionId) {
this.sessionId = sessionId;
}
public String getSessionId() {
return sessionId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}

View File

@ -0,0 +1,43 @@
package lingo.common;
public class SetUsernameMessage {
private String errorMessage;
private boolean success;
private String username;
public SetUsernameMessage() {}
public SetUsernameMessage(boolean success, String username, String errorMessage) {
this.errorMessage = errorMessage;
this.success = success;
this.username = username;
}
public String getErrorMessage() {
return errorMessage;
}
public boolean isSuccess() {
return success;
}
public String getUsername() {
return username;
}
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
public void setSuccess(boolean success) {
this.success = success;
}
public void setUsername(String username) {
this.username = username;
}
}

View File

@ -2,12 +2,12 @@ package lingo.server;
import static org.springframework.messaging.simp.SimpMessageHeaderAccessor.SESSION_ID_HEADER; import static org.springframework.messaging.simp.SimpMessageHeaderAccessor.SESSION_ID_HEADER;
import java.util.ArrayList; import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.HashSet;
import java.util.Map; import java.util.Map;
import java.util.Set;
import javax.annotation.PostConstruct; import java.util.TreeMap;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -19,19 +19,23 @@ import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessageType; import org.springframework.messaging.simp.SimpMessageType;
import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.annotation.SubscribeMapping;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.socket.messaging.AbstractSubProtocolEvent; import org.springframework.web.socket.messaging.AbstractSubProtocolEvent;
import org.springframework.web.socket.messaging.SessionConnectedEvent; import org.springframework.web.socket.messaging.SessionConnectedEvent;
import org.springframework.web.socket.messaging.SessionDisconnectEvent; import org.springframework.web.socket.messaging.SessionDisconnectEvent;
import lingo.client.api.StompTopics; import lingo.client.api.Destinations;
import lingo.common.ChatMessage; import lingo.common.ChatMessage;
import lingo.common.Game; import lingo.common.Game;
import lingo.common.GameLeftMessage;
import lingo.common.Player;
import lingo.common.Report; import lingo.common.Report;
import lingo.common.SetUsernameMessage;
@Controller @RestController
@MessageMapping("/lingo")
public class LingoController implements ApplicationListener<AbstractSubProtocolEvent> { public class LingoController implements ApplicationListener<AbstractSubProtocolEvent> {
private static final Logger log = LoggerFactory.getLogger(LingoController.class); private static final Logger log = LoggerFactory.getLogger(LingoController.class);
@ -42,25 +46,41 @@ public class LingoController implements ApplicationListener<AbstractSubProtocolE
@Autowired @Autowired
private WordRepository wordRepo; private WordRepository wordRepo;
private final List<String> waitingList = new ArrayList<>(); private final Map<Integer, Game> gameById = new TreeMap<>();
private final Map<String, Game> gameBySession = new HashMap<>(); private final Map<Player, Game> gameByPlayer = new HashMap<>();
private final Map<String, Game> practiceBySession = new HashMap<>(); private final Map<Player, Game> practiceByPlayer = new HashMap<>();
private final Map<String, String> usernameBySession = new HashMap<>(); private final Map<String, Player> playerBySession = new HashMap<>();
private final Set<String> usernames = new HashSet<>();
@MessageMapping("/chat") @MessageMapping("/chat")
public ChatMessage chat(String message, @Header(SESSION_ID_HEADER) String sessionId) { public ChatMessage chat(String message, @Header(SESSION_ID_HEADER) String sessionId) {
final String username = usernameBySession.get(sessionId); final Player player = playerBySession.get(sessionId);
return new ChatMessage(username, message); if (player == null) {
log.warn("No player for session {}", sessionId);
throw new IllegalStateException("No player for session " + sessionId);
}
return new ChatMessage(player.getUsername(), message);
}
@RequestMapping("/games")
public Collection<Game> getGames() {
return gameById.values();
} }
@MessageMapping("/guess") @MessageMapping("/guess")
public void guess(String guess, @Header(SESSION_ID_HEADER) String sessionId) { public void guess(String guess, @Header(SESSION_ID_HEADER) String sessionId) {
final Player player = playerBySession.get(sessionId);
if (player == null) {
log.warn("No player for session {}", sessionId);
return;
}
guess = guess.toUpperCase(); guess = guess.toUpperCase();
log.info("Player {} guessed: {}", sessionId, guess); log.info("{} guessed {}", sessionId, guess);
final Game game = gameBySession.get(sessionId); final Game game = gameByPlayer.get(player);
final int[] result = game.evaluate(guess); final int[] result = game.evaluate(guess);
// Generate reports // Generate reports
@ -80,49 +100,123 @@ public class LingoController implements ApplicationListener<AbstractSubProtocolE
playerReport.setResult(result); playerReport.setResult(result);
opponentReport.setResult(result); opponentReport.setResult(result);
} }
final String opponentId = sessionId.equals(game.playerOne) ? game.playerTwo : game.playerOne; final Player opponent;
sendToUser(sessionId, StompTopics.PLAYER_REPORTS, playerReport); if (sessionId.equals(game.getHost().getSessionId())) {
sendToUser(opponentId, StompTopics.OPPONENT_REPORTS, opponentReport); opponent = game.getChallenger();
}
@MessageMapping("/join")
public void join(String username, @Header(SESSION_ID_HEADER) String sessionId) {
log.info("Player joined: {}, {}", sessionId, username);
usernameBySession.put(sessionId, username);
send(StompTopics.USER_JOINED, username);
joinWaitingList(sessionId);
}
private void joinWaitingList(String sessionId) {
synchronized (waitingList) {
if (!waitingList.contains(sessionId)) {
waitingList.add(sessionId);
waitingList.notify();
}
}
}
private void leave(String sessionId) {
final String username = usernameBySession.remove(sessionId);
if (username != null) {
send(StompTopics.CHAT, new ChatMessage(null, username + " left"));
}
final Game game = gameBySession.remove(sessionId);
if (game == null) {
leaveWaitingList(sessionId);
} else { } else {
log.info("Player {} left their game!", sessionId); opponent = game.getHost();
final String opponentId = sessionId.equals(game.playerOne) ? game.playerTwo : game.playerOne; }
gameBySession.remove(opponentId); sendToPlayer(player, Destinations.PLAYER_REPORTS, playerReport);
sendToUser(opponentId, StompTopics.OPPONENT_LEFT, "You win!"); sendToPlayer(opponent, Destinations.OPPONENT_REPORTS, opponentReport);
joinWaitingList(opponentId); }
@MessageMapping("/hostGame")
public synchronized void hostGame(@Header(SESSION_ID_HEADER) String sessionId) {
final Player player = playerBySession.get(sessionId);
if (player == null) {
log.warn("No player for session {}", sessionId);
return;
}
if (gameByPlayer.containsKey(player)) {
log.warn("{} is in a game already", player.getUsername());
return;
}
final Game game = new Game(player);
gameById.put(game.id, game);
gameByPlayer.put(player, game);
log.info("{} hosted a game", player.getUsername());
send(Destinations.GAME_HOSTED, game);
}
@MessageMapping("/joinGame")
public synchronized void joinGame(Integer gameId, @Header(SESSION_ID_HEADER) String sessionId) {
final Player player = playerBySession.get(sessionId);
if (player == null) {
log.warn("No player for session {}", sessionId);
return;
}
if (gameByPlayer.containsKey(player)) {
log.warn("{} is in a game already", player.getUsername());
return;
}
final Game game = gameById.get(gameId);
if (game == null) {
log.warn("No game with id {}", gameId);
return;
}
if (game.getChallenger() == null) {
game.setChallenger(player);
gameByPlayer.put(player, game);
log.info("{} joined {}'s game", player.getUsername(), game.getHost());
send(Destinations.GAME_JOINED, game);
// Start the game immediately
// TODO: require the players to "ready up"
game.setAcceptableGuesses(wordRepo.getGuesses());
game.setPossibleWords(wordRepo.getWords());
final String firstWord = game.newGame();
final String firstLetter = String.valueOf(firstWord.charAt(0));
log.info("First word: {}", firstWord);
final Player playerOne = game.getHost();
final Player playerTwo = game.getChallenger();
final String[] playerOneMessage = new String[] { firstLetter, playerTwo.getUsername() };
final String[] playerTwoMessage = new String[] { firstLetter, playerOne.getUsername() };
sendToPlayer(playerOne, Destinations.OPPONENT_JOINED, playerOneMessage);
sendToPlayer(playerTwo, Destinations.OPPONENT_JOINED, playerTwoMessage);
send(Destinations.GAME_STARTED, new String[] { playerOne.getUsername(), playerTwo.getUsername() });
} }
} }
private void leaveWaitingList(String sessionId) { private synchronized void leave(String sessionId) {
synchronized (waitingList) { final Player player = playerBySession.remove(sessionId);
waitingList.remove(sessionId); if (player == null) {
waitingList.notify(); log.warn("No player for session {}", sessionId);
return;
}
final String username = player.getUsername();
usernames.remove(username);
final Game game = gameByPlayer.remove(player);
if (game == null) {
log.info("{} left", username);
send(Destinations.CHAT, new ChatMessage(null, username + " left"));
return;
}
leaveGame(game, player);
}
@MessageMapping("/leaveGame")
public synchronized void leaveGame(@Header(SESSION_ID_HEADER) String sessionId) {
final Player player = playerBySession.get(sessionId);
if (player == null) {
log.warn("No player for session {}", sessionId);
return;
}
final Game game = gameByPlayer.remove(player);
if (game == null) {
log.warn("{} is not in a game", player.getUsername());
return;
}
leaveGame(game, player);
}
private synchronized void leaveGame(Game game, Player player) {
final Player gameHost = game.getHost();
final Player gameChallenger = game.getChallenger();
if (gameHost == player) {
if (gameChallenger == null) {
// Close the game
gameById.remove(game.id);
send(Destinations.GAME_CLOSED, game);
} else {
// Leave the game
game.setHost(gameChallenger);
game.setChallenger(null);
send(Destinations.GAME_LEFT, new GameLeftMessage(game, player));
}
} else if (gameChallenger == player) {
// Leave the game
game.setChallenger(null);
send(Destinations.GAME_LEFT, new GameLeftMessage(game, player));
} }
} }
@ -138,6 +232,7 @@ public class LingoController implements ApplicationListener<AbstractSubProtocolE
private void onSessionConnected(SessionConnectedEvent event) { private void onSessionConnected(SessionConnectedEvent event) {
final String sessionId = StompHeaderAccessor.wrap(event.getMessage()).getSessionId(); final String sessionId = StompHeaderAccessor.wrap(event.getMessage()).getSessionId();
log.info("Session connected: {}", sessionId); log.info("Session connected: {}", sessionId);
playerBySession.put(sessionId, new Player(sessionId));
} }
private void onSessionDisconnect(SessionDisconnectEvent event) { private void onSessionDisconnect(SessionDisconnectEvent event) {
@ -146,27 +241,31 @@ public class LingoController implements ApplicationListener<AbstractSubProtocolE
leave(sessionId); leave(sessionId);
} }
@PostConstruct @SubscribeMapping("/topic/sessionId")
private void postConstruct() { public String onSessionId(@Header(SESSION_ID_HEADER) String sessionId) {
new Thread(new WaitingListListener()).start(); return sessionId;
} }
@MessageMapping("/practiceGame") @MessageMapping("/practiceGame")
public void practiceGame(@Header(SESSION_ID_HEADER) String sessionId) { public void practiceGame(@Header(SESSION_ID_HEADER) String sessionId) {
log.info("Player wants a practice session: {}", sessionId); final Player player = playerBySession.get(sessionId);
final Game game = new Game(sessionId, null, wordRepo.getWords(), wordRepo.getGuesses()); log.info("{} wants a practice session", sessionId);
practiceBySession.put(sessionId, game); final Game game = new Game(player);
game.setAcceptableGuesses(wordRepo.getGuesses());
game.setPossibleWords(wordRepo.getWords());
practiceByPlayer.put(player, game);
final String firstWord = game.newGame(); final String firstWord = game.newGame();
final String firstLetter = String.valueOf(firstWord.charAt(0)); final String firstLetter = String.valueOf(firstWord.charAt(0));
log.info("First word: {}", firstWord); log.info("First word: {}", firstWord);
sendToUser(sessionId, StompTopics.PRACTICE_GAME, firstLetter); sendToPlayer(player, Destinations.PRACTICE_GAME, firstLetter);
} }
@MessageMapping("/practiceGuess") @MessageMapping("/practiceGuess")
public void practiceGuess(String guess, @Header(SESSION_ID_HEADER) String sessionId) { public void practiceGuess(String guess, @Header(SESSION_ID_HEADER) String sessionId) {
final Player player = playerBySession.get(sessionId);
guess = guess.toUpperCase(); guess = guess.toUpperCase();
log.info("Player {} guessed: {}", sessionId, guess); log.info("{} guessed {}", sessionId, guess);
final Game game = practiceBySession.get(sessionId); final Game game = practiceByPlayer.get(player);
final int[] result = game.evaluate(guess); final int[] result = game.evaluate(guess);
// Generate report // Generate report
@ -181,57 +280,38 @@ public class LingoController implements ApplicationListener<AbstractSubProtocolE
} else { } else {
report.setResult(result); report.setResult(result);
} }
sendToUser(sessionId, StompTopics.PRACTICE_REPORTS, report); sendToPlayer(player, Destinations.PRACTICE_REPORTS, report);
} }
private void send(String destination, Object payload) { private void send(String destination, Object payload) {
messagingTemplate.convertAndSend(destination, payload); messagingTemplate.convertAndSend(destination, payload);
} }
private void sendToUser(String user, String destination, Object payload) { private void sendToPlayer(Player player, String destination, Object payload) {
// TODO: cache the headers? sendToSession(player.getSessionId(), destination, payload);
final SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
headerAccessor.setSessionId(user);
headerAccessor.setLeaveMutable(true);
final MessageHeaders headers = headerAccessor.getMessageHeaders();
messagingTemplate.convertAndSendToUser(user, destination, payload, headers);
} }
/** private void sendToSession(String sessionId, String destination, Object payload) {
* Task that spawns a game whenever two players are waiting. // TODO: cache the headers?
*/ final SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
private class WaitingListListener implements Runnable { headerAccessor.setSessionId(sessionId);
headerAccessor.setLeaveMutable(true);
final MessageHeaders headers = headerAccessor.getMessageHeaders();
messagingTemplate.convertAndSendToUser(sessionId, destination, payload, headers);
}
@Override @MessageMapping("/setUsername")
public void run() { public synchronized void setUsername(String username, @Header(SESSION_ID_HEADER) String sessionId) {
while (true) { final Player player = playerBySession.get(sessionId);
final String playerOne; if (usernames.add(username)) {
final String playerTwo; player.setUsername(username);
synchronized (waitingList) { log.info("{} --> {}", sessionId, username);
while (waitingList.size() < 2) { sendToPlayer(player, Destinations.SESSION_USERNAME, new SetUsernameMessage(true, username, null));
try { send(Destinations.USER_JOINED, new Object[] { username, playerBySession.size() });
waitingList.wait(); } else {
} catch (InterruptedException ok) { log.warn("{} -/> {} : Username taken", sessionId, username);
ok.printStackTrace(); final SetUsernameMessage response = new SetUsernameMessage(false, null, "Username taken");
} sendToPlayer(player, Destinations.SESSION_USERNAME, response);
}
playerOne = waitingList.remove(0);
playerTwo = waitingList.remove(0);
}
final Game game = new Game(playerOne, playerTwo, wordRepo.getWords(), wordRepo.getGuesses());
gameBySession.put(playerOne, game);
gameBySession.put(playerTwo, game);
final String firstWord = game.newGame();
final String firstLetter = String.valueOf(firstWord.charAt(0));
log.info("First word: {}", firstWord);
final String playerOneUsername = usernameBySession.get(playerOne);
final String playerTwoUsername = usernameBySession.get(playerTwo);
final String[] playerOneMessage = new String[] { firstLetter, playerTwoUsername };
final String[] playerTwoMessage = new String[] { firstLetter, playerOneUsername };
sendToUser(playerOne, StompTopics.OPPONENT_JOINED, playerOneMessage);
sendToUser(playerTwo, StompTopics.OPPONENT_JOINED, playerTwoMessage);
send(StompTopics.GAME_STARTED, new String[] { playerOneUsername, playerTwoUsername });
}
} }
} }

View File

@ -24,9 +24,10 @@ public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {
} }
@Override @Override
public void configureMessageBroker(MessageBrokerRegistry config) { public void configureMessageBroker(MessageBrokerRegistry registry) {
config.enableSimpleBroker("/topic"); registry.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app"); registry.setApplicationDestinationPrefixes("/app", "/user");
registry.setUserDestinationPrefix("/user");
} }
@Override @Override

View File

@ -2,28 +2,57 @@
<html> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Lingo</title> <title>Lingo | Client</title>
<link rel="stylesheet" href="cube-grid.css"> <link rel="stylesheet" href="cube-grid.css">
<link rel="stylesheet" href="layout.css"> <link rel="stylesheet" href="layout.css">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<script src="//cdnjs.cloudflare.com/ajax/libs/vue/2.1.8/vue.js"></script>
</head> </head>
<body> <body>
<div id="usernameDiv" class="jumbotron"> <div>
<h2>What is your name?</h2>
<form>
<div class="form-group">
<input id="username" type="text" class="form-control" maxlength="16" placeholder="Name">
</div>
<p id="usernameError" class="error-message"></p>
<button id="usernameButton" type="button" class="btn btn-default">Submit</button>
</form>
</div>
<div id="appDiv" class="hidden">
<div class="main column"> <div class="main column">
<div class="header row">Lingo</div> <div class="header row">Lingo</div>
<div class="body row nofooter"> <div class="body row nofooter">
<div id="canvasDiv" class="hidden"> <div id="usernameDiv" class="form">
<canvas id="canvas" class="centered" width="600" height="475" tabindex="1"></canvas> <h2>What is your name?</h2>
<input id="nicknameInput" type="text" class="form-control" maxlength="16">
<p id="usernameError" class="error-message"></p>
</div>
<div id="appDiv" class="hidden">
<div id="lobbyColumn" class="lobby column primary">
<div id="vue-app" class="body row noheader nofooter scroll-y">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Games</h3>
</div>
<div class="list-group">
<div v-if="games.length === 0" class="list-group-item">
There are no games.
</div>
<div v-for="game in games">
<div v-if="game.started" v-bind:id="'game-' + game.id" class="list-group-item">
<strong>{{ game.playerOne }}</strong>
is playing with
<strong>{{ game.playerTwo }}</strong>
</div>
<button v-else v-bind:id="'game-' + game.id" @click="joinGame" type="button" class="list-group-item">
<strong>{{ game.playerOne }}</strong>
wants to play
</button>
</div>
</div>
</div>
<button v-if="inGame" @click="leaveGame" type="button">Leave game</button>
<button v-else @click="hostGame" type="button">Create a game</button>
</div>
</div>
<div id="gameColumn" class="game column">
<div class="body row noheader nofooter">
<div id="canvasDiv" class="hidden">
<canvas id="canvas" class="centered" width="600" height="475" tabindex="1"></canvas>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -31,7 +60,7 @@
<div class="header row">Chat</div> <div class="header row">Chat</div>
<div id="messageList" class="body row scroll-y"></div> <div id="messageList" class="body row scroll-y"></div>
<div class="footer row"> <div class="footer row">
<input id="messageInput" placeholder="Chat here..." /> <input id="messageInput" placeholder="Type here to chat..." />
</div> </div>
</div> </div>
</div> </div>

View File

@ -24,48 +24,100 @@ var canvas = document.getElementById('canvas');
var ctx = canvas.getContext('2d'); var ctx = canvas.getContext('2d');
var client; var client;
var sessionId = null;
var vm = new Vue({
el: '#vue-app',
data: {
games: [],
gameId: null
},
computed: {
inGame: function() {
return this.gameId !== null;
}
},
methods: {
hostGame: function(event) {
client.send('/app/hostGame');
},
joinGame: function(event) {
// Discard 'game-' prefix
var buttonId = event.target.id;
var gameId = buttonId.substr(5);
client.send('/app/joinGame', {}, gameId);
},
leaveGame: function(event) {
client.send('/app/leaveGame');
}
}
});
function afterConnected(stompConnectedFrame) {
console.log('Connected to STOMP endpoint')
var sessionIdTopic = '/user/topic/sessionId';
var sessionIdSubscription = null;
var sessionIdHandler = function(message) {
console.log('Session ID: ' + message.body);
sessionId = message.body;
sessionIdSubscription.unsubscribe();
}
sessionIdSubscription = client.subscribe(sessionIdTopic, sessionIdHandler);
}
function main() { function main() {
client = Stomp.over(new SockJS('/stomp'));
client.connect({}, afterConnected);
var usernameDiv = document.getElementById('usernameDiv'); var usernameDiv = document.getElementById('usernameDiv');
var usernameInput = document.getElementById('username');
var usernameButton = document.getElementById('usernameButton');
var usernameError = document.getElementById('usernameError'); var usernameError = document.getElementById('usernameError');
var submitUsernameFunction = function() { var usernameInput = document.getElementById('nicknameInput');
var usernameValue = usernameInput.value.trim();
if (usernameValue.length === 0) { var usernameTopic = '/user/topic/sessionUsername';
return; var usernameSubscription = null;
var usernameHandler = function(message) {
var response = JSON.parse(message.body);
if (response.success === true) {
console.log('Username: ' + response.username);
myUsername = response.username;
start();
usernameDiv.classList.add('hidden');
appDiv.classList.remove('hidden');
} else {
usernameError.innerHTML = response.errorMessage;
} }
myUsername = usernameValue; };
localStorage.setItem('lingo.username', myUsername);
console.log('My username: ' + myUsername);
start();
usernameDiv.classList.add('hidden');
appDiv.classList.remove('hidden');
}
usernameButton.addEventListener('click', submitUsernameFunction);
usernameInput.focus(); usernameInput.focus();
usernameInput.addEventListener('keydown', function(e) { usernameInput.addEventListener('keydown', function(e) {
if (e.keyCode === KEYCODE_RETURN) { if (e.keyCode === KEYCODE_RETURN) {
e.preventDefault(); e.preventDefault();
submitUsernameFunction(); if (sessionId === null) {
usernameError.innerHTML = 'Not connected to server';
return;
}
var usernameValue = usernameInput.value.trim();
if (usernameValue.length === 0) {
usernameError.innerHTML = 'Name cannot be empty';
return;
}
if (usernameSubscription === null) {
usernameSubscription = client.subscribe(usernameTopic, usernameHandler);
client.subscribe('/topic/userJoined', onUserJoined);
}
client.send('/app/setUsername', {}, usernameValue);
} }
}); });
usernameInput.addEventListener('keyup', function(e) { usernameInput.addEventListener('keyup', function(e) {
if (e.keyCode === KEYCODE_RETURN) {
return;
}
var usernameValue = usernameInput.value.trim(); var usernameValue = usernameInput.value.trim();
if (usernameValue.length === 0) { if (usernameValue.length !== 0) {
usernameError.innerHTML = 'Name cannot be empty.';
usernameButton.disabled = true;
} else {
usernameError.innerHTML = ''; usernameError.innerHTML = '';
usernameButton.disabled = false;
} }
}); });
var storedUsername = localStorage.getItem('lingo.username');
if (storedUsername === null) {
usernameInput.value = 'Alex Trebek';
} else {
usernameInput.value = storedUsername;
}
} }
function start() { function start() {
@ -80,18 +132,16 @@ function start() {
reset(); reset();
repaint(); repaint();
client = Stomp.over(new SockJS('/stomp')); client.subscribe('/topic/chat', onChat);
client.subscribe('/topic/gameClosed', onGameClosed);
client.connect({}, function(frame) { client.subscribe('/topic/gameHosted', onGameHosted);
subscribeToChat(); client.subscribe('/topic/gameJoined', onGameJoined);
subscribeToGameStarted(); client.subscribe('/topic/gameLeft', onGameLeft);
subscribeToOpponentJoined(); client.subscribe('/topic/gameStarted', onGameStarted);
subscribeToOpponentLeft(); client.subscribe('/user/topic/opponentJoined', onOpponentJoined);
subscribeToOpponentReports(); client.subscribe('/user/topic/opponentLeft', onOpponentLeft);
subscribeToPlayerReports(); client.subscribe('/user/topic/opponentReports', onOpponentReport);
subscribeToUserJoined(); client.subscribe('/user/topic/playerReports', onPlayerReport);
client.send('/app/lingo/join', {}, myUsername);
});
} }
// special keys // special keys
@ -104,7 +154,7 @@ function addKeydownListener() {
} }
else if (e.which === KEYCODE_RETURN) { else if (e.which === KEYCODE_RETURN) {
if (myGuess.length === 5) { if (myGuess.length === 5) {
client.send("/app/lingo/guess", {}, myGuess); client.send("/app/guess", {}, myGuess);
myGuess = ''; myGuess = '';
repaint(); repaint();
} }
@ -138,8 +188,8 @@ function addChatMessageListener() {
return; return;
} }
messageInput.value = ''; messageInput.value = '';
client.send('/app/lingo/chat', {}, text); client.send('/app/chat', {}, text);
addChatMessage('Me', text); addChatMessage(myUsername, text);
} }
}); });
} }
@ -147,7 +197,7 @@ function addChatMessageListener() {
function addChatMessage(sender, body) { function addChatMessage(sender, body) {
var messageList = document.getElementById('messageList'); var messageList = document.getElementById('messageList');
var usernameNode = document.createElement('strong'); var usernameNode = document.createElement('strong');
var usernameTextNode = document.createTextNode(sender) var usernameTextNode = document.createTextNode(sender);
usernameNode.appendChild(usernameTextNode); usernameNode.appendChild(usernameTextNode);
var messageTextNode = document.createTextNode(' ' + body); var messageTextNode = document.createTextNode(' ' + body);
var messageItem = document.createElement('div'); var messageItem = document.createElement('div');
@ -315,6 +365,17 @@ function isValidResult(result) {
return true; return true;
} }
function removeGame(gameId) {
var indexToRemove = null;
for (var i = 0; i < vm.games.length; i++) {
if (vm.games[i].id === gameId) {
indexToRemove = i;
break;
}
}
vm.games.splice(indexToRemove, 1);
}
function repaint() { function repaint() {
// clear the canvas // clear the canvas
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
@ -340,120 +401,198 @@ function reset(firstLetter, clearScore) {
} }
} }
function subscribeToChat() { function toggleView() {
client.subscribe('/topic/lingo/chat', function(message) { var lobbyColumn = document.getElementById('lobbyColumn');
var chatMessage = JSON.parse(message.body); var gameColumn = document.getElementById('gameColumn')
var messageSender = chatMessage.username; if (lobbyColumn.classList.contains('primary')) {
var messageBody = chatMessage.message; lobbyColumn.classList.remove('primary');
if (messageSender === null) { } else {
addChatAnnouncement(messageBody); lobbyColumn.classList.add('primary');
} else if (messageSender === myUsername) { }
console.log('Ignoring message sent by myself') if (gameColumn.classList.contains('primary')) {
} else { gameColumn.classList.remove('primary');
console.log('Message from ' + messageSender + ": " + messageBody); } else {
addChatMessage(messageSender, messageBody); gameColumn.classList.add('primary');
} }
});
} }
function subscribeToGameStarted() { function onChat(message) {
client.subscribe('/topic/lingo/gameStarted', function(message) { var chatMessage = JSON.parse(message.body);
var report = JSON.parse(message.body); var messageSender = chatMessage.username;
var playerOne = report[0]; var messageBody = chatMessage.message;
var playerTwo = report[1]; if (messageSender === null) {
if (playerOne === myUsername) { addChatAnnouncement(messageBody);
addChatAnnouncement('You are playing with ' + playerTwo); } else if (messageSender === myUsername) {
} else if (playerTwo === myUsername) { // Ignore messages sent by yourself
addChatAnnouncement('You are playing with ' + playerOne); } else {
} else { console.log('Message from ' + messageSender + ": " + messageBody);
addChatAnnouncement(playerOne + ' is playing with ' + playerTwo); addChatMessage(messageSender, messageBody);
} }
});
} }
function subscribeToOpponentJoined() { function onGameClosed(message) {
client.subscribe('/user/topic/lingo/opponentJoined', function(message) { var game = JSON.parse(message.body);
var report = JSON.parse(message.body); var gameId = game.id;
var firstLetter = report[0]; var gameHost = game.host.username;
opponentUsername = report[1]; if (gameHost === myUsername) {
console.log('Opponent username: ' + opponentUsername); vm.gameId = null;
reset(firstLetter, true); }
canvasDiv.classList.remove('hidden'); console.log(gameHost + ' closed Game ' + gameId);
removeGame(gameId);
}
function onGameHosted(message) {
var game = JSON.parse(message.body);
var gameId = game.id;
var gameHost = game.host.username;
if (gameHost === myUsername) {
vm.gameId = gameId;
}
console.log(gameHost + ' hosted Game ' + gameId);
vm.games.push({ id: gameId, playerOne: gameHost, started: false });
}
function onGameJoined(message) {
var game = JSON.parse(message.body);
var gameId = game.id;
var gameHost = game.host.username;
var gameChallenger = game.challenger.username;
if (gameChallenger === myUsername) {
vm.gameId = gameId;
}
console.log(gameChallenger + ' joined ' + gameHost + "'s game");
for (var i = 0; i < vm.games.length; i++) {
if (vm.games[i].id === gameId) {
vm.games[i].playerTwo = gameChallenger;
vm.games[i].started = true;
break;
}
}
if (gameHost === myUsername || gameChallenger === myUsername) {
toggleView();
}
}
function onGameLeft(message) {
var report = JSON.parse(message.body);
var game = report.game;
var gameId = game.id;
var gameHost = game.host.username;
var gameLeaver = report.gameLeaver.username;
var previousPlayers = [];
for (var i = 0; i < vm.games.length; i++) {
if (vm.games[i].id === gameId) {
previousPlayers.push(vm.games[i].playerOne);
previousPlayers.push(vm.games[i].playerTwo);
vm.games[i].playerOne = gameHost;
vm.games[i].playerTwo = game.challenger ? game.challenger.username : null;
vm.games[i].started = false;
break;
}
}
console.log(gameLeaver + ' left ' + gameHost + "'s game");
if (gameLeaver === myUsername) {
vm.gameId = null;
}
if (previousPlayers.indexOf(myUsername) != -1) {
onOpponentLeft();
toggleView();
}
}
function onGameStarted(message) {
var report = JSON.parse(message.body);
var playerOne = report[0];
var playerTwo = report[1];
if (playerOne === myUsername) {
addChatAnnouncement('You are playing with ' + playerTwo);
} else if (playerTwo === myUsername) {
addChatAnnouncement('You are playing with ' + playerOne);
} else {
addChatAnnouncement(playerOne + ' is playing with ' + playerTwo);
}
}
function onOpponentJoined(message) {
var report = JSON.parse(message.body);
var firstLetter = report[0];
opponentUsername = report[1];
console.log('Opponent username: ' + opponentUsername);
reset(firstLetter, true);
canvasDiv.classList.remove('hidden');
repaint();
}
function onOpponentLeft(message) {
opponentUsername = null;
lastWord = null;
canvasDiv.classList.add('hidden');
repaint();
}
function onOpponentReport(message) {
var report = JSON.parse(message.body);
if (report.correct === true) {
var guess = report.guess;
var firstLetter = report.firstLetter;
console.log('Opponent guessed correctly! ' + guess);
opponentScore = opponentScore + 100;
lastWord = guess;
reset(firstLetter, false);
repaint(); repaint();
}); } else {
} var result = report.result;
console.log('Opponent result: ' + result);
function subscribeToOpponentLeft() { opponentResults.push(result);
client.subscribe('/user/topic/lingo/opponentLeft', function(message) {
opponentUsername = null;
lastWord = null;
canvasDiv.classList.add('hidden');
repaint(); repaint();
}); }
} }
function subscribeToOpponentReports() { function onPlayerReport(message) {
client.subscribe('/user/topic/lingo/opponentReports', function(message) { var report = JSON.parse(message.body);
var report = JSON.parse(message.body); console.log('My report: ' + report);
if (report.correct === true) { if (report.correct === true) {
var guess = report.guess; var guess = report.guess;
var firstLetter = report.firstLetter; var firstLetter = report.firstLetter;
console.log('Opponent guessed correctly! ' + guess); console.log('I guessed correctly!');
opponentScore = opponentScore + 100; myScore = myScore + 100;
lastWord = guess; lastWord = guess;
reset(firstLetter, false); reset(firstLetter, false);
repaint(); repaint();
} else {
var guess = report.guess;
var result = report.result;
console.log('My result: ' + result);
// TODO: use isValidResult function
if (result[0] === 9) {
myGuesses.push('-----');
} else { } else {
var result = report.result; for (var i = 0; i < 5; i++) {
console.log('Opponent result: ' + result); if (result[i] === 2) {
opponentResults.push(result); myProgress[i] = guess[i];
repaint();
}
});
}
function subscribeToPlayerReports() {
client.subscribe('/user/topic/lingo/playerReports', function(message) {
var report = JSON.parse(message.body);
console.log('My report: ' + report);
if (report.correct === true) {
var guess = report.guess;
var firstLetter = report.firstLetter;
console.log('I guessed correctly!');
myScore = myScore + 100;
lastWord = guess;
reset(firstLetter, false);
repaint();
} else {
var guess = report.guess;
var result = report.result;
console.log('My result: ' + result);
// TODO: use isValidResult function
if (result[0] === 9) {
myGuesses.push('-----');
} else {
for (var i = 0; i < 5; i++) {
if (result[i] === 2) {
myProgress[i] = guess[i];
}
} }
myGuesses.push(guess);
} }
myResults.push(result); myGuesses.push(guess);
repaint();
} }
}); myResults.push(result);
repaint();
}
} }
function subscribeToUserJoined() { function onUserJoined(message) {
client.subscribe('/topic/lingo/userJoined', function(message) { var report = JSON.parse(message.body);
var username = message.body; var username = report[0];
if (username === myUsername) { var numUsers = report[1];
addChatAnnouncement('You joined'); if (username === myUsername) {
addChatAnnouncement('Welcome to Lingo!');
if (numUsers === 1) {
addChatAnnouncement('You are the only player online');
} else { } else {
addChatAnnouncement(username + ' joined'); addChatAnnouncement('There are ' + numUsers + ' players online');
} }
}); } else {
addChatAnnouncement(username + ' joined');
}
} }
main(); main();

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lingo</title> <title>Lingo | Home</title>
<link rel="stylesheet" href="//unpkg.com/bootstrap/dist/css/bootstrap.min.css"> <link rel="stylesheet" href="//unpkg.com/bootstrap/dist/css/bootstrap.min.css">
</head> </head>
<body> <body>

View File

@ -54,7 +54,7 @@ body {
} }
.body.row.noheader { .body.row.noheader {
bottom: 0; top: 0;
} }
.footer.row { .footer.row {
@ -70,3 +70,23 @@ body {
.chat.column .body { .chat.column .body {
padding: 0 10px; padding: 0 10px;
} }
.lobby.column {
width: 300px;
left: 0;
}
.lobby.column .body {
padding: 0 10px;
}
.lobby.column.primary {
width: inherit;
right: 0;
}
.game.column.primary {
left: 300px;
right: 0;
border-left: 1px solid black;
}

View File

@ -36,7 +36,7 @@ function start() {
client.connect({}, function(frame) { client.connect({}, function(frame) {
subscribeToPracticeGame(); subscribeToPracticeGame();
subscribeToPracticeReports(); subscribeToPracticeReports();
client.send('/app/lingo/practiceGame'); client.send('/app/practiceGame');
}); });
} }
@ -52,7 +52,7 @@ function addKeydownListener() {
// return // return
else if (e.which === 13) { else if (e.which === 13) {
if (myGuess.length === 5) { if (myGuess.length === 5) {
client.send("/app/lingo/practiceGuess", {}, myGuess); client.send("/app/practiceGuess", {}, myGuess);
myGuess = ''; myGuess = '';
repaint(); repaint();
} }
@ -223,7 +223,7 @@ function reset(firstLetter, clearScore) {
} }
function subscribeToPracticeGame() { function subscribeToPracticeGame() {
client.subscribe('/user/topic/lingo/practiceGame', function(message) { client.subscribe('/user/topic/practiceGame', function(message) {
var firstLetter = message.body; var firstLetter = message.body;
reset(firstLetter, true); reset(firstLetter, true);
repaint(); repaint();
@ -231,7 +231,7 @@ function subscribeToPracticeGame() {
} }
function subscribeToPracticeReports() { function subscribeToPracticeReports() {
client.subscribe('/user/topic/lingo/practiceReports', function(message) { client.subscribe('/user/topic/practiceReports', function(message) {
var report = JSON.parse(message.body); var report = JSON.parse(message.body);
console.log('My report: ' + report); console.log('My report: ' + report);
if (report.correct === true) { if (report.correct === true) {

View File

@ -10,7 +10,7 @@
} }
body { body {
font-family: cursive; font-family: sans-serif;
} }
button { button {
@ -28,7 +28,7 @@ button:hover:disabled {
/* Main area */ /* Main area */
.header { .header {
font-size: 1.4em; font-size: 1.6em;
color: white; color: white;
background: steelblue; background: steelblue;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 1); text-shadow: 1px 1px 1px rgba(0, 0, 0, 1);
@ -46,6 +46,7 @@ button:hover:disabled {
padding: 10px; padding: 10px;
position: relative; position: relative;
overflow-wrap: break-word; overflow-wrap: break-word;
word-wrap: break-word;
} }
.message-item.first { .message-item.first {
@ -59,23 +60,85 @@ button:hover:disabled {
#messageInput { #messageInput {
width: 100%; width: 100%;
height: 50px; height: 50px;
font-size: 100%;
padding-left: 10px; padding-left: 10px;
background-color: linen;
border-color: black;
border-left: none;
border-width: 1px;
} }
/* Username page */ #messageInput::-webkit-input-placeholder {
color: steelblue;
.jumbotron {
margin: 48px auto;
padding: 48px 60px;
width: 80%;
background-color: #eee;
border-radius: 6px;
} }
.jumbotron h2 { #messageInput::-moz-placeholder {
margin-bottom: 15px; color: steelblue;
font-size: 21px; }
font-weight: 200;
#messageInput:-ms-input-placeholder {
color: steelblue;
}
/* Lobby pane */
.panel {
margin: 20px 0;
border: 1px solid transparent;
border-color: #ddd;
border-radius: 4px;
}
.panel-heading {
background-color: lightgray;
border-color: #ddd;
padding: 10px 15px;
}
.panel-title {
margin-top: 0;
margin-bottom: 0;
font-size: 16px;
color: steelblue;
}
.list-group {
margin-bottom: 0;
}
.panel-heading+.list-group .list-group-item:first-item {
border-top-width: 0;
}
.panel>.list-group .list-group-item {
border-width: 1px 0;
border-radius: 0;
}
.list-group-item {
position: relative;
display: block;
padding: 10px 15px;
margin-bottom: -1px;
background-color: white;
border: 1px solid #ddd;
}
button.list-group-item {
width: 100%;
text-align: left;
}
button.list-group-item:hover {
background-color: lightblue;
}
/* Nickname pane */
.form {
padding-top: 48px;
text-align: center;
font-weight: 100;
} }
.error-message { .error-message {
@ -83,18 +146,17 @@ button:hover:disabled {
font-size: 14px; font-size: 14px;
} }
.form-group {
margin-bottom: 15px;
}
.form-control { .form-control {
width: 100%; width: 300px;
height: 24px; font-size: 200%;
padding: 6px 12px; letter-spacing: 3px;
font-size: 14px; background-color: transparent;
color: #555; border: none;
border: 1px solid #ccc; border-bottom: 2px solid black;
border-radius: 4px; color: steelblue;
outline: none;
padding-bottom: 15px;
text-align: center;
} }
/* Waiting pane */ /* Waiting pane */