Multiplayer #1
4 changed files with 151 additions and 94 deletions
|
@ -4,14 +4,16 @@ extern crate rocket;
|
|||
use itertools::Itertools;
|
||||
use rand::prelude::SmallRng;
|
||||
use rand::SeedableRng;
|
||||
use rocket::fs::FileServer;
|
||||
use rocket::futures::{SinkExt, StreamExt};
|
||||
use rocket::tokio::sync::broadcast::Sender;
|
||||
use rocket::tokio::sync::{Mutex, RwLock};
|
||||
use rocket::tokio::time::interval;
|
||||
use rocket::{tokio, State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, LazyLock, Weak};
|
||||
use rocket::fs::FileServer;
|
||||
use std::time::Duration;
|
||||
use tokio::select;
|
||||
use word_grid::api::{APIGame, ApiState, Update};
|
||||
use word_grid::dictionary::{Dictionary, DictionaryImpl};
|
||||
|
@ -24,6 +26,15 @@ use ws::Message;
|
|||
static DICTIONARY: LazyLock<DictionaryImpl> =
|
||||
LazyLock::new(|| DictionaryImpl::create_from_path("../resources/dictionary.csv"));
|
||||
|
||||
fn escape_characters(x: &str) -> String {
|
||||
let x = x
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">");
|
||||
|
||||
x
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq)]
|
||||
struct Player {
|
||||
name: String,
|
||||
|
@ -132,13 +143,12 @@ async fn incoming_message_handler<E: std::fmt::Display>(
|
|||
Some(message) => {
|
||||
match message {
|
||||
Ok(message) => {
|
||||
if let Message::Ping(_) = message {
|
||||
println!("Received ping from player {player:#?}");
|
||||
if matches!(message, Message::Ping(_)) || matches!(message, Message::Pong(_)) {
|
||||
return false;
|
||||
}
|
||||
let message = message.to_text().unwrap();
|
||||
if message.len() == 0 {
|
||||
println!("Websocket closed");
|
||||
println!("Received message of length zero");
|
||||
println!("Player {player:#?} is leaving");
|
||||
let mut room = room.write().await;
|
||||
|
||||
|
@ -372,8 +382,11 @@ async fn room(
|
|||
ws: ws::WebSocket,
|
||||
rooms: &State<Mutex<RoomMap>>,
|
||||
) -> ws::Channel<'static> {
|
||||
let id = escape_characters(id);
|
||||
let player_name = escape_characters(player_name);
|
||||
|
||||
let mut rooms = rooms.lock().await;
|
||||
let room = rooms.get(id);
|
||||
let room = rooms.get(&id);
|
||||
|
||||
let player = Player {
|
||||
name: player_name.to_string(),
|
||||
|
@ -465,23 +478,38 @@ async fn room(
|
|||
|
||||
ws.channel(move |mut stream| {
|
||||
Box::pin(async move {
|
||||
let mut interval = interval(Duration::from_secs(10));
|
||||
loop {
|
||||
let incoming_message = stream.next();
|
||||
let room_message = receiver.recv();
|
||||
let ping_tick = interval.tick();
|
||||
|
||||
select! {
|
||||
// Rust formatter can't reach into this macro, hence we broke out the logic
|
||||
// into sub-functions
|
||||
message = incoming_message => {
|
||||
if incoming_message_handler(message, &sender, &player, &room, &mut stream).await {
|
||||
println!("incoming returned true");
|
||||
return Ok(())
|
||||
}
|
||||
},
|
||||
message = room_message => {
|
||||
if outgoing_message_handler(message, &sender, &player, &room, &mut stream).await {
|
||||
println!("outgoing returned true");
|
||||
return Ok(())
|
||||
}
|
||||
|
||||
},
|
||||
_ = ping_tick => {
|
||||
// send keep-alive
|
||||
let message = Message::Ping(Vec::new());
|
||||
let result = stream.send(message.into()).await;
|
||||
// if it fails, disconnect
|
||||
if let Err(e) = result {
|
||||
println!("Failed to send ping for player {player:#?}; disconnecting");
|
||||
println!("Received error {e}");
|
||||
return Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -262,7 +262,7 @@ export function AISelection(props: {
|
|||
//const processedProportionDictionary = 1.0 - props.proportionDictionary / 100;
|
||||
|
||||
return <>
|
||||
<div className="grid">
|
||||
<div className="ai-grid">
|
||||
<label htmlFor="proportion-dictionary">AI's proportion of dictionary:</label>
|
||||
<input type="number"
|
||||
name="proportion-dictionary"
|
||||
|
|
|
@ -1,14 +1,12 @@
|
|||
import * as React from "react";
|
||||
import {useRef, useState} from "react";
|
||||
import {useState} from "react";
|
||||
import {createRoot} from "react-dom/client";
|
||||
import {AISelection} from "./UI";
|
||||
import {ClientToServerMessage, WSAPI, PartyInfo, ServerToClientMessage} from "./ws_api";
|
||||
import {Game} from "./Game";
|
||||
import {Settings} from "./utils";
|
||||
|
||||
|
||||
|
||||
const LOGBASE = 10000;
|
||||
|
||||
function unprocessAIRandomness(processedAIRandomness: number): string {
|
||||
const x = 100 * (LOGBASE ** processedAIRandomness - 1) / (LOGBASE - 1)
|
||||
return x.toFixed(0)
|
||||
|
@ -29,30 +27,23 @@ export function Menu(): React.JSX.Element {
|
|||
const [proportionDictionary, setProportionDictionary] = useState<number>(7);
|
||||
const [game, setGame] = useState<React.JSX.Element>(null);
|
||||
|
||||
|
||||
let button_or_game = <button onClick={() => {
|
||||
const event: ClientToServerMessage = {
|
||||
type: "StartGame"
|
||||
};
|
||||
socket.send(JSON.stringify(event));
|
||||
}}>Start Game</button>;
|
||||
if(game){
|
||||
button_or_game = game;
|
||||
}
|
||||
const validSettings = roomName.length > 0 && !roomName.includes("/") && playerName.length > 0 && !playerName.includes("?") && !playerName.includes("&");
|
||||
|
||||
// Can change log scale to control shape of curve using following equation:
|
||||
// aiRandomness = log(1 + x*(n-1))/log(n) when x, the user input, ranges between 0 and 1
|
||||
const processedAIRandomness = Math.log(1 + (LOGBASE - 1) * aiRandomness / 100) / Math.log(LOGBASE);
|
||||
const processedProportionDictionary = 1.0 - proportionDictionary / 100;
|
||||
|
||||
if(socket != null && partyInfo == null) {
|
||||
if (game) {
|
||||
return game;
|
||||
} else if (socket != null && partyInfo == null) {
|
||||
return <div><p>Connecting to {roomName}</p></div>
|
||||
} else if (partyInfo != null) {
|
||||
const players = partyInfo.players.map((x) => {
|
||||
return <li key={x.name}>{x.name}</li>;
|
||||
});
|
||||
const ais = partyInfo.ais.map((x, i) => {
|
||||
return <li key={i}>
|
||||
return <li key={i} className="side-grid">
|
||||
<span>Proportion: {unprocessedAIProportion(x.proportion)} / Randomness: {unprocessAIRandomness(x.randomness)}</span>
|
||||
<button onClick={() => {
|
||||
const event = {
|
||||
|
@ -61,13 +52,13 @@ export function Menu(): React.JSX.Element {
|
|||
};
|
||||
socket.send(JSON.stringify(event));
|
||||
}
|
||||
}>X
|
||||
}>Delete
|
||||
</button>
|
||||
</li>
|
||||
});
|
||||
|
||||
|
||||
return <div>
|
||||
return <dialog open className="multiplayer-lobby">
|
||||
<p>Connected to {roomName}</p>
|
||||
Players: <ol>
|
||||
{players}
|
||||
|
@ -75,9 +66,11 @@ export function Menu(): React.JSX.Element {
|
|||
AIs: <ol>
|
||||
{ais}
|
||||
</ol>
|
||||
<details>
|
||||
<details className="multiplayer-ai-details">
|
||||
<summary>Add AI</summary>
|
||||
<AISelection aiRandomness={aiRandomness} setAIRandomness={setAIRandomness} proportionDictionary={proportionDictionary} setProportionDictionary={setProportionDictionary} />
|
||||
<AISelection aiRandomness={aiRandomness} setAIRandomness={setAIRandomness}
|
||||
proportionDictionary={proportionDictionary}
|
||||
setProportionDictionary={setProportionDictionary}/>
|
||||
<button onClick={() => {
|
||||
const event = {
|
||||
type: "AddAI",
|
||||
|
@ -91,34 +84,44 @@ export function Menu(): React.JSX.Element {
|
|||
Add AI
|
||||
</button>
|
||||
</details>
|
||||
<button onClick={(e) => {
|
||||
<div className="side-grid">
|
||||
<button onClick={() => {
|
||||
socket.close();
|
||||
setSocket(null);
|
||||
setPartyInfo(null);
|
||||
}}>Disconnect</button>
|
||||
{button_or_game}
|
||||
}}>Disconnect
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
const event: ClientToServerMessage = {
|
||||
type: "StartGame"
|
||||
};
|
||||
socket.send(JSON.stringify(event));
|
||||
}}>Start Game
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
</dialog>
|
||||
|
||||
} else {
|
||||
return <div>
|
||||
<div>
|
||||
return <dialog open>
|
||||
<div className="multiplayer-inputs-grid">
|
||||
<label>
|
||||
Room Name:
|
||||
<input type="text" value={roomName} onChange={(e) => {
|
||||
setRoomName(e.target.value)
|
||||
}}></input>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
Player Name:
|
||||
<input type="text" value={playerName} onChange={(e) => {
|
||||
setPlayerName(e.target.value)
|
||||
}}></input>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button onClick={() => {
|
||||
<button
|
||||
disabled={!validSettings}
|
||||
onClick={() => {
|
||||
let socket = new WebSocket(`/room/${roomName}?player_name=${playerName}`)
|
||||
socket.addEventListener("message", (event) => {
|
||||
const input: ServerToClientMessage = JSON.parse(event.data);
|
||||
|
@ -132,6 +135,7 @@ export function Menu(): React.JSX.Element {
|
|||
}} end_game_fn={function (): void {
|
||||
socket.close();
|
||||
setSocket(null);
|
||||
setPartyInfo(null);
|
||||
setGame(null);
|
||||
}}/>);
|
||||
}
|
||||
|
@ -140,6 +144,7 @@ export function Menu(): React.JSX.Element {
|
|||
socket.addEventListener("close", (event) => {
|
||||
console.log({event});
|
||||
setSocket(null);
|
||||
setPartyInfo(null);
|
||||
setGame(null);
|
||||
if (event.reason != null && event.reason.length > 0) {
|
||||
alert(`Disconnected with reason "${event.reason} & code ${event.code}"`);
|
||||
|
@ -148,7 +153,8 @@ export function Menu(): React.JSX.Element {
|
|||
setSocket(socket);
|
||||
}}>Connect
|
||||
</button>
|
||||
</div>;
|
||||
</div>
|
||||
</dialog>;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -218,22 +218,45 @@
|
|||
}
|
||||
}
|
||||
|
||||
.multiplayer-inputs-grid {
|
||||
display: grid;
|
||||
//grid-template-columns: 3fr 2fr;
|
||||
//grid-column-gap: 1em;
|
||||
grid-row-gap: 0.5em;
|
||||
|
||||
dialog {
|
||||
border-radius: 10px;
|
||||
z-index: 1;
|
||||
label {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 2fr;
|
||||
}
|
||||
}
|
||||
|
||||
.new-game {
|
||||
width: 50em;
|
||||
|
||||
.grid {
|
||||
.ai-grid{
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 2fr;
|
||||
grid-column-gap: 1em;
|
||||
grid-row-gap: 0.5em;
|
||||
|
||||
}
|
||||
|
||||
.side-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-column-gap: 1em;
|
||||
}
|
||||
|
||||
.multiplayer-ai-details {
|
||||
button {
|
||||
margin: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
dialog {
|
||||
border-radius: 10px;
|
||||
z-index: 1;
|
||||
width: 50em;
|
||||
|
||||
.new-game {
|
||||
width: 50em;
|
||||
|
||||
.selection-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
|
Loading…
Reference in a new issue