summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--TODO.txt2
-rw-r--r--game.html1
-rw-r--r--game.js117
-rw-r--r--server/src/main.rs62
-rw-r--r--style.css13
5 files changed, 146 insertions, 49 deletions
diff --git a/TODO.txt b/TODO.txt
index 7610b29..e69de29 100644
--- a/TODO.txt
+++ b/TODO.txt
@@ -1,2 +0,0 @@
-- single player mode with no server
-- handle server errors on the client side
diff --git a/game.html b/game.html
index 51a34bc..4bf8997 100644
--- a/game.html
+++ b/game.html
@@ -23,5 +23,6 @@
</div>
<audio id="connect-audio" src="connect.mp3" preload></audio>
<audio id="solve-audio" src="solve.mp3" preload></audio>
+ <div id="error" style="opacity:0;"></div>
</body>
</html>
diff --git a/game.js b/game.js
index 0fad2e5..5853e21 100644
--- a/game.js
+++ b/game.js
@@ -2,9 +2,8 @@
window.addEventListener('load', function () {
const ACTION_MOVE = 3;
const ACTION_CONNECT = 4;
- const socket = new WebSocket(location.protocol === "file:" || location.hostname === "localhost" ? "ws://localhost:54472" : "wss://jigsaw.pommicket.com");
+ let socket;
const searchParams = new URL(location.href).searchParams;
- socket.binaryType = "arraybuffer";
let puzzleSeed = Math.floor(Math.random() * 0x7fffffff);
// direct URL to image file
let imageUrl = searchParams.has('image') ? encodeURI(searchParams.get('image')) : undefined;
@@ -19,7 +18,11 @@ window.addEventListener('load', function () {
const imageLinkElement = getById('image-link');
const joinPuzzle = searchParams.get('join');
const joinLink = getById('join-link');
- function setJoinLink(puzzleID) {
+ const errorBox = getById('error');
+ const hostMultiplayerButton = getById('host-multiplayer');
+ let puzzleID = joinPuzzle ? joinPuzzle : null;
+ let rejoining = false;
+ function setJoinLink() {
const url = new URL(location.href);
url.hash = '';
url.search = '?' + new URLSearchParams({
@@ -37,7 +40,7 @@ window.addEventListener('load', function () {
joinLink.innerText = prev;
}, 3000);
});
- if (joinPuzzle) setJoinLink(joinPuzzle);
+ if (joinPuzzle) setJoinLink();
let solved = false;
const connectRadius = 10;
let pieceZIndexCounter = 1;
@@ -132,6 +135,18 @@ window.addEventListener('load', function () {
l[j] = temp;
}
}
+ let errorTimeout;
+ function showError(e) {
+ console.log(`Error: ${e}`);
+ errorBox.classList.add('no-animation');
+ errorBox.style.opacity = 1;
+ errorBox.innerText = `Error: ${e}`;
+ if (errorTimeout) clearTimeout(errorTimeout);
+ errorTimeout = setTimeout(() => {
+ errorBox.classList.remove('no-animation');
+ errorBox.style.opacity = 0;
+ }, 5000);
+ }
const TOP_IN = 0;
const TOP_OUT = 1;
const RIGHT_IN = 2;
@@ -427,8 +442,6 @@ window.addEventListener('load', function () {
if (draggingPiece) {
let dx = (e.clientX - draggingPieceLastPos.x) / playArea.clientWidth;
let dy = (e.clientY - draggingPieceLastPos.y) / playArea.clientHeight;
- let originalDx = dx;
- let originalDy = dy;
for (const piece of draggingPiece.connectedComponent) {
// ensure pieces don't go past left edge
dx = Math.max(dx, 0.0001 - piece.x);
@@ -444,12 +457,8 @@ window.addEventListener('load', function () {
piece.y += dy;
piece.updatePosition();
}
- draggingPieceLastPos.x = e.clientX;
- draggingPieceLastPos.y = e.clientY;
- if (dx !== originalDx || dy !== originalDy) {
- // stop dragging piece if it was dragged past edge
- stopDraggingPiece();
- }
+ draggingPieceLastPos.x += dx * playArea.clientWidth;
+ draggingPieceLastPos.y += dy * playArea.clientHeight;
}
});
function loadImage() {
@@ -516,8 +525,7 @@ window.addEventListener('load', function () {
async function createPuzzle() {
await loadImage();
if (isNaN(roughPieceCount) || roughPieceCount < 10 || roughPieceCount > 1000) {
- // TODO : better error reporting
- console.error('bad piece count');
+ showError('bad piece count');
return;
}
let bestWidth = 1;
@@ -592,6 +600,10 @@ window.addEventListener('load', function () {
}, 100);
}
async function hostPuzzle() {
+ if (!socket) {
+ openSocket();
+ await new Promise((resolve) => socket.addEventListener('open', () => resolve()));
+ }
socket.send(`new ${puzzleWidth} ${puzzleHeight} ${imageUrl};${imageLink} ${puzzleSeed}`);
multiplayer = true;
}
@@ -645,13 +657,26 @@ window.addEventListener('load', function () {
socket.send(new Uint32Array(actions.buffer, 0, i));
}
}
+ function rejoinPuzzle() {
+ if (rejoining) return;
+ multiplayer = false;
+ rejoining = true;
+ if (socket) {
+ socket.closedByClient = true;
+ socket.close();
+ socket = null;
+ }
+ setTimeout(openSocket, 1000);
+ }
let waitingForServerToGiveUsImageUrl = false;
let puzzleCreated = null;
if (!joinPuzzle && imageUrl.startsWith('http')) {
puzzleCreated = createPuzzle();
}
- socket.addEventListener('open', async () => {
- if (joinPuzzle) {
+ async function onSocketOpen() {
+ if (rejoining) {
+ socket.send(`rejoin ${puzzleID}`);
+ } else if (joinPuzzle) {
socket.send(`join ${joinPuzzle}`);
} else {
if (imageUrl.startsWith('http')) {
@@ -664,15 +689,25 @@ window.addEventListener('load', function () {
socket.send('wikimediaPotd');
waitingForServerToGiveUsImageUrl = true;
} else {
- // TODO : better error reporting
- throw new Error("bad image URL");
+ showError("bad image URL");
}
}
- });
- socket.addEventListener('message', async (e) => {
+ }
+ async function onSocketClose(e) {
+ if (e.target.closedByClient)
+ return;
+ if (!multiplayer && !waitingForServerToGiveUsImageUrl) {
+ socket = null;
+ return;
+ }
+ if (rejoining) return;
+ showError('Lost connection to server. Trying to reconnect…');
+ rejoinPuzzle();
+ }
+ async function onSocketMessage(e) {
if (typeof e.data === 'string') {
if (e.data.startsWith('id: ')) {
- let puzzleID = e.data.split(' ')[1];
+ puzzleID = e.data.split(' ')[1];
sendServerUpdate(); // send piece positions
const connectivityUpdate = [0 /* message ID */];
for (const piece of pieces) {
@@ -682,7 +717,7 @@ window.addEventListener('load', function () {
}
socket.send(new Uint32Array(connectivityUpdate));
history.pushState({}, null, `?join=${puzzleID}`);
- setJoinLink(puzzleID);
+ setJoinLink();
} else if (e.data.startsWith('ack')) {
const messageID = parseInt(e.data.split(' ')[1]);
if (messageID === waitingForAck) {
@@ -700,7 +735,11 @@ window.addEventListener('load', function () {
getById('host-multiplayer').style.display = 'inline-block';
} else if (e.data.startsWith('error ')) {
const error = e.data.substring('error '.length);
- console.error(error); // TODO : better error handling
+ showError(error);
+ rejoinPuzzle();
+ } else if (e.data === 'rejoined') {
+ multiplayer = true;
+ rejoining = false;
}
} else {
const opcode = new Uint8Array(e.data, 0, 1)[0];
@@ -710,7 +749,18 @@ window.addEventListener('load', function () {
applyUpdate(e.data);
}
}
- });
+ }
+ async function onSocketError() {
+ showError("Couldn't connect to server. You can still play single player with a custom image URL.");
+ multiplayer = false;
+ rejoining = false;
+ hostMultiplayerButton.style.display = imageUrl ? 'inline' : 'none';
+ if (puzzleID) {
+ hostMultiplayerButton.innerText = '👥 Reconnect to multiplayer game';
+ }
+ joinLink.style.display = 'none';
+ socket = null;
+ }
const prevPlayAreaSize = Object.preventExtensions({width: playArea.clientWidth, height: playArea.clientHeight});
function everyFrame() {
if (prevPlayAreaSize.width !== playArea.clientWidth || prevPlayAreaSize.height !== playArea.clientHeight) {
@@ -736,9 +786,13 @@ window.addEventListener('load', function () {
getById('piece-size-minus').addEventListener('click', () => {
setPieceSize(pieceWidth / 1.2, pieceHeight / 1.2);
});
- getById('host-multiplayer').addEventListener('click', () => {
- getById('host-multiplayer').style.display = 'none';
- hostPuzzle();
+ hostMultiplayerButton.addEventListener('click', () => {
+ hostMultiplayerButton.style.display = 'none';
+ if (puzzleID) {
+ rejoinPuzzle();
+ } else {
+ hostPuzzle();
+ }
});
setInterval(sendServerUpdate, 1000);
setInterval(() => {
@@ -747,4 +801,13 @@ window.addEventListener('load', function () {
}
}, 1000);
requestAnimationFrame(everyFrame);
+ function openSocket() {
+ socket = new WebSocket(location.protocol === "file:" || location.hostname === "localhost" ? "ws://localhost:54472" : "wss://jigsaw.pommicket.com");
+ socket.binaryType = "arraybuffer";
+ socket.addEventListener('open', onSocketOpen);
+ socket.addEventListener('close', onSocketClose);
+ socket.addEventListener('error', onSocketError);
+ socket.addEventListener('message', onSocketMessage);
+ }
+ openSocket();
});
diff --git a/server/src/main.rs b/server/src/main.rs
index 5e62213..c019a2a 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -15,7 +15,7 @@ use tungstenite::protocol::Message;
const PUZZLE_ID_CHARSET: &[u8] = b"23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
const PUZZLE_ID_LEN: usize = 7;
-const MAX_PLAYERS: u16 = 20;
+const MAX_PLAYERS: u32 = 20;
const MAX_PIECES: usize = 1000;
const ACTION_MOVE: u32 = 3;
const ACTION_CONNECT: u32 = 4;
@@ -28,7 +28,7 @@ fn generate_puzzle_id() -> [u8; PUZZLE_ID_LEN] {
#[derive(Debug)]
struct Server {
// keep this in memory, since we want to reset it to 0 when the server restarts
- player_counts: Mutex<HashMap<[u8; PUZZLE_ID_LEN], u16>>,
+ player_counts: Mutex<HashMap<[u8; PUZZLE_ID_LEN], u32>>,
wikimedia_featured: Vec<String>,
wikimedia_potd: RwLock<String>,
database: tokio_postgres::Client,
@@ -62,6 +62,28 @@ impl Server {
.await
.is_ok())
}
+ async fn increase_player_count(&self, id: [u8; PUZZLE_ID_LEN]) -> Result<()> {
+ let mut player_counts = self.player_counts.lock().await;
+ let entry = player_counts.entry(id).or_insert(0);
+ if *entry >= MAX_PLAYERS {
+ Err(Error::TooManyPlayers)
+ } else {
+ *entry += 1;
+ Ok(())
+ }
+ }
+ async fn decrease_player_count(&self, id: [u8; PUZZLE_ID_LEN]) -> Result<()> {
+ let mut player_counts = self.player_counts.lock().await;
+ let std::collections::hash_map::Entry::Occupied(mut o) = player_counts.entry(id) else {
+ return Err(Error::BadPuzzleID);
+ };
+ if *o.get() <= 1 {
+ o.remove();
+ } else {
+ *o.get_mut() -= 1;
+ }
+ Ok(())
+ }
async fn set_puzzle_data(
&self,
id: [u8; PUZZLE_ID_LEN],
@@ -186,7 +208,7 @@ impl std::fmt::Display for Error {
match self {
Error::BadPieceID => write!(f, "bad piece ID"),
Error::BadPuzzleID => write!(f, "bad puzzle ID"),
- Error::BadSyntax(s) => write!(f, "bad syntax: {s}"),
+ Error::BadSyntax(s) => write!(f, "{s}"),
Error::ImageURLTooLong => write!(f, "image URL too long"),
Error::TooManyPieces => write!(f, "too many pieces"),
Error::NotJoined => write!(f, "haven't joined a puzzle"),
@@ -326,16 +348,21 @@ async fn handle_websocket(
.as_bytes()
.try_into()
.map_err(|_| Error::BadSyntax("bad join ID"))?;
- let mut player_counts = server.player_counts.lock().await;
- let entry = player_counts.entry(id).or_default();
- if *entry >= MAX_PLAYERS {
- return Err(Error::TooManyPlayers);
- }
- *entry += 1;
- drop(player_counts); // release lock
+ server.increase_player_count(id).await?;
*puzzle_id = Some(id);
let info = get_puzzle_info(server, &id).await?;
ws.send(Message::Binary(info)).await?;
+ } else if let Some(id) = text.strip_prefix("rejoin ") {
+ let id = id
+ .as_bytes()
+ .try_into()
+ .map_err(|_| Error::BadSyntax("bad join ID"))?;
+ if puzzle_id.is_some() {
+ return Err(Error::BadSyntax("unexpected rejoin"));
+ }
+ server.increase_player_count(id).await?;
+ *puzzle_id = Some(id);
+ ws.send(Message::Text("rejoined".to_string())).await?;
} else if text == "poll" {
let puzzle_id = puzzle_id.ok_or(Error::NotJoined)?;
let PieceInfo {
@@ -434,16 +461,11 @@ async fn handle_connection(server: &Server, conn: &mut tokio::net::TcpStream) ->
ws.send(Message::Text(format!("error {e}"))).await?;
};
if let Some(puzzle_id) = puzzle_id {
- *server
- .player_counts
- .lock()
- .await
- .entry(puzzle_id)
- .or_insert_with(|| {
- eprintln!("negative player count??");
- // prevent underflow
- 1
- }) -= 1;
+ if let Err(e) = server.decrease_player_count(puzzle_id).await {
+ eprintln!(
+ "unexpected error while decreasing player count for puzzle {puzzle_id:?}: {e}"
+ );
+ }
}
status
}
diff --git a/style.css b/style.css
index b074ea1..122cd0a 100644
--- a/style.css
+++ b/style.css
@@ -79,3 +79,16 @@ a, a:visited {
width: 100%;
height: 100%;
}
+
+#error {
+ position: absolute;
+ top: 1em;
+ right: 1em;
+ width: 30%;
+ padding: 0.5em;
+ border: 2px solid #800;
+ border-radius: 5px;
+ background-color: #a00;
+ color: white;
+ transition: opacity 3s;
+}