First layout of the game

This commit is contained in:
Mysaa Java
2026-08-24 15:24:50 +02:00
parent 902ba26df3
commit 639a5565ad
16 changed files with 698 additions and 7 deletions
@@ -1,13 +1,30 @@
package com.bernard.nodecames;
import java.util.UUID;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.servlet.view.RedirectView;
import com.bernard.nodecames.game.GameManager;
@Controller
public class HttpController {
@GetMapping("/")
public String index() {
return "index.html";
}
@GetMapping("/")
public String index() {
return "index.html";
}
@GetMapping("/room/{id}/grid")
public String grid(@PathVariable("id") String id) {
return "grid";
}
@GetMapping("/create-room")
public RedirectView createRoom() {
UUID uuid = GameManager.newGrid();
return new RedirectView("/room/"+uuid.toString()+"/grid");
}
}
@@ -0,0 +1,44 @@
package com.bernard.nodecames.frontend;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration;
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer {
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
container.setMaxTextMessageBufferSize(8192);
container.setMaxBinaryMessageBufferSize(8192);
return container;
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/socket").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// STOMP messages whose destination header begins with /app are routed to
// @MessageMapping methods in @Controller classes
config.setApplicationDestinationPrefixes("/app");
// Use the built-in message broker for subscriptions and broadcasting and
// route messages whose destination header begins with /topic or /queue to the broker
config.enableSimpleBroker("/topic");
}
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
registry.setMessageSizeLimit(4 * 8192);
registry.setTimeToFirstMessage(30000);
}
}
@@ -0,0 +1,75 @@
package com.bernard.nodecames.frontend;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.messaging.Message;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import com.bernard.nodecames.game.GameManager;
import com.bernard.nodecames.model.Grid;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.JsonNodeFactory;
@Controller
public class WebSocketController {
private static final JsonNodeFactory jsn = JsonNodeFactory.instance;
@Autowired
GameManager gm;
@GetMapping("/room/{id}/game/{player}")
public Object grid(@PathVariable("id") String gridId, @PathVariable("player") String playerStr) {
char player;
if(playerStr.equals("player-a"))
player = 'A';
else if (playerStr.equals("player-b"))
player = 'B';
else
throw new IllegalArgumentException("Unknown player type");
Grid g = GameManager.findGrid(gridId);
return new ResponseEntity<>(GameManager.gridData(g, player), HttpStatus.OK);
}
@MessageMapping("submit-hint")
@SendTo("/topic/submit-hint")
public JsonNode submitHint(@Header("room") String roomId, @Header("player") String player, @Payload JsonNode content) {
String hint = content.asObject().get("hint").stringValue();
int wordCount = content.asObject().get("hintWordCount").intValue();
Grid g = GameManager.findGrid(roomId);
gm.proposeHint(g, player.charAt(0), hint, wordCount);
return content;
}
@MessageMapping("point-card")
@SendTo("/topic/point-card")
public JsonNode pointCard(@Header("room") String roomId, @Header("player") String player, @Payload JsonNode content) {
int cardIndex = content.asObject().get("cardIndex").intValue();
Grid g = GameManager.findGrid(roomId);
gm.pointCard(g, player.charAt(0), cardIndex);
return content;
}
@MessageMapping("end-guessing")
@SendTo("/topic/end-guessing")
public String endGuessing(@Header("room") String roomId, @Header("player") String player) {
Grid g = GameManager.findGrid(roomId);
gm.endGuessing(g, player.charAt(0));
return "";
}
}
@@ -0,0 +1,219 @@
package com.bernard.nodecames.game;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.IntStream;
import org.springframework.stereotype.Service;
import com.bernard.nodecames.model.Card;
import com.bernard.nodecames.model.GameEvent;
import com.bernard.nodecames.model.Grid;
import com.bernard.nodecames.model.Hint;
import com.bernard.nodecames.model.Card.Color;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.JsonNodeFactory;
import tools.jackson.databind.node.ObjectNode;
@Service
public class GameManager {
private static final JsonNodeFactory jsn = JsonNodeFactory.instance;
private static Map<UUID, Grid> games = new HashMap<>();
public static JsonNode gridData(Grid g, char player) {
ArrayNode cardsNode = jsn.arrayNode(g.getCardCount());
for (int i = 0; i < g.getCardCount(); i++) {
ObjectNode cardNode = jsn.objectNode();
Card c = g.getCards()[i];
cardNode.set("word", jsn.stringNode(c.getWord()));
cardNode.set("color", jsn.stringNode((player=='A'?c.getColorA():c.getColorB()).name().toLowerCase()));
if((player=='A' && c.isRevealedA()) || (player=='B' && c.isRevealedB()))
cardNode.set("otherColor", jsn.stringNode((player=='A'?c.getColorB():c.getColorA()).name().toLowerCase()));
cardsNode.add(cardNode);
}
ArrayNode eventsNode = jsn.arrayNode(g.getGameEvents().size());
for(GameEvent ge : g.getGameEvents()) {
ObjectNode geNode = jsn.objectNode();
geNode.set("type", jsn.stringNode(ge.getType().name().toLowerCase()));
geNode.set("issuer", jsn.stringNode(Character.toString(ge.getIssuer())));
switch (ge.getType()) {
case GameEvent.Type.HINT:
Hint hint = (Hint)ge.getData();
geNode.set("word", jsn.stringNode(hint.getWord()));
geNode.set("wordCount", jsn.numberNode(hint.getWordCount()));
break;
case GameEvent.Type.GUESS:
Card c = (Card)ge.getData();
Integer pos = IntStream.range(0, g.getCardCount())
.filter(i -> c == g.getCards()[i])
.findFirst()
.getAsInt();
geNode.set("card-index", jsn.numberNode(pos));
break;
case GameEvent.Type.END_GUESSING:
geNode.set("manual", jsn.booleanNode((Boolean)ge.getData()));
break;
case GameEvent.Type.START_GAME:
break;
}
eventsNode.add(geNode);
}
ObjectNode currentHintNode = jsn.objectNode();
if(g.getCurrentHint() != null) {
currentHintNode.set("word", jsn.stringNode(g.getCurrentHint().getWord()));
currentHintNode.set("wordCount", jsn.numberNode(g.getCurrentHint().getWordCount()));
}
ObjectNode out = jsn.objectNode();
out.set("cards", cardsNode);
out.set("phase", jsn.stringNode(g.getPhase().name().toLowerCase()));
out.set("events", eventsNode);
if(g.getCurrentHint() != null)
out.set("current-hint", currentHintNode);
out.set("current-guess-count", jsn.numberNode(g.getCurrentGuessCount()));
out.set("used-hints", jsn.numberNode(g.getUsedHints()));
out.set("used-whites", jsn.numberNode(g.getUsedWhites()));
return out;
}
public static Grid findGrid(String gridId) {
return games.get(UUID.fromString(gridId));
}
public static UUID newGrid() {
UUID uuid = UUID.randomUUID();
String[] words = new String[25];
for (int i = 0; i < words.length; i++)
words[i] = "MotNumero"+Integer.toString(i);
List<Card> cards = new ArrayList<>(25);
int i = 0;
cards.add(new Card(words[i++], Color.BLACK, Color.BLACK));
for (;i < 4; i++)
cards.add(new Card(words[i], Color.GREEN, Color.GREEN));
for (;i < 9; i++)
cards.add(new Card(words[i], Color.WHITE, Color.GREEN));
for (;i < 14; i++)
cards.add(new Card(words[i], Color.GREEN, Color.WHITE));
cards.add(new Card(words[i++], Color.GREEN, Color.BLACK));
cards.add(new Card(words[i++], Color.BLACK, Color.GREEN));
cards.add(new Card(words[i++], Color.WHITE, Color.BLACK));
cards.add(new Card(words[i++], Color.BLACK, Color.WHITE));
for (;i < 25; i++)
cards.add(new Card(words[i], Color.WHITE, Color.WHITE));
Collections.shuffle(cards);
Grid g = new Grid(uuid.toString(), (Card[])cards.toArray(new Card[cards.size()]));
games.put(uuid, g);
return uuid;
}
public void proposeHint(Grid g, char player, String hint, int wordCount) {
if (!(((player == 'A') && (g.getPhase() == Grid.Phase.HINTING_A)) ||
((player == 'B') && (g.getPhase() == Grid.Phase.HINTING_B)) ||
g.getPhase() == Grid.Phase.HINTING_ANY)) {
throw new RuntimeException("This action is not allowed now");
}
Hint h = new Hint(hint, wordCount);
g.incrementHintCount();
g.newGameEvent(GameEvent.newHintEvent(h, player));
if (player == 'A')
g.setPhase(Grid.Phase.GUESSING_B);
else
g.setPhase(Grid.Phase.GUESSING_A);
g.setCurrentHint(h);
g.setCurrentGuessCount(0);
}
public void pointCard(Grid g, char player, int cardIndex) {
if (!(((player == 'A') && (g.getPhase() == Grid.Phase.GUESSING_A)) ||
((player == 'B') && (g.getPhase() == Grid.Phase.GUESSING_B)))) {
throw new RuntimeException("This action is not allowed now");
}
if (cardIndex<0 || cardIndex>g.getCardCount()) {
throw new IllegalArgumentException("The given card index is invalid");
}
Card c = g.getCards()[cardIndex];
boolean endedGuessing = false;
if (player == 'A') {
if (c.isRevealedA() || (c.isRevealedB() && c.getColorA() == Card.Color.GREEN)) {
throw new RuntimeException("Card has already been revealed");
}
c.setRevealedA(true);
switch(c.getColorB()) {
case Card.Color.GREEN:
break;
case Card.Color.WHITE:
g.incrementWhiteCount();
endedGuessing = true;
break;
case Card.Color.BLACK:
endedGuessing = true;
break;
}
} else {
if (c.isRevealedB() || (c.isRevealedA() && c.getColorB() == Card.Color.GREEN)) {
throw new RuntimeException("Card has already been revealed");
}
c.setRevealedB(true);
switch(c.getColorA()) {
case Card.Color.GREEN:
break;
case Card.Color.WHITE:
g.incrementWhiteCount();
endedGuessing = true;
break;
case Card.Color.BLACK:
endedGuessing = true;
break;
}
}
g.newGameEvent(GameEvent.newGuessEvent(c, player));
g.incrementCurrentGuessCount();
if (!g.getCurrentHint().canStillGuess(g.getCurrentGuessCount()))
endedGuessing = true;
if(endedGuessing) {
g.setCurrentHint(null);
if (player == 'A')
g.setPhase(Grid.Phase.HINTING_A);
else
g.setPhase(Grid.Phase.HINTING_B);
g.newGameEvent(GameEvent.newEndGuessingEvent(false, player));
}
}
public void endGuessing(Grid g, char player) {
if (!(((player == 'A') && (g.getPhase() == Grid.Phase.GUESSING_A)) ||
((player == 'B') && (g.getPhase() == Grid.Phase.GUESSING_B)))) {
throw new RuntimeException("This action is not allowed now");
}
g.setCurrentHint(null);
if (player == 'A')
g.setPhase(Grid.Phase.HINTING_B);
else
g.setPhase(Grid.Phase.HINTING_A);
g.newGameEvent(GameEvent.newEndGuessingEvent(true, player));
}
}
@@ -0,0 +1,29 @@
package com.bernard.nodecames.model;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
@AllArgsConstructor
@Getter
@Setter
public class Card {
String word;
Color colorA;
Color colorB;
// revealedA is wether A knows B's color
boolean revealedA;
boolean revealedB;
public Card(String word, Color colorA, Color colorB) {
this(word, colorA, colorB, false, false);
}
public enum Color {
GREEN,
WHITE,
BLACK;
}
}
@@ -1,20 +1,25 @@
package com.bernard.nodecames.model;
import java.util.List;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class Game {
@Id
@GeneratedValue
private Long id;
private String name;
String name;
}
@@ -0,0 +1,45 @@
package com.bernard.nodecames.model;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
public class GameEvent {
Type type;
Object data;
// 'A' for player A, 'B' for player B, '_' for none
char issuer;
private GameEvent(Type type, Object data, char issuer){
this.data = data;
this.issuer = issuer;
this.type = type;
}
public static GameEvent newHintEvent(Hint h, char issuer) {
return new GameEvent(Type.HINT, h, issuer);
}
public static GameEvent newGuessEvent(Card c, char issuer) {
return new GameEvent(Type.GUESS, c, issuer);
}
public static GameEvent newEndGuessingEvent(boolean manual, char issuer) {
return new GameEvent(Type.END_GUESSING, manual, issuer);
}
public static GameEvent newStartGameEvent() {
return new GameEvent(Type.START_GAME, null, '_');
}
@AllArgsConstructor
public enum Type {
HINT(Hint.class),
GUESS(Card.class),
END_GUESSING(Boolean.class),
START_GAME(Object.class);
Class<?> dataType;
}
}
@@ -0,0 +1,61 @@
package com.bernard.nodecames.model;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class Grid {
String id;
Card[] cards;
List<GameEvent> gameEvents;
Hint currentHint = null;
int currentGuessCount;
int usedHints;
int usedWhites;
Phase phase;
public Grid(String name, Card[] cards) {
this(name, cards, new ArrayList<>(), null, 0, 0, 0, Phase.HINTING_ANY);
}
public void incrementHintCount() {
this.setUsedHints(this.getUsedHints()+1);
}
public void incrementWhiteCount() {
this.setUsedWhites(this.getUsedWhites()+1);
}
public void incrementCurrentGuessCount() {
this.setCurrentGuessCount(this.getCurrentGuessCount()+1);
}
public void newGameEvent(GameEvent event) {
this.gameEvents.add(event);
}
public int getCardCount() {
return this.getCards().length;
}
public enum Phase {
HINTING_ANY,
HINTING_A,
HINTING_B,
GUESSING_A,
GUESSING_B;
}
}
@@ -0,0 +1,17 @@
package com.bernard.nodecames.model;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
@Getter
public class Hint {
String word;
int wordCount;
public boolean canStillGuess(int guessCount) {
return (wordCount == -1) || (guessCount < (wordCount + 1));
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ spring:
jpa:
properties:
javax:
jakarta:
persistence:
schema-generation:
create-source: metadata
+16
View File
@@ -0,0 +1,16 @@
li.card {
border: purple solid 2pt;
list-style-type: none;
padding: 1ex;
margin: 1ex;
font-size: 16px;
}
ul#cards-list {
display: flex;
flex-flow: row wrap;
}
span.card-word {
font-weight: bold;
font-size: 30px;
}
+109
View File
@@ -0,0 +1,109 @@
var socket = null
var player = '_'
var roomId = '_'
var wsHeaders = {}
function getRoomId() {
const urlRegex = /\/room\/([a-f0-9-]{36})\/grid$/
const res = urlRegex.exec(window.location.href)
if (res == null)
console.log("Could not understand room id from url")
return res[1]
}
function makeCards(data) {
var cards = data['cards']
$('#cards-list').empty()
for(var i = 0; i<cards.length; i++) {
html = `
<li class="card" id="card-${i}">
<span class="card-word">${cards[i].word}</span><br/>
Color: <span class="card-color-text" id="card-${i}-color">${cards[i].color}</span><br/>
<button id="card-${i}-button">Toucher</button>
</li>
`
$('#cards-list').append($(html))
$(`#card-${i}-button`).on('click', pointCard)
}
}
function selectPlayer(e) {
console.log(e)
console.log(e.target)
console.log(e.target.id)
console.log(e.target.id == "select-player-a")
player = (e.target.id == "select-player-a") ? 'A' : 'B'
wsHeaders = {
"room": roomId,
"player": player
}
$('#player-selector').hide()
$('#game-panel').show()
$.ajax({
contentType: 'application/json',
type: "GET",
url: "/room/"+roomId+"/game/player-"+player.toLowerCase(),
success: makeCards
})
socket = Stomp.over(new SockJS('/socket'));
socket.connect({}, function (frame) {
socket.subscribe('/topic/submit-hint', onSubmitHint);
socket.subscribe('/topic/point-card', onPointCard);
socket.subscribe('/topic/end-guessing', onEndGuessing);
});
}
function submitHint(e) {
hint = $('#submit-hint-text').val()
hintWordCount = Number($('#submit-hint-wordcount option:selected').val())
socket.send('/app/submit-hint', wsHeaders, JSON.stringify({
'hint': hint,
'hintWordCount': hintWordCount
}))
}
function pointCard(e) {
cardIndex = Number(/card-([0-9]+)-button/.exec(e.target.id)[1])
socket.send('/app/point-card', wsHeaders, JSON.stringify({
'cardIndex': cardIndex,
}))
}
function endGuessing(e) {
socket.send('/app/end-guessing', wsHeaders, "")
}
function onSubmitHint(m) {
data = JSON.parse(m.body)
$("#event-log").append($(`
<li>
${data['hint']} en ${data['hintWordCount']}
</li>
`))
}
function onPointCard(m) {
data = JSON.parse(m.body)
$("#event-log").append($(`
<li>
Pointed card ${data['cardIndex']}
</li>
`))
}
function onEndGuessing(m) {
$("#event-log").append($(`
<li>
Ended guessing
</li>
`))
}
function initialize() {
$('#select-player-a').on('click', selectPlayer)
$('#select-player-b').on('click', selectPlayer)
$('#submit-hint button').on('click', submitHint)
$('#end-guessing-button').on('click', endGuessing)
roomId = getRoomId()
$('#game-panel').hide()
}
File diff suppressed because one or more lines are too long
+49
View File
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="fr" dir="ltr">
<head>
<div th:replace="~{html-head}"/>
<link rel="stylesheet" th:href="@{/css/grid.css}"/>
<script th:src="@{/js/jquery-4.0.0.min.js}" type="text/javascript"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.6.1/sockjs.min.js" integrity="sha512-1QvjE7BtotQjkq8PxLeF6P46gEpBRXuskzIVgjFpekzFVF4yjRgrQvTG1MTOJ3yQgvTteKAcO7DSZI92+u/yZw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js" integrity="sha512-iKDtgDyTHjAitUDdLljGhenhPwrbBfqTKWO1mkhSFH3A7blITC9MhYon6SjnMhp4o0rADGw9yAC6EW4t5a4K3g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script th:src="@{/js/grid.js}" type="text/javascript"></script>
</head>
<body>
<main>
<div id="player-selector">
Which player are you ? <br/>
<button id="select-player-a">Player A</button>
<button id="select-player-b">Player B</button>
</div>
<div id="game-panel">
<ul id="cards-list">
</ul>
<div id="submit-hint">
<input type="text" id="submit-hint-text"/>
<select id="submit-hint-wordcount">
<option value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
<button>Submit Hint</button>
</div>
<button id="end-guessing-button">End Guessing</button>
<br/>
<ol id="event-log">
</ol>
</div>
</main>
<script>
initialize()
</script>
</body>
</html>