Initial commit

This commit is contained in:
Charles Gould 2016-12-30 04:16:06 -05:00
commit 89712083cc
36 changed files with 15923 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
# Maven
target/
# Eclipse
.settings/
*.project
*.classpath
# IntelliJ IDEA
.idea/
*.iml

22
client-api/pom.xml Normal file
View File

@ -0,0 +1,22 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.charego</groupId>
<artifactId>lingo-websocket</artifactId>
<version>1.0</version>
</parent>
<artifactId>lingo-websocket-client-api</artifactId>
<name>Lingo WebSocket :: Client API</name>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<version>${project.version}</version>
<artifactId>lingo-websocket-common</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,17 @@
package lingo.client.api;
public class StompTopics {
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");
private static String createTopicName(String suffix) {
return "/topic/lingo/" + suffix;
}
}

24
client/LingoClient.launch Normal file
View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<launchConfiguration type="org.eclipse.jdt.launching.localJavaApplication">
<listAttribute key="org.eclipse.debug.core.MAPPED_RESOURCE_PATHS">
<listEntry value="/lingo-websocket-client/src/main/java/lingo/client/bootstrap/LingoClient.java"/>
</listAttribute>
<listAttribute key="org.eclipse.debug.core.MAPPED_RESOURCE_TYPES">
<listEntry value="1"/>
</listAttribute>
<listAttribute key="org.eclipse.debug.ui.favoriteGroups">
<listEntry value="org.eclipse.debug.ui.launchGroup.debug"/>
<listEntry value="org.eclipse.debug.ui.launchGroup.run"/>
</listAttribute>
<listAttribute key="org.eclipse.jdt.launching.CLASSPATH">
<listEntry value="&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; standalone=&quot;no&quot;?&gt;&#13;&#10;&lt;runtimeClasspathEntry containerPath=&quot;org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8&quot; path=&quot;1&quot; type=&quot;4&quot;/&gt;&#13;&#10;"/>
<listEntry value="&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; standalone=&quot;no&quot;?&gt;&#13;&#10;&lt;runtimeClasspathEntry internalArchive=&quot;/lingo-websocket-client/src/main/config&quot; path=&quot;3&quot; type=&quot;2&quot;/&gt;&#13;&#10;"/>
<listEntry value="&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; standalone=&quot;no&quot;?&gt;&#13;&#10;&lt;runtimeClasspathEntry path=&quot;3&quot; projectName=&quot;lingo-websocket-client&quot; type=&quot;1&quot;/&gt;&#13;&#10;"/>
<listEntry value="&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; standalone=&quot;no&quot;?&gt;&#13;&#10;&lt;runtimeClasspathEntry containerPath=&quot;org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER&quot; path=&quot;3&quot; type=&quot;4&quot;/&gt;&#13;&#10;"/>
</listAttribute>
<stringAttribute key="org.eclipse.jdt.launching.CLASSPATH_PROVIDER" value="org.eclipse.m2e.launchconfig.classpathProvider"/>
<booleanAttribute key="org.eclipse.jdt.launching.DEFAULT_CLASSPATH" value="false"/>
<stringAttribute key="org.eclipse.jdt.launching.MAIN_TYPE" value="lingo.client.bootstrap.LingoClient"/>
<stringAttribute key="org.eclipse.jdt.launching.PROJECT_ATTR" value="lingo-websocket-client"/>
<stringAttribute key="org.eclipse.jdt.launching.SOURCE_PATH_PROVIDER" value="org.eclipse.m2e.launchconfig.sourcepathProvider"/>
</launchConfiguration>

43
client/pom.xml Normal file
View File

@ -0,0 +1,43 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.charego</groupId>
<artifactId>lingo-websocket</artifactId>
<version>1.0</version>
</parent>
<artifactId>lingo-websocket-client</artifactId>
<name>Lingo WebSocket :: Client</name>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<version>${project.version}</version>
<artifactId>lingo-websocket-client-api</artifactId>
</dependency>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>com.zenjava</groupId>
<artifactId>javafx-maven-plugin</artifactId>
<version>8.7.0</version>
<configuration>
<mainClass>lingo.client.bootstrap.LingoClient</mainClass>
<additionalAppResources>src/main/config</additionalAppResources>
<copyAdditionalAppResourcesToJar>true</copyAdditionalAppResourcesToJar>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,7 @@
# http://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html
server:
host: localhost
port: 8080
stomp:
endpoint: stomp

View File

@ -0,0 +1,63 @@
package lingo.client.bootstrap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.concurrent.CustomizableThreadFactory;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
@SpringBootApplication
public class LingoClient extends Application {
private Parent root;
public static void main(final String[] args) {
Application.launch(args);
}
@Override
public void init() throws Exception {
ConfigurableApplicationContext context = startSpringApplication();
FXMLLoader loader = new FXMLLoader(getClass().getResource("/fxml/Lingo.fxml"));
loader.setControllerFactory(clazz -> context.getBean(clazz));
root = loader.load();
}
@Override
public void start(Stage stage) throws Exception {
// Close the Spring context when the client is closed.
stage.setOnCloseRequest(e -> {
stage.close();
System.exit(0);
});
Scene scene = new Scene(root);
scene.getStylesheets().add("/style.css");
stage.setResizable(false);
stage.setScene(scene);
stage.setTitle("Lingo");
stage.show();
}
private ConfigurableApplicationContext startSpringApplication() {
SpringApplication application = new SpringApplication(LingoClient.class);
String[] args = getParameters().getRaw().stream().toArray(String[]::new);
application.setHeadless(false);
application.setWebEnvironment(false);
return application.run(args);
}
@Bean
public ExecutorService executorService() {
return Executors.newFixedThreadPool(5, new CustomizableThreadFactory("ClientThread-"));
}
}

View File

@ -0,0 +1,102 @@
package lingo.client.bootstrap;
import java.io.IOException;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.stereotype.Component;
import javafx.application.Platform;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;
import lingo.client.multiplayer.MultiplayerConfig;
import lingo.client.multiplayer.MultiplayerPresenter;
import lingo.client.singleplayer.SinglePlayerPresenter;
import lingo.client.util.FxmlController;
import lingo.common.WordReader;
@Component
public class LingoPresenter implements FxmlController {
private static final Logger log = LoggerFactory.getLogger(LingoPresenter.class);
@Autowired
private ApplicationContext bootstrapContext;
@Autowired
private ExecutorService executorService;
@FXML
private BorderPane content;
@FXML
private BorderPane gameModeChooser;
@FXML
private void exit(ActionEvent event) {
Stage stage = (Stage) content.getScene().getWindow();
stage.close();
System.exit(0);
}
@Override
public void initialize() {
// No initialization needed
}
@FXML
private void showMultiplayer(ActionEvent event) {
log.info("Launching multiplayer...");
executorService.execute(() -> {
AnnotationConfigApplicationContext multiplayerContext = new AnnotationConfigApplicationContext();
multiplayerContext.setParent(bootstrapContext);
multiplayerContext.scan(MultiplayerConfig.class.getPackage().getName());
multiplayerContext.refresh();
MultiplayerPresenter presenter = multiplayerContext.getBean(MultiplayerPresenter.class);
presenter.setOnBackButtonPressed(e -> {
multiplayerContext.close();
content.setCenter(gameModeChooser);
});
Platform.runLater(() -> {
FXMLLoader loader = new FXMLLoader(getClass().getResource("/fxml/LingoMultiplayer.fxml"));
loader.setControllerFactory(clazz -> multiplayerContext.getBean(clazz));
try {
content.setCenter(loader.load());
} catch (IOException e) {
log.error("Failed to load multiplayer", e);
}
});
});
}
@FXML
private void showSinglePlayer(ActionEvent event) {
log.info("Launching single player...");
// TODO: Is there a memory leak here?
try {
Set<String> guesses = WordReader.readFileToSet("/guesses.txt");
List<String> words = WordReader.readFileToList("/words.txt");
SinglePlayerPresenter presenter = new SinglePlayerPresenter(words, guesses, e -> {
content.setCenter(gameModeChooser);
});
content.setCenter(presenter.getNode());
presenter.startGame();
} catch (IOException e) {
log.error("Failed to load single player", e);
}
}
}

View File

@ -0,0 +1,39 @@
package lingo.client.multiplayer;
import java.util.ArrayList;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.converter.ByteArrayMessageConverter;
import org.springframework.messaging.converter.CompositeMessageConverter;
import org.springframework.messaging.converter.MappingJackson2MessageConverter;
import org.springframework.messaging.converter.MessageConverter;
import org.springframework.messaging.converter.StringMessageConverter;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.web.socket.client.WebSocketClient;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.messaging.WebSocketStompClient;
@Configuration
public class MultiplayerConfig {
@Bean
public WebSocketStompClient stompClient(MessageConverter messageConverter) {
WebSocketClient webSocketClient = new StandardWebSocketClient();
WebSocketStompClient stompClient = new WebSocketStompClient(webSocketClient);
stompClient.setMessageConverter(messageConverter);
stompClient.setTaskScheduler(new ThreadPoolTaskScheduler());
return stompClient;
}
@Bean
public MessageConverter messageConverter() {
List<MessageConverter> converters = new ArrayList<>();
converters.add(new StringMessageConverter());
converters.add(new ByteArrayMessageConverter());
converters.add(new MappingJackson2MessageConverter());
return new CompositeMessageConverter(converters);
}
}

View File

@ -0,0 +1,322 @@
package lingo.client.multiplayer;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import javax.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.stomp.StompFrameHandler;
import org.springframework.messaging.simp.stomp.StompHeaders;
import org.springframework.stereotype.Component;
import javafx.application.Platform;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.fxml.FXML;
import javafx.geometry.VPos;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.Button;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.TextAlignment;
import javafx.scene.web.WebView;
import lingo.client.api.StompTopics;
import lingo.client.util.FxmlController;
import lingo.client.view.Board;
import lingo.client.view.OpponentBoard;
import lingo.client.view.PlayerBoard;
import lingo.common.Game;
import lingo.common.Report;
@Component
public class MultiplayerPresenter implements FxmlController {
private static final Logger log = LoggerFactory.getLogger(MultiplayerPresenter.class);
private static final double MARGIN_BOTTOM = 75;
@FXML
private Button backButton;
@FXML
private Canvas canvas;
@FXML
private StackPane contentPane;
@FXML
private WebView webView;
@Autowired
private ExecutorService executorService;
@Autowired
private StompTemplate stompTemplate;
private EventHandler<ActionEvent> backButtonHandler;
private GraphicsContext gc;
private String lastWord;
private PlayerBoard playerBoard;
private OpponentBoard opponentBoard;
private final CountDownLatch subscriptionsLatch = new CountDownLatch(4);
private void clearBoards(boolean clearScore) {
playerBoard.clearBoard();
opponentBoard.clearBoard();
if (clearScore) {
playerBoard.clearScore();
opponentBoard.clearScore();
}
}
private void drawLastWord() {
if (lastWord != null) {
double x = canvas.getWidth() / 2;
double y = canvas.getHeight() - MARGIN_BOTTOM / 2;
gc.setFill(Color.BLACK);
gc.fillText("Previous word: " + lastWord.toUpperCase(), x, y);
}
}
@Override
public void initialize() {
// Needed for key event handling
canvas.setFocusTraversable(true);
gc = canvas.getGraphicsContext2D();
gc.setFont(Font.font(24));
gc.setTextAlign(TextAlignment.CENTER);
gc.setTextBaseline(VPos.CENTER);
playerBoard = new PlayerBoard(canvas, 50, 50);
opponentBoard = new OpponentBoard(canvas, 50 + Board.WIDTH + 50, 50);
Platform.runLater(() -> {
String html = getClass().getResource("/cube-grid.html").toExternalForm();
String css = getClass().getResource("/cube-grid.css").toExternalForm();
webView.getEngine().load(html);
webView.getEngine().setUserStyleSheetLocation(css);
webView.setContextMenuEnabled(false);
repaint();
});
backButton.setOnAction(backButtonHandler);
executorService.execute(() -> {
while (subscriptionsLatch.getCount() != 0) {
try {
subscriptionsLatch.await();
} catch (InterruptedException ok) {
ok.printStackTrace();
}
}
stompTemplate.getSession().send("/app/lingo/join", null);
});
}
@FXML
private void keyPressed(KeyEvent e) {
final KeyCode keyCode = e.getCode();
if (keyCode == KeyCode.BACK_SPACE) {
if (playerBoard.handleBackspace()) {
repaint();
}
} else if (keyCode == KeyCode.ENTER) {
final String guess = playerBoard.handleEnter();
if (guess != null) {
executorService.execute(() -> stompTemplate.getSession().send("/app/lingo/guess", guess));
repaint();
}
} else if (keyCode.isLetterKey()) {
if (playerBoard.handleLetter(keyCode.getName())) {
repaint();
}
}
}
private void newWord(String firstLetter) {
playerBoard.setProgress(0, firstLetter.charAt(0));
}
@PostConstruct
private void postConstruct() {
executorService.execute(() -> {
stompTemplate.subscribe("/user" + StompTopics.OPPONENT_JOINED, new OpponentJoinedHandler(),
subscription -> subscriptionsLatch.countDown());
stompTemplate.subscribe("/user" + StompTopics.OPPONENT_LEFT, new OpponentLeftHandler(),
subscription -> subscriptionsLatch.countDown());
stompTemplate.subscribe("/user" + StompTopics.OPPONENT_REPORTS, new OpponentReportHandler(),
subscription -> subscriptionsLatch.countDown());
stompTemplate.subscribe("/user" + StompTopics.PLAYER_REPORTS, new PlayerReportHandler(),
subscription -> subscriptionsLatch.countDown());
});
}
private void repaint() {
gc.clearRect(0, 0, canvas.getWidth(), canvas.getHeight());
playerBoard.drawBoard();
opponentBoard.drawBoard();
drawLastWord();
}
public void setOnBackButtonPressed(EventHandler<ActionEvent> handler) {
backButtonHandler = handler;
}
private void showWaitingAnimation(boolean show) {
if (show) {
contentPane.getChildren().add(webView);
backButton.toFront();
} else {
contentPane.getChildren().remove(webView);
}
}
private class OpponentJoinedHandler implements StompFrameHandler {
@Override
public Type getPayloadType(StompHeaders headers) {
return String.class;
}
@Override
public void handleFrame(StompHeaders headers, Object payload) {
final String firstLetter = payload.toString();
Platform.runLater(() -> {
clearBoards(true);
newWord(firstLetter);
showWaitingAnimation(false);
repaint();
});
}
}
private class OpponentLeftHandler implements StompFrameHandler {
@Override
public Type getPayloadType(StompHeaders headers) {
return String.class;
}
@Override
public void handleFrame(StompHeaders headers, Object payload) {
Platform.runLater(() -> {
clearBoards(true);
showWaitingAnimation(true);
lastWord = null;
repaint();
});
}
}
private class OpponentReportHandler implements StompFrameHandler {
@Override
public Type getPayloadType(StompHeaders headers) {
return Report.class;
}
@Override
public void handleFrame(StompHeaders headers, Object payload) {
handleMessage((Report) payload);
}
private void handleMessage(Report report) {
if (report.isCorrect()) {
onCorrectGuess(report);
} else {
onIncorrectGuess(report);
}
}
private void onCorrectGuess(Report report) {
final String guess = report.getGuess();
final String firstLetter = report.getFirstLetter();
log.info("Opponent guessed correctly: " + guess);
Platform.runLater(() -> {
opponentBoard.addToScore(100);
lastWord = guess;
clearBoards(false);
newWord(firstLetter);
repaint();
});
}
private void onIncorrectGuess(Report report) {
final int[] result = report.getResult();
log.info("Opponent result: " + Arrays.toString(result));
Platform.runLater(() -> {
opponentBoard.addResult(result);
repaint();
});
}
}
private class PlayerReportHandler implements StompFrameHandler {
@Override
public Type getPayloadType(StompHeaders headers) {
return Report.class;
}
@Override
public void handleFrame(StompHeaders headers, Object payload) {
handleMessage((Report) payload);
}
private void handleMessage(Report report) {
if (report.isCorrect()) {
onCorrectGuess(report);
} else {
onIncorrectGuess(report);
}
}
private void onCorrectGuess(Report report) {
final String guess = report.getGuess();
final String firstLetter = report.getFirstLetter();
log.info("I guessed correctly!");
Platform.runLater(() -> {
playerBoard.addToScore(100);
lastWord = guess;
clearBoards(false);
newWord(firstLetter);
repaint();
});
}
private void onIncorrectGuess(Report report) {
final String guess = report.getGuess();
final int[] result = report.getResult();
log.info("My result: " + Arrays.toString(result));
Platform.runLater(() -> {
if (Arrays.equals(result, Game.INVALID_GUESS)) {
playerBoard.addGuess("-----");
} else {
for (int i = 0; i < Game.WORD_LENGTH; i++) {
if (result[i] == Game.CORRECT_CHARACTER) {
playerBoard.setProgress(i, guess.charAt(i));
}
}
playerBoard.addGuess(guess);
}
playerBoard.addResult(result);
repaint();
});
}
}
}

View File

@ -0,0 +1,133 @@
package lingo.client.multiplayer;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.function.Consumer;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.messaging.simp.stomp.StompFrameHandler;
import org.springframework.messaging.simp.stomp.StompHeaders;
import org.springframework.messaging.simp.stomp.StompSession;
import org.springframework.messaging.simp.stomp.StompSession.Subscription;
import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.WebSocketStompClient;
@Component
public class StompTemplate {
private static final Logger log = LoggerFactory.getLogger(StompTemplate.class);
@Value("${server.host}")
private String host;
@Value("${server.port}")
private int port;
@Value("${server.stomp.endpoint}")
private String stompEndpoint;
@Autowired
private ExecutorService executorService;
@Autowired
private WebSocketStompClient stompClient;
private StompSession stompSession;
private final BlockingQueue<SubscriptionRequest> subscriptionRequests = new LinkedBlockingQueue<>();
public StompSession getSession() {
/*
* TODO: If STOMP session is null or disconnected, create a new
* connection before returning this field.
*/
return stompSession;
}
@PostConstruct
private void postConstruct() {
final String url = String.format("ws://%s:%d/%s", host, port, stompEndpoint);
executorService.execute(() -> stompClient.connect(url, new WebSocketSessionHandler()));
new Thread(new WebSocketSessionListener()).start();
}
@PreDestroy
private void preDestroy() {
if (stompSession != null) {
log.info("Disconnecting from STOMP endpoint...");
stompSession.disconnect();
}
stompClient.stop();
}
public void subscribe(String destination, StompFrameHandler handler) {
subscribe(destination, handler, null);
}
public void subscribe(String destination, StompFrameHandler handler, Consumer<Subscription> callback) {
try {
subscriptionRequests.put(new SubscriptionRequest(destination, handler, callback));
} catch (InterruptedException e) {
log.error("Failed to subscribe to destination: {}", destination, e);
}
}
private class SubscriptionRequest {
public final String destination;
public final StompFrameHandler handler;
public final Consumer<Subscription> callback;
public SubscriptionRequest(String destination, StompFrameHandler handler, Consumer<Subscription> callback) {
this.destination = destination;
this.handler = handler;
this.callback = callback;
}
public void onSubscribed(Subscription subscription) {
if (callback != null) {
callback.accept(subscription);
}
}
}
private class WebSocketSessionHandler extends StompSessionHandlerAdapter {
@Override
public void afterConnected(StompSession session, StompHeaders connectedHeaders) {
log.info("Connected to STOMP endpoint");
stompSession = session;
}
}
private class WebSocketSessionListener implements Runnable {
@Override
public void run() {
while (true) {
if (stompSession == null) {
try {
Thread.sleep(1000L);
} catch (InterruptedException ok) {
ok.printStackTrace();
}
continue;
}
try {
final SubscriptionRequest request = subscriptionRequests.take();
final Subscription subscription = stompSession.subscribe(request.destination, request.handler);
request.onSubscribed(subscription);
} catch (InterruptedException e) {
log.error("Failed to subscribe", e);
}
}
}
}
}

View File

@ -0,0 +1,158 @@
package lingo.client.singleplayer;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javafx.application.Platform;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.geometry.VPos;
import javafx.scene.Node;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.Button;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Font;
import javafx.scene.text.TextAlignment;
import lingo.client.view.PlayerBoard;
import lingo.common.Game;
import lingo.common.Report;
public class SinglePlayerPresenter {
private static final Logger log = LoggerFactory.getLogger(SinglePlayerPresenter.class);
private final Button backButton;
private final Canvas canvas;
private final StackPane contentPane;
private final GraphicsContext gc;
private final PlayerBoard gameBoard;
private final Game game;
public SinglePlayerPresenter(List<String> words, Set<String> guesses, EventHandler<ActionEvent> backButtonHandler) {
backButton = new Button("Back");
backButton.getStyleClass().add("game-nav");
StackPane.setAlignment(backButton, Pos.BOTTOM_LEFT);
StackPane.setMargin(backButton, new Insets(0, 0, 10, 10));
backButton.setOnAction(backButtonHandler);
backButton.setPrefWidth(50);
canvas = new Canvas(650, 420);
canvas.setFocusTraversable(true);
canvas.setOnKeyPressed(e -> keyPressed(e));
gc = canvas.getGraphicsContext2D();
gc.setFont(Font.font(24));
gc.setTextAlign(TextAlignment.CENTER);
gc.setTextBaseline(VPos.CENTER);
contentPane = new StackPane();
contentPane.getChildren().add(canvas);
contentPane.getChildren().add(backButton);
gameBoard = new PlayerBoard(canvas, 200, 60);
game = new Game(null, null, words, guesses);
}
private void clearBoards(boolean clearScore) {
gameBoard.clearBoard();
if (clearScore) {
gameBoard.clearScore();
}
}
public Node getNode() {
return contentPane;
}
private void keyPressed(KeyEvent e) {
final KeyCode keyCode = e.getCode();
if (keyCode == KeyCode.BACK_SPACE) {
if (gameBoard.handleBackspace()) {
repaint();
}
} else if (keyCode == KeyCode.ENTER) {
final String guess = gameBoard.handleEnter();
if (guess != null) {
final int[] result = game.evaluate(guess);
Report report = new Report();
report.setGuess(guess);
report.setResult(result);
if (Game.isCorrect(result)) {
final String newWord = game.newWord();
final String firstLetter = String.valueOf(newWord.charAt(0));
report.setCorrect(true);
report.setFirstLetter(firstLetter);
report.setResult(result);
onCorrectGuess(report);
} else {
onIncorrectGuess(report);
}
}
} else if (keyCode.isLetterKey()) {
if (gameBoard.handleLetter(keyCode.getName())) {
repaint();
}
}
}
private void newWord(String firstLetter) {
gameBoard.setProgress(0, firstLetter.charAt(0));
}
private void onCorrectGuess(Report report) {
final String firstLetter = report.getFirstLetter();
log.info("I guessed correctly!");
Platform.runLater(() -> {
gameBoard.addToScore(100);
clearBoards(false);
newWord(firstLetter);
repaint();
});
}
private void onIncorrectGuess(Report report) {
final String guess = report.getGuess();
final int[] result = report.getResult();
log.info("My result: " + Arrays.toString(result));
Platform.runLater(() -> {
if (Arrays.equals(result, Game.INVALID_GUESS)) {
gameBoard.addGuess("-----");
} else {
for (int i = 0; i < Game.WORD_LENGTH; i++) {
if (result[i] == Game.CORRECT_CHARACTER) {
gameBoard.setProgress(i, guess.charAt(i));
}
}
gameBoard.addGuess(guess);
}
gameBoard.addResult(result);
repaint();
});
}
private void repaint() {
gc.clearRect(0, 0, canvas.getWidth(), canvas.getHeight());
gameBoard.drawBoard();
}
public void startGame() {
final String firstWord = game.newGame();
final String firstLetter = String.valueOf(firstWord.charAt(0));
newWord(firstLetter);
repaint();
}
}

View File

@ -0,0 +1,25 @@
package lingo.client.util;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.fxml.Initializable;
/**
* Identifies a controller that will be created by an {@link FXMLLoader}. The
* {@code FXMLLoader} will automatically inject {@code location} and
* {@code resources} properties into the controller, and then it will call the
* no-arg {@link #initialize()} method. This is the recommended approach: don't
* use the {@link Initializable} interface.
*/
public interface FxmlController {
/**
* Called by the {@link FXMLLoader} to initialize a controller after its
* root element has been completely processed. This means all of the
* controller's {@link FXML} elements will be injected, and they can be used
* to wire up the GUI in ways that couldn't be accomplished using pure FXML,
* e.g. attaching property listeners.
*/
void initialize();
}

View File

@ -0,0 +1,35 @@
package lingo.client.view;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
public abstract class Board {
public static final double HEIGHT = 300;
public static final double WIDTH = 250;
public static final double SIDE = 50;
protected final Canvas canvas;
protected final GraphicsContext gc;
/** The leftmost x-coordinate */
protected final double xInit;
/** The topmost y-coordinate */
protected final double yInit;
public Board(Canvas canvas, double xInit, double yInit) {
this.canvas = canvas;
this.gc = canvas.getGraphicsContext2D();
this.xInit = xInit;
this.yInit = yInit;
}
public void clearBoard() {
gc.clearRect(xInit, yInit, WIDTH, HEIGHT);
}
public abstract void drawBoard();
}

View File

@ -0,0 +1,80 @@
package lingo.client.view;
import java.util.ArrayList;
import java.util.List;
import javafx.scene.canvas.Canvas;
import javafx.scene.paint.Color;
public abstract class GameBoard extends Board {
/** Tracks the player's previous guess evaluations */
protected final List<int[]> results = new ArrayList<>();
/** Tracks the player's score */
protected int score;
public GameBoard(Canvas canvas, double xInit, double yInit) {
super(canvas, xInit, yInit);
}
@Override
public void clearBoard() {
super.clearBoard();
results.clear();
}
protected void drawScore() {
double scoreX = xInit + WIDTH / 2;
double scoreY = yInit / 2;
gc.setFill(Color.BLACK);
gc.fillText(String.valueOf(score), scoreX, scoreY);
}
protected void drawGrid() {
gc.beginPath();
for (int x = 0; x <= WIDTH; x += SIDE) {
gc.moveTo(xInit + x, yInit);
gc.lineTo(xInit + x, yInit + HEIGHT);
}
for (int y = 0; y <= HEIGHT; y += SIDE) {
gc.moveTo(xInit, yInit + y);
gc.lineTo(xInit + WIDTH, yInit + y);
}
gc.setFill(Color.BLACK);
gc.stroke();
}
protected void drawResults() {
double y = yInit + SIDE * 1.5;
int numResults = Math.min(4, results.size());
for (int i = 0; i < numResults; i++) {
double x = xInit + SIDE * 0.5;
int[] result = results.get(results.size() - numResults + i);
for (int j = 0; j < 5; j++) {
if (result[j] == 1) {
gc.setFill(Color.YELLOW);
gc.fillRect(x - SIDE * 0.5, y - SIDE * 0.5, SIDE, SIDE);
} else if (result[j] == 2) {
gc.setFill(Color.ORANGE);
gc.fillRect(x - SIDE * 0.5, y - SIDE * 0.5, SIDE, SIDE);
}
x += SIDE;
}
y += SIDE;
}
}
public void addResult(int[] value) {
results.add(value);
}
public void addToScore(int value) {
score += value;
}
public void clearScore() {
score = 0;
}
}

View File

@ -0,0 +1,18 @@
package lingo.client.view;
import javafx.scene.canvas.Canvas;
public class OpponentBoard extends GameBoard {
public OpponentBoard(Canvas canvas, double xInit, double yInit) {
super(canvas, xInit, yInit);
}
@Override
public void drawBoard() {
drawScore();
drawResults();
drawGrid();
}
}

View File

@ -0,0 +1,120 @@
package lingo.client.view;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javafx.scene.canvas.Canvas;
import javafx.scene.paint.Color;
public class PlayerBoard extends GameBoard {
/** Tracks the player's current guess */
private final StringBuilder guess = new StringBuilder();
/** Tracks the player's previous guesses */
private final List<String> guesses = new ArrayList<>();
/** Tracks the player's progress toward the current word */
private final Map<Integer, String> progress = new HashMap<>();
public PlayerBoard(Canvas canvas, double xInit, double yInit) {
super(canvas, xInit, yInit);
}
@Override
public void clearBoard() {
super.clearBoard();
guess.setLength(0);
guesses.clear();
progress.clear();
results.clear();
}
@Override
public void drawBoard() {
drawScore();
drawInput();
drawResults();
double yStart = drawGuesses();
drawHint(yStart);
drawGrid();
}
private void drawInput() {
gc.setFill(Color.GREEN);
double x = xInit + SIDE * 0.5;
double y = yInit + SIDE * 0.5;
for (int i = 0; i < guess.length(); i++) {
String character = String.valueOf(guess.charAt(i));
gc.fillText(character, x, y);
x += SIDE;
}
}
private double drawGuesses() {
double y = yInit + SIDE * 1.5;
double numGuesses = Math.min(4, guesses.size());
for (int i = 0; i < numGuesses; i++) {
double x = xInit + SIDE * 0.5;
String guess = guesses.get((int) (guesses.size() - numGuesses + i));
for (int j = 0; j < 5; j++) {
String character = String.valueOf(guess.charAt(j));
gc.setFill(Color.GREEN);
gc.fillText(character, x, y);
x += SIDE;
}
y += SIDE;
}
return y;
}
private void drawHint(double yStart) {
double x = xInit + SIDE * 0.5;
for (int i = 0; i < 5; i++) {
if (progress.containsKey(i)) {
gc.fillText(progress.get(i), x, yStart);
}
x += SIDE;
}
}
public void addGuess(String value) {
guesses.add(value);
}
public String getGuess() {
return guess.toString();
}
public boolean handleBackspace() {
if (guess.length() > 0) {
guess.setLength(guess.length() - 1);
return true;
}
return false;
}
public String handleEnter() {
if (guess.length() == 5) {
final String value = guess.toString();
guess.setLength(0);
return value;
}
return null;
}
public boolean handleLetter(String letter) {
if (guess.length() < 5) {
guess.append(letter);
return true;
}
return false;
}
public void setProgress(int i, char letter) {
progress.put(i, String.valueOf(letter));
}
}

View File

@ -0,0 +1,195 @@
h1 {
font: 24pt sans-serif;
font-variant: small-caps;
text-align: center
}
/* Below based on http://tobiasahlin.com/spinkit */
.sk-cube-grid {
width: 200px;
height: 200px;
margin: 35px auto;
}
.sk-cube-grid .sk-cube {
width: 20%;
height: 20%;
background-color: orange;
float: left;
-webkit-animation: sk-cubeGridScaleDelay 1.5s infinite ease-in-out;
animation: sk-cubeGridScaleDelay 1.5s infinite ease-in-out;
}
/*
0.0s - 26
0.1s - 27, 21
0.2s - 28, 22, 16
0.3s - 29, 23, 17, 11
0.4s - 30, 24, 18, 12, 06
0.5s - 25, 19, 13, 07, 01
0.6s - 20, 14, 08, 02
0.7s - 15, 09, 03
0.8s - 10, 04
0.9s - 05
*/
/* 0 second delay */
.sk-cube-grid .sk-cube26 {
-webkit-animation-delay: 0s;
animation-delay: 0s;
}
/* 0.1 second delay */
.sk-cube-grid .sk-cube27 {
-webkit-animation-delay: 0.1s;
animation-delay: 0.1s;
}
.sk-cube-grid .sk-cube21 {
-webkit-animation-delay: 0.1s;
animation-delay: 0.1s;
}
/* 0.2 second delay */
.sk-cube-grid .sk-cube28 {
-webkit-animation-delay: 0.2s;
animation-delay: 0.2s;
}
.sk-cube-grid .sk-cube22 {
-webkit-animation-delay: 0.2s;
animation-delay: 0.2s;
}
.sk-cube-grid .sk-cube16 {
-webkit-animation-delay: 0.2s;
animation-delay: 0.2s;
}
/* 0.3 second delay */
.sk-cube-grid .sk-cube29 {
-webkit-animation-delay: 0.3s;
animation-delay: 0.3s;
}
.sk-cube-grid .sk-cube23 {
-webkit-animation-delay: 0.3s;
animation-delay: 0.3s;
}
.sk-cube-grid .sk-cube17 {
-webkit-animation-delay: 0.3s;
animation-delay: 0.3s;
}
.sk-cube-grid .sk-cube11 {
-webkit-animation-delay: 0.3s;
animation-delay: 0.3s;
}
/* 0.4 second delay */
.sk-cube-grid .sk-cube30 {
-webkit-animation-delay: 0.4s;
animation-delay: 0.4s;
}
.sk-cube-grid .sk-cube24 {
-webkit-animation-delay: 0.4s;
animation-delay: 0.4s;
}
.sk-cube-grid .sk-cube18 {
-webkit-animation-delay: 0.4s;
animation-delay: 0.4s;
}
.sk-cube-grid .sk-cube12 {
-webkit-animation-delay: 0.4s;
animation-delay: 0.4s;
}
.sk-cube-grid .sk-cube6 {
-webkit-animation-delay: 0.4s;
animation-delay: 0.4s;
}
/* 0.5 second delay */
.sk-cube-grid .sk-cube25 {
-webkit-animation-delay: 0.5s;
animation-delay: 0.5s;
}
.sk-cube-grid .sk-cube19 {
-webkit-animation-delay: 0.5s;
animation-delay: 0.5s;
}
.sk-cube-grid .sk-cube13 {
-webkit-animation-delay: 0.5s;
animation-delay: 0.5s;
}
.sk-cube-grid .sk-cube7 {
-webkit-animation-delay: 0.5s;
animation-delay: 0.5s;
}
.sk-cube-grid .sk-cube1 {
-webkit-animation-delay: 0.5s;
animation-delay: 0.5s;
}
/* 0.6 second delay */
.sk-cube-grid .sk-cube20 {
-webkit-animation-delay: 0.6s;
animation-delay: 0.6s;
}
.sk-cube-grid .sk-cube14 {
-webkit-animation-delay: 0.6s;
animation-delay: 0.6s;
}
.sk-cube-grid .sk-cube8 {
-webkit-animation-delay: 0.6s;
animation-delay: 0.6s;
}
.sk-cube-grid .sk-cube2 {
-webkit-animation-delay: 0.6s;
animation-delay: 0.6s;
}
/* 0.7 second delay */
.sk-cube-grid .sk-cube15 {
-webkit-animation-delay: 0.7s;
animation-delay: 0.7s;
}
.sk-cube-grid .sk-cube9 {
-webkit-animation-delay: 0.7s;
animation-delay: 0.7s;
}
.sk-cube-grid .sk-cube3 {
-webkit-animation-delay: 0.7s;
animation-delay: 0.7s;
}
/* 0.8 second delay */
.sk-cube-grid .sk-cube10 {
-webkit-animation-delay: 0.8s;
animation-delay: 0.8s;
}
.sk-cube-grid .sk-cube4 {
-webkit-animation-delay: 0.8s;
animation-delay: 0.8s;
}
/* 0.9 second delay */
.sk-cube-grid .sk-cube5 {
-webkit-animation-delay: 0.9s;
animation-delay: 0.9s;
}
@-webkit-keyframes sk-cubeGridScaleDelay {
0%, 70%, 100% {
-webkit-transform: scale3D(1, 1, 1);
transform: scale3D(1, 1, 1);
} 35% {
-webkit-transform: scale3D(0, 0, 1);
transform: scale3D(0, 0, 1);
}
}
@keyframes sk-cubeGridScaleDelay {
0%, 70%, 100% {
-webkit-transform: scale3D(1, 1, 1);
transform: scale3D(1, 1, 1);
} 35% {
-webkit-transform: scale3D(0, 0, 1);
transform: scale3D(0, 0, 1);
}
}

View File

@ -0,0 +1,39 @@
<!doctype html>
<html>
<body>
<h1>Waiting for Opponent</h1>
<!-- Based on http://tobiasahlin.com/spinkit -->
<div class="sk-cube-grid">
<div class="sk-cube sk-cube1"></div>
<div class="sk-cube sk-cube2"></div>
<div class="sk-cube sk-cube3"></div>
<div class="sk-cube sk-cube4"></div>
<div class="sk-cube sk-cube5"></div>
<div class="sk-cube sk-cube6"></div>
<div class="sk-cube sk-cube7"></div>
<div class="sk-cube sk-cube8"></div>
<div class="sk-cube sk-cube9"></div>
<div class="sk-cube sk-cube10"></div>
<div class="sk-cube sk-cube11"></div>
<div class="sk-cube sk-cube12"></div>
<div class="sk-cube sk-cube13"></div>
<div class="sk-cube sk-cube14"></div>
<div class="sk-cube sk-cube15"></div>
<div class="sk-cube sk-cube16"></div>
<div class="sk-cube sk-cube17"></div>
<div class="sk-cube sk-cube18"></div>
<div class="sk-cube sk-cube19"></div>
<div class="sk-cube sk-cube20"></div>
<div class="sk-cube sk-cube21"></div>
<div class="sk-cube sk-cube22"></div>
<div class="sk-cube sk-cube23"></div>
<div class="sk-cube sk-cube24"></div>
<div class="sk-cube sk-cube25"></div>
<div class="sk-cube sk-cube26"></div>
<div class="sk-cube sk-cube27"></div>
<div class="sk-cube sk-cube28"></div>
<div class="sk-cube sk-cube29"></div>
<div class="sk-cube sk-cube30"></div>
</div>
</body>
</html>

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.geometry.Insets?>
<?import javafx.geometry.Pos?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.layout.VBox?>
<BorderPane xmlns:fx="http://javafx.com/fxml"
fx:controller="lingo.client.bootstrap.LingoPresenter"
fx:id="content"
prefWidth="650"
prefHeight="420">
<center>
<BorderPane fx:id="gameModeChooser">
<center>
<VBox spacing="20" alignment="CENTER">
<children>
<Button text="Practice" onAction="#showSinglePlayer" prefWidth="350" styleClass="game-mode" />
<Button text="Multiplayer" onAction="#showMultiplayer" prefWidth="350" styleClass="game-mode" />
</children>
</VBox>
</center>
<bottom>
<Button text="Exit" onAction="#exit" prefWidth="50" styleClass="game-nav">
<BorderPane.alignment>
<Pos fx:value="BOTTOM_LEFT" />
</BorderPane.alignment>
<BorderPane.margin>
<Insets bottom="10" left="10" />
</BorderPane.margin>
</Button>
</bottom>
</BorderPane>
</center>
</BorderPane>

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.geometry.Insets?>
<?import javafx.geometry.Pos?>
<?import javafx.scene.canvas.Canvas?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.layout.StackPane?>
<?import javafx.scene.web.WebView?>
<StackPane xmlns:fx="http://javafx.com/fxml"
fx:controller="lingo.client.multiplayer.MultiplayerPresenter"
fx:id="contentPane">
<children>
<Canvas fx:id="canvas" width="650" height="420" onKeyPressed="#keyPressed" />
<WebView fx:id="webView" prefWidth="650" prefHeight="420" />
<Button fx:id="backButton" text="Back" prefWidth="50" styleClass="game-nav">
<StackPane.alignment>
<Pos fx:value="BOTTOM_LEFT" />
</StackPane.alignment>
<StackPane.margin>
<Insets bottom="10" left="10" />
</StackPane.margin>
</Button>
</children>
</StackPane>

View File

@ -0,0 +1,12 @@
.text {
-fx-font-family: Helvetica;
-fx-font-smoothing-type: gray;
}
.game-mode {
-fx-font-size: 24px;
}
.game-nav {
-fx-font-size: 12px;
}

31
common/pom.xml Normal file
View File

@ -0,0 +1,31 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.charego</groupId>
<artifactId>lingo-websocket</artifactId>
<version>1.0</version>
</parent>
<artifactId>lingo-websocket-common</artifactId>
<name>Lingo WebSocket :: Common</name>
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.3.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,91 @@
package lingo.common;
import java.util.Collections;
import java.util.List;
import java.util.Set;
public class Game {
public static final int INCORRECT_CHARACTER = 0;
public static final int INCORRECT_POSITION = 1;
public static final int CORRECT_CHARACTER = 2;
public static final int WORD_LENGTH = 5;
public static final int[] INVALID_GUESS = new int[] { 9, 9, 9, 9, 9 };
public final String playerOne;
public final String playerTwo;
private final Set<String> acceptableGuesses;
private final List<String> possibleWords;
private String word;
private int wordIndex = 0;
public Game(String playerOne, String playerTwo, List<String> possibleWords, Set<String> acceptableGuesses) {
this.playerOne = playerOne;
this.playerTwo = playerTwo;
this.possibleWords = possibleWords;
this.acceptableGuesses = acceptableGuesses;
}
private static int indexOf(char[] array, char searchTerm) {
for (int i = 0; i < WORD_LENGTH; i++) {
if (array[i] == searchTerm) {
return i;
}
}
return -1;
}
public static boolean isCorrect(int[] result) {
for (int i = 0; i < WORD_LENGTH; i++) {
if (result[i] != CORRECT_CHARACTER) {
return false;
}
}
return true;
}
public int[] evaluate(String guess) {
if (!acceptableGuesses.contains(guess)) {
return INVALID_GUESS;
}
// the guess is acceptable
int[] result = new int[WORD_LENGTH];
char[] remaining = new char[WORD_LENGTH];
for (int i = 0; i < WORD_LENGTH; i++) {
if (guess.charAt(i) == word.charAt(i)) {
result[i] = CORRECT_CHARACTER;
} else {
result[i] = INCORRECT_CHARACTER;
remaining[i] = word.charAt(i);
}
}
for (int i = 0; i < WORD_LENGTH; i++) {
if (result[i] == INCORRECT_CHARACTER) {
int index = indexOf(remaining, guess.charAt(i));
if (index != -1) {
result[i] = INCORRECT_POSITION;
remaining[index] = 0;
}
}
}
return result;
}
public String newGame() {
Collections.shuffle(possibleWords);
wordIndex = 0;
return newWord();
}
public String newWord() {
word = possibleWords.get(wordIndex++);
return word;
}
}

View File

@ -0,0 +1,47 @@
package lingo.common;
public class Report {
private boolean correct;
private String firstLetter;
private String guess;
private int[] result;
public Report() {}
public String getFirstLetter() {
return firstLetter;
}
public void setFirstLetter(String value) {
this.firstLetter = value;
}
public String getGuess() {
return guess;
}
public void setGuess(String value) {
this.guess = value;
}
public int[] getResult() {
return result;
}
public void setResult(int[] value) {
this.result = value;
}
public boolean isCorrect() {
return correct;
}
public void setCorrect(boolean value) {
this.correct = value;
}
}

View File

@ -0,0 +1,38 @@
package lingo.common;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class WordReader {
private static void readFileToCollection(String filename, Collection<String> c) throws IOException {
try (final InputStream stream = WordReader.class.getResourceAsStream(filename);
final InputStreamReader streamReader = new InputStreamReader(stream);
final BufferedReader bufferedReader = new BufferedReader(streamReader)) {
String line = null;
while ((line = bufferedReader.readLine()) != null) {
c.add(line);
}
}
}
public static List<String> readFileToList(String filename) throws IOException {
final List<String> list = new ArrayList<>();
readFileToCollection(filename, list);
return list;
}
public static Set<String> readFileToSet(String filename) throws IOException {
final Set<String> list = new HashSet<>();
readFileToCollection(filename, list);
return list;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

30
pom.xml Normal file
View File

@ -0,0 +1,30 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.4.2.RELEASE</version>
</parent>
<groupId>com.charego</groupId>
<artifactId>lingo-websocket</artifactId>
<version>1.0</version>
<packaging>pom</packaging>
<name>Lingo WebSocket</name>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>1.8</java.version>
</properties>
<modules>
<module>client</module>
<module>client-api</module>
<module>common</module>
<module>server</module>
</modules>
</project>

24
server/LingoServer.launch Normal file
View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<launchConfiguration type="org.eclipse.jdt.launching.localJavaApplication">
<listAttribute key="org.eclipse.debug.core.MAPPED_RESOURCE_PATHS">
<listEntry value="/lingo-websocket-server/src/main/java/lingo/server/LingoServer.java"/>
</listAttribute>
<listAttribute key="org.eclipse.debug.core.MAPPED_RESOURCE_TYPES">
<listEntry value="1"/>
</listAttribute>
<listAttribute key="org.eclipse.debug.ui.favoriteGroups">
<listEntry value="org.eclipse.debug.ui.launchGroup.debug"/>
<listEntry value="org.eclipse.debug.ui.launchGroup.run"/>
</listAttribute>
<listAttribute key="org.eclipse.jdt.launching.CLASSPATH">
<listEntry value="&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; standalone=&quot;no&quot;?&gt;&#13;&#10;&lt;runtimeClasspathEntry containerPath=&quot;org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8&quot; path=&quot;1&quot; type=&quot;4&quot;/&gt;&#13;&#10;"/>
<listEntry value="&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; standalone=&quot;no&quot;?&gt;&#13;&#10;&lt;runtimeClasspathEntry internalArchive=&quot;/lingo-websocket-server/src/main/config&quot; path=&quot;3&quot; type=&quot;2&quot;/&gt;&#13;&#10;"/>
<listEntry value="&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; standalone=&quot;no&quot;?&gt;&#13;&#10;&lt;runtimeClasspathEntry path=&quot;3&quot; projectName=&quot;lingo-websocket-server&quot; type=&quot;1&quot;/&gt;&#13;&#10;"/>
<listEntry value="&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; standalone=&quot;no&quot;?&gt;&#13;&#10;&lt;runtimeClasspathEntry containerPath=&quot;org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER&quot; path=&quot;3&quot; type=&quot;4&quot;/&gt;&#13;&#10;"/>
</listAttribute>
<stringAttribute key="org.eclipse.jdt.launching.CLASSPATH_PROVIDER" value="org.eclipse.m2e.launchconfig.classpathProvider"/>
<booleanAttribute key="org.eclipse.jdt.launching.DEFAULT_CLASSPATH" value="false"/>
<stringAttribute key="org.eclipse.jdt.launching.MAIN_TYPE" value="lingo.server.LingoServer"/>
<stringAttribute key="org.eclipse.jdt.launching.PROJECT_ATTR" value="lingo-websocket-server"/>
<stringAttribute key="org.eclipse.jdt.launching.SOURCE_PATH_PROVIDER" value="org.eclipse.m2e.launchconfig.sourcepathProvider"/>
</launchConfiguration>

53
server/pom.xml Normal file
View File

@ -0,0 +1,53 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.charego</groupId>
<artifactId>lingo-websocket</artifactId>
<version>1.0</version>
</parent>
<artifactId>lingo-websocket-server</artifactId>
<name>Lingo WebSocket :: Server</name>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<version>${project.version}</version>
<artifactId>lingo-websocket-client-api</artifactId>
</dependency>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Development Tools -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<!-- Prevent transitive application to other modules -->
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!-- Enable hot refreshing of resources -->
<!-- Add src/main/resources to the classpath -->
<!-- Remove duplicate resources from target/classes -->
<addResources>true</addResources>
<!-- Add src/main/config to the classpath -->
<folders>src/main/config</folders>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,5 @@
# http://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html
server:
address: localhost
port: 8080

View File

@ -0,0 +1,179 @@
package lingo.server;
import static org.springframework.messaging.simp.SimpMessageHeaderAccessor.SESSION_ID_HEADER;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.messaging.MessageHeaders;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessageType;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Controller;
import org.springframework.web.socket.messaging.AbstractSubProtocolEvent;
import org.springframework.web.socket.messaging.SessionConnectedEvent;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
import lingo.client.api.StompTopics;
import lingo.common.Game;
import lingo.common.Report;
@Controller
@MessageMapping("/lingo")
public class LingoController implements ApplicationListener<AbstractSubProtocolEvent> {
private static final Logger log = LoggerFactory.getLogger(LingoController.class);
@Autowired
private SimpMessagingTemplate messagingTemplate;
@Autowired
private WordRepository wordRepo;
private final List<String> waitingList = new ArrayList<>();
private final Map<String, Game> gameBySession = new HashMap<>();
@MessageMapping("/guess")
public void guess(String guess, @Header(SESSION_ID_HEADER) String sessionId) {
guess = guess.toUpperCase();
log.info("Player {} guessed: {}", sessionId, guess);
final Game game = gameBySession.get(sessionId);
final int[] result = game.evaluate(guess);
// Generate reports
final Report playerReport = new Report();
final Report opponentReport = new Report();
playerReport.setGuess(guess);
if (Game.isCorrect(result)) {
final String newWord = game.newWord();
final String firstLetter = String.valueOf(newWord.charAt(0));
log.info("New word: {}", newWord);
playerReport.setCorrect(true);
playerReport.setFirstLetter(firstLetter);
opponentReport.setCorrect(true);
opponentReport.setFirstLetter(firstLetter);
opponentReport.setGuess(guess);
} else {
playerReport.setResult(result);
opponentReport.setResult(result);
}
final String opponentId = sessionId.equals(game.playerOne) ? game.playerTwo : game.playerOne;
sendToUser(sessionId, StompTopics.PLAYER_REPORTS, playerReport);
sendToUser(opponentId, StompTopics.OPPONENT_REPORTS, opponentReport);
}
@MessageMapping("/join")
public void join(@Header(SESSION_ID_HEADER) String sessionId) {
log.info("Player {} joined", sessionId);
joinWaitingList(sessionId);
}
private void joinWaitingList(String sessionId) {
synchronized (waitingList) {
if (!waitingList.contains(sessionId)) {
waitingList.add(sessionId);
waitingList.notify();
}
}
}
private void leave(String sessionId) {
final Game game = gameBySession.remove(sessionId);
if (game == null) {
leaveWaitingList(sessionId);
} else {
log.info("Player {} left their game!", sessionId);
final String opponentId = sessionId.equals(game.playerOne) ? game.playerTwo : game.playerOne;
gameBySession.remove(opponentId);
sendToUser(opponentId, StompTopics.OPPONENT_LEFT, "You win!");
joinWaitingList(opponentId);
}
}
private void leaveWaitingList(String sessionId) {
synchronized (waitingList) {
waitingList.remove(sessionId);
waitingList.notify();
}
}
@Override
public void onApplicationEvent(AbstractSubProtocolEvent event) {
if (event instanceof SessionConnectedEvent) {
onSessionConnected((SessionConnectedEvent) event);
} else if (event instanceof SessionDisconnectEvent) {
onSessionDisconnect((SessionDisconnectEvent) event);
}
}
private void onSessionConnected(SessionConnectedEvent event) {
final String sessionId = StompHeaderAccessor.wrap(event.getMessage()).getSessionId();
log.info("Session connected: {}", sessionId);
}
private void onSessionDisconnect(SessionDisconnectEvent event) {
final String sessionId = event.getSessionId();
log.info("Session disconnected: {}", sessionId);
leave(sessionId);
}
@PostConstruct
private void postConstruct() {
new Thread(new WaitingListListener()).start();
}
private void sendToUser(String user, String destination, Object payload) {
// TODO: cache the headers?
final SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
headerAccessor.setSessionId(user);
headerAccessor.setLeaveMutable(true);
final MessageHeaders headers = headerAccessor.getMessageHeaders();
messagingTemplate.convertAndSendToUser(user, destination, payload, headers);
}
/**
* Task that spawns a game whenever two players are waiting.
*/
private class WaitingListListener implements Runnable {
@Override
public void run() {
while (true) {
final String playerOne;
final String playerTwo;
synchronized (waitingList) {
while (waitingList.size() < 2) {
try {
waitingList.wait();
} catch (InterruptedException ok) {
ok.printStackTrace();
}
}
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);
sendToUser(playerOne, StompTopics.OPPONENT_JOINED, firstLetter);
sendToUser(playerTwo, StompTopics.OPPONENT_JOINED, firstLetter);
}
}
}
}

View File

@ -0,0 +1,13 @@
package lingo.server;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class LingoServer {
public static void main(String[] args) {
SpringApplication.run(LingoServer.class, args);
}
}

View File

@ -0,0 +1,24 @@
package lingo.server;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.AbstractWebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/stomp");
}
}

View File

@ -0,0 +1,39 @@
package lingo.server;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import org.springframework.stereotype.Component;
import lingo.common.WordReader;
@Component
public class WordRepository {
private final Set<String> guesses;
private final List<String> words;
public WordRepository() throws IOException {
guesses = WordReader.readFileToSet("/guesses.txt");
words = WordReader.readFileToList("/words.txt");
}
/**
* Returns the set of acceptable guesses (unmodifiable).
*/
public Set<String> getGuesses() {
return Collections.unmodifiableSet(guesses);
}
/**
* Returns a copy of the list of potential answers (OK to shuffle it).
*/
public List<String> getWords() {
return new ArrayList<>(words);
}
}