working multiplayer for desktop

This commit is contained in:
Daniel Bulant 2023-01-20 19:38:40 +01:00
parent 9dc60925f0
commit c7df814200
9 changed files with 318 additions and 48 deletions

View file

@ -1,5 +1,7 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { quadOut } from "svelte/easing";
import { fly } from "svelte/transition";
import Move from "./move.svelte";
export var self: 1 | 2 = 1;
@ -72,6 +74,24 @@
updateContainerStates();
}
function addPlayerMove(player: 1 | 2, i: number, j: number) {
if(moves.find(move => move.i == i && move.j == j)) {
console.error("DESYNC! Repeated move", player, i, j);
return;
}
if(currentContainer !== i) {
console.error("DESYNC! Invalid move (container not active)!", player, i, j, "current container:", i);
return;
}
moves.push({ p: player, i, j });
moves = moves;
updateContainerStates();
}
export { addPlayerMove };
function updateContainerStates() {
for(var i in containerStates) {
if(containerStates[i]) continue;
@ -138,9 +158,15 @@
moves = [];
containerStates = new Array(9).fill(0);
}
function check(e: MouseEvent) {
if(twoPlayer) return;
var confirmed = confirm("Are you sure you want to quit?");
if(!confirmed) return e.preventDefault() || false;
}
</script>
<a href="/" class="arrow-back fixed top-0 left-0 w-4 h-4 m-4 p-2 transform transition-transform hover:-translate-x-1">
<a href="/" on:click={check} class="arrow-back fixed top-0 left-0 w-4 h-4 m-4 p-2 transform transition-transform hover:-translate-x-1">
<svg width="16" height="16">
<line y1="50%" x1="0" y2="50%" x2="100%" stroke="currentColor" stroke-width="2" />
<line y1="50%" x1="0" y2="100%" x2="50%" stroke="currentColor" stroke-width="2" />
@ -148,18 +174,20 @@
</svg>
</a>
<div on:click={reset} class="reload fixed top-0 left-10 w-4 h-4 m-4 p-2 transform transition-transform rotate-180 hover:rotate-360 active:rotate-540">
<svg fill="currentColor" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 489.645 489.645" xml:space="preserve" class="w-full h-full">
<g>
<path d="M460.656,132.911c-58.7-122.1-212.2-166.5-331.8-104.1c-9.4,5.2-13.5,16.6-8.3,27c5.2,9.4,16.6,13.5,27,8.3
c99.9-52,227.4-14.9,276.7,86.3c65.4,134.3-19,236.7-87.4,274.6c-93.1,51.7-211.2,17.4-267.6-70.7l69.3,14.5
c10.4,2.1,21.8-4.2,23.9-15.6c2.1-10.4-4.2-21.8-15.6-23.9l-122.8-25c-20.6-2-25,16.6-23.9,22.9l15.6,123.8
c1,10.4,9.4,17.7,19.8,17.7c12.8,0,20.8-12.5,19.8-23.9l-6-50.5c57.4,70.8,170.3,131.2,307.4,68.2
C414.856,432.511,548.256,314.811,460.656,132.911z"/>
</g>
</svg>
</div>
{#if !twoPlayer}
<div on:click={reset} class="reload fixed top-0 left-10 w-4 h-4 m-4 p-2 transform transition-transform rotate-180 hover:rotate-360 active:rotate-540">
<svg fill="currentColor" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 489.645 489.645" xml:space="preserve" class="w-full h-full">
<g>
<path d="M460.656,132.911c-58.7-122.1-212.2-166.5-331.8-104.1c-9.4,5.2-13.5,16.6-8.3,27c5.2,9.4,16.6,13.5,27,8.3
c99.9-52,227.4-14.9,276.7,86.3c65.4,134.3-19,236.7-87.4,274.6c-93.1,51.7-211.2,17.4-267.6-70.7l69.3,14.5
c10.4,2.1,21.8-4.2,23.9-15.6c2.1-10.4-4.2-21.8-15.6-23.9l-122.8-25c-20.6-2-25,16.6-23.9,22.9l15.6,123.8
c1,10.4,9.4,17.7,19.8,17.7c12.8,0,20.8-12.5,19.8-23.9l-6-50.5c57.4,70.8,170.3,131.2,307.4,68.2
C414.856,432.511,548.256,314.811,460.656,132.911z"/>
</g>
</svg>
</div>
{/if}
<main class:disabled={overallState} class="flex flex-wrap min-h-100vh min-w-full items-center">
<div class="board relative p-8">
@ -242,28 +270,45 @@
</div>
{/if}
<div class="absolute top-200 left-0 right-0 text-center">
{#if currentPlayer == 1}
<svg width="16" height="16" class="text-red-500">
<line x1="0" y1="0" x2="100%" y2="100%" stroke="currentColor" stroke-width="2" />
<line x1="100%" y1="0" x2="0" y2="100%" stroke="currentColor" stroke-width="2" />
</svg>
{:else}
<svg width="16" height="16" class="text-blue-500">
<circle cx="50%" cy="50%" r="45%" stroke="currentColor" stroke-width="2" fill="none" />
</svg>
{/if}
is on turn.
{#if twoPlayer && self == currentPlayer}
<b>It is <span class:text-red-500={currentPlayer == 1} class:text-blue-500={currentPlayer == 2}>YOUR</span> {selfName ? "(" + selfName + ")" : ""} turn.</b>
{:else if twoPlayer && self != currentPlayer}
Waiting for {opponentName || "opponent"}...
{/if}
</div>
{#key currentPlayer}
<div class="absolute top-200 left-0 right-0 text-center" in:fly={{ delay: 300, duration: 300, easing: quadOut, opacity: 0, y: 30 }} out:fly={{ delay: 0, duration: 300, easing: quadOut, opacity: 0, y: -30 }}>
{#if currentPlayer == 1}
<svg width="16" height="16" class="text-red-500">
<line x1="0" y1="0" x2="100%" y2="100%" stroke="currentColor" stroke-width="2" />
<line x1="100%" y1="0" x2="0" y2="100%" stroke="currentColor" stroke-width="2" />
</svg>
{:else}
<svg width="16" height="16" class="text-blue-500">
<circle cx="50%" cy="50%" r="45%" stroke="currentColor" stroke-width="2" fill="none" />
</svg>
{/if}
is on turn.
{#if twoPlayer && self == currentPlayer}
<b>It is <span class:text-red-500={currentPlayer == 1} class:text-blue-500={currentPlayer == 2}>YOUR</span> {selfName ? "(" + selfName + ")" : ""} turn.</b>
{:else if twoPlayer && self != currentPlayer}
Waiting for <b class:text-red-500={currentPlayer == 1} class:text-blue-500={currentPlayer == 2}>{opponentName || "opponent"}</b>...
{/if}
</div>
{/key}
</div>
<div class="info min-w-38 px-4 h-full overflow-y-auto <md:w-full">
<div class="moves">
{#if twoPlayer}
<div class="move text-red-500">
<svg width="16" height="16">
<line x1="0" y1="0" x2="100%" y2="100%" stroke="currentColor" stroke-width="2" />
<line x1="100%" y1="0" x2="0" y2="100%" stroke="currentColor" stroke-width="2" />
</svg>
{self == 1 ? (selfName || "you") : (opponentName || "opponent")}
</div>
<div class="move text-blue-500">
<svg width="16" height="16">
<circle cx="50%" cy="50%" r="45%" stroke="currentColor" stroke-width="2" fill="none" />
</svg>
{self == 2 ? (selfName || "you") : (opponentName || "opponent")}
</div>
{/if}
{#each moves as move}
<Move player={move.p} board={move.i} piece={move.j} on:mouseover={() => hoveredPiece = { i: move.i, j: move.j }} on:mouseout={() => { if(hoveredPiece?.j == move.j && hoveredPiece.i == move.i) hoveredPiece = null }} />
{/each}
@ -278,6 +323,9 @@
.info .moves {
columns: 9.5rem auto;
}
.move {
@apply p-1 flex gap-2 p-1 items-center leading-none;
}
.winner {
@apply absolute inset-4 pointer-events-none;
}

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { onMount } from "svelte";
import { quadOut } from "svelte/easing";
import { fade, fly } from "svelte/transition";
import { connection, list } from "./websocket";
@ -6,16 +7,54 @@
const duration = 400;
let createRoomOpen = false;
let roomName = "";
let roomName = $connection?.name ? `${$connection.name}'s room` : "";
function create() {
console.log("create room", roomName);
$connection!.createGame(roomName);
}
function join(roomName: string) {
console.log("join room", roomName);
$connection!.join(roomName);
}
function disconnect() {
$connection?.leave();
}
onMount(() => {
let i = setInterval(() => {
$connection?.refreshList();
}, 10000); // should usually be synchronized just fine, this is to prevent desynchronization (and also works as a kind of ping)
return () => clearInterval(i);
})
</script>
<a href="/multiplayer" on:click={disconnect} class="arrow-back fixed top-0 left-0 w-4 h-4 m-4 mt-8 p-2 transform transition-transform hover:-translate-x-1">
<svg width="16" height="16">
<line y1="50%" x1="0" y2="50%" x2="100%" stroke="currentColor" stroke-width="2" />
<line y1="50%" x1="0" y2="100%" x2="50%" stroke="currentColor" stroke-width="2" />
<line y1="50%" x1="0" y2="0" x2="50%" stroke="currentColor" stroke-width="2" />
</svg>
</a>
<div on:click={() => $connection?.refreshList()} on:keydown={() => $connection?.refreshList()} class="reload fixed top-0 left-10 w-4 h-4 m-4 p-2 transform transition-transform rotate-180 hover:rotate-360 active:rotate-540">
<svg fill="currentColor" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 489.645 489.645" xml:space="preserve" class="w-full h-full">
<g>
<path d="M460.656,132.911c-58.7-122.1-212.2-166.5-331.8-104.1c-9.4,5.2-13.5,16.6-8.3,27c5.2,9.4,16.6,13.5,27,8.3
c99.9-52,227.4-14.9,276.7,86.3c65.4,134.3-19,236.7-87.4,274.6c-93.1,51.7-211.2,17.4-267.6-70.7l69.3,14.5
c10.4,2.1,21.8-4.2,23.9-15.6c2.1-10.4-4.2-21.8-15.6-23.9l-122.8-25c-20.6-2-25,16.6-23.9,22.9l15.6,123.8
c1,10.4,9.4,17.7,19.8,17.7c12.8,0,20.8-12.5,19.8-23.9l-6-50.5c57.4,70.8,170.3,131.2,307.4,68.2
C414.856,432.511,548.256,314.811,460.656,132.911z"/>
</g>
</svg>
</div>
<main class="flex flex-col w-100vw h-100vh">
<div class="p-4 flex justify-between border-b border-b-black border-b-solid">
<div class="p-4 pl-16 flex justify-between border-b border-b-black border-b-solid">
<h1 in:fly={{ delay: 0, duration, opacity: 0, y: 100, easing: quadOut }}>Rooms</h1>
<div class="right">
<button in:fly={{ delay: duration * 0.5, duration, opacity: 0, y: 100, easing: quadOut }} disabled>Quick match</button>
@ -26,7 +65,7 @@
{#if $list}
<ul>
{#each $list as room}
<li>{room.name}</li>
<li on:click={() => join(room.name)}>{room.name}</li>
{/each}
</ul>
{:else}
@ -51,6 +90,15 @@
</dialog>
<style>
a {
@apply text-black;
}
ul {
@apply list-none;
}
li {
@apply cursor-pointer border-b border-b-solid border-b-gray-300 p-4;
}
button {
@apply w-64 h-10 px-2 border border-gray-300 bg-white mt-4 my-0;
}
@ -72,4 +120,7 @@
dialog[open] {
@apply block fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 p-0 m-0;
}
* {
@apply box-border select-none;
}
</style>

View file

@ -18,6 +18,14 @@
}
</script>
<a href="/" class="arrow-back fixed top-0 left-0 w-4 h-4 m-4 p-2 transform transition-transform hover:-translate-x-1">
<svg width="16" height="16">
<line y1="50%" x1="0" y2="50%" x2="100%" stroke="currentColor" stroke-width="2" />
<line y1="50%" x1="0" y2="100%" x2="50%" stroke="currentColor" stroke-width="2" />
<line y1="50%" x1="0" y2="0" x2="50%" stroke="currentColor" stroke-width="2" />
</svg>
</a>
{#if shown}
<main>
<div class="top">
@ -33,6 +41,9 @@
{/if}
<style>
a {
@apply text-black;
}
* {
@apply box-border;
}

View file

@ -0,0 +1,35 @@
<script>
import { quadOut } from "svelte/easing";
import { fly } from "svelte/transition";
import { room } from "./websocket";
const duration = 400;
</script>
<a href="/multiplayer" class="arrow-back fixed top-0 left-0 w-4 h-4 m-4 p-2 transform transition-transform hover:-translate-x-1">
<svg width="16" height="16">
<line y1="50%" x1="0" y2="50%" x2="100%" stroke="currentColor" stroke-width="2" />
<line y1="50%" x1="0" y2="100%" x2="50%" stroke="currentColor" stroke-width="2" />
<line y1="50%" x1="0" y2="0" x2="50%" stroke="currentColor" stroke-width="2" />
</svg>
</a>
<main>
<h1 in:fly={{ delay: 0, duration, opacity: 0, y: 100, easing: quadOut }}>Waiting for an opponent...</h1>
<p in:fly={{ delay: duration * 0.5, duration, opacity: 0, y: 100, easing: quadOut }}>Currently in room <code>{$room?.name}</code>.</p>
</main>
<style>
main {
@apply w-100vw h-100vh flex flex-col items-center justify-center;
}
p {
@apply text-gray-400;
}
a {
@apply text-black;
}
code {
@apply text-black;
}
</style>

View file

@ -1,5 +1,11 @@
import { writable, type Writable } from "svelte/store";
export class MoveEvent extends Event {
constructor(public i: number, public j: number) {
super("move");
}
}
export class WebsocketConnection extends EventTarget {
ws: WebSocket;
roomName: string | null = null;
@ -30,7 +36,7 @@ export class WebsocketConnection extends EventTarget {
list.set(null);
});
this.ws.addEventListener("error", (e) => {
console.error("WS error");
console.error("WS error", e);
this.addError("Connection error");
connection.set(null);
room.set(null);
@ -43,6 +49,7 @@ export class WebsocketConnection extends EventTarget {
case "join": {
messages.update(t => { t.push({ type: "system", content: `${msg.client} joined`});return t})
this.players.add(msg.client);
room.update(t => { t!.count = this.players.size; return t });
break;
}
case "joined": {
@ -55,7 +62,8 @@ export class WebsocketConnection extends EventTarget {
this.roomHost = msg.host;
room.set({
name: msg.name,
host: msg.host
host: msg.host,
count: clients.length
});
break;
}
@ -64,7 +72,8 @@ export class WebsocketConnection extends EventTarget {
this.roomHost = this.name;
room.set({
name: msg.name,
host: this.name
host: this.name,
count: 1
});
this.addMessage({ type: "system", content: `${msg.name} created the room`});
break;
@ -75,6 +84,7 @@ export class WebsocketConnection extends EventTarget {
gameData.set(null);
}
this.players.delete(msg.client);
room.update(t => { t!.count = this.players.size; return t });
break;
}
case "host": {
@ -96,6 +106,30 @@ export class WebsocketConnection extends EventTarget {
listLoading.set(false);
break;
}
case "room_created": {
list.update(t => { t?.push({ name: msg.name}); return t });
break;
}
case "room_deleted": {
list.update(t => {
if(!t) return t;
var i = t.findIndex(t => t.name == msg.name);
if(i == -1) return t;
t.splice(i, 1);
return t;
});
break;
}
case "broadcast": {
if(msg.client == this.name) break;
switch (msg.d.t) {
case "move": {
console.log("Dispatching move event", msg.d.i, msg.d.j)
this.dispatchEvent(new MoveEvent(msg.d.i, msg.d.j));
}
}
break;
}
case "error": {
console.error(msg.e);
this.addError(msg.e);
@ -143,6 +177,10 @@ export class WebsocketConnection extends EventTarget {
send(data: any) {
this.ws.send(data);
}
leave() {
this.ws.close();
}
}
interface ErrorMessage {
@ -162,6 +200,6 @@ interface SystemMessage {
export const connection: Writable<WebsocketConnection | null> = writable(null);
export const list: Writable<{ name: string }[] | null> = writable(null);
export const listLoading = writable(true);
export const room: Writable<{ name: string, host: string } | null> = writable(null);
export const room: Writable<{ name: string, host: string, count: number } | null> = writable(null);
export const messages: Writable<(UserMessage | ErrorMessage | SystemMessage)[]> = writable([]);
export const gameData: Writable<{ log: { p: string, i: number, j: number }[] }|null> = writable(null);

View file

@ -1,3 +1,6 @@
<svelte:head>
<title>Slightly complicated tictactoe</title>
</svelte:head>
<main class="flex items-center justify-center flex-col">
<div class="chooser">
@ -50,9 +53,18 @@
.chooser > a {
@apply text-black no-underline cursor-pointer w-full p-8 border rounded-lg border-gray-400 border-solid;
}
.chooser > a:hover {
@apply bg-black/10;
}
.rules {
@apply cursor-not-allowed text-gray-500 flex justify-center items-center w-full my-8 p-4 border rounded-lg border-gray-400 border-solid;
}
.rules:hover {
@apply bg-red-500/3;
}
.rules:active {
@apply bg-red-500/10;
}
.icon {
@apply h-20 w-20 mr-4;
}

View file

@ -3,4 +3,8 @@
</script>
<svelte:head>
<title>Slightly complicated tictactoe</title>
</svelte:head>
<Game />

View file

@ -1,14 +1,61 @@
<script>
<script lang="ts">
import Game from "$lib/game.svelte";
import List from "$lib/list.svelte";
import NameChoose from "$lib/nameChoose.svelte";
import { connection, room } from "$lib/websocket";
import Wait from "$lib/wait.svelte";
import { connection, MoveEvent, room } from "$lib/websocket";
import { onMount } from "svelte";
var addPlayerMove: (p: number, i: number, j: number) => void;
function addSelfMove(e: any) {
$connection?.broadcast({
t: "move",
i: e.detail.i,
j: e.detail.j
});
}
onMount(() => {
return () => {
$connection?.leave();
}
});
let addedEventListener = false;
$: {
if($connection && !addedEventListener) {
function addOtherMove(event: MoveEvent) {
console.log("Received other move", event.i, event.j);
// note the changed order between this and self - we need to set the opponent here!
addPlayerMove($room?.host === $connection?.name ? 2 : 1, event.i, event.j);
}
console.log("Adding event listener");
$connection.addEventListener("move", addOtherMove as any);
addedEventListener = true;
} else if(!$connection && addedEventListener) {
addedEventListener = false;
}
}
</script>
<svelte:head>
<title>Slightly complicated tictactoe</title>
</svelte:head>
{#if !$connection}
<NameChoose />
{:else if !$room}
<List />
{:else if $room.count < 2}
<Wait />
{:else}
<Game />
<Game
twoPlayer
self={$room.host === $connection.name ? 1 : 2}
opponentName={$connection.name}
selfName={[...$connection.players.values()].find(t => t !== $connection?.name)}
on:move={addSelfMove}
bind:addPlayerMove
/>
{/if}

View file

@ -23,6 +23,18 @@ const rooms = new Map();
*/
const clients = new Map();
function deleteRoom(room) {
rooms.delete(room);
for(let [, client] of clients) {
if(!client.room) {
client.connection.send(
JSON.stringify({ t: "room_deleted", name: room })
)
}
}
}
require("uWebSockets.js")
.App({})
.ws("/", {
@ -39,11 +51,15 @@ require("uWebSockets.js")
name.length < 2 ||
name.length > 64 ||
!name.trim()
)
) {
console.log("Invalid name");
return res.end("invalid_name");
}
name = name.trim();
if ([...clients.values()].find((client) => client.name === name))
if ([...clients.values()].find((client) => client.name === name)) {
console.log("Duplicate name");
return res.end("name_used");
}
/* This immediately calls open handler, you must not use res after this call */
res.upgrade(
{
@ -120,8 +136,15 @@ require("uWebSockets.js")
};
rooms.set(room.name, room);
client.room = room;
for(let [, cclient] of clients) {
if(!cclient.room) {
cclient.connection.send(
JSON.stringify({ t: "room_created", name })
);
}
}
return ws.send(
JSON.stringify({ t: "create", name: name })
JSON.stringify({ t: "create", name })
);
}
case "leave": {
@ -139,7 +162,7 @@ require("uWebSockets.js")
);
room.clients.splice(room.clients.indexOf(client), 1);
if (room.clients.length === 0) {
rooms.delete(room.name);
deleteRoom(room.name);
} else if (room.host == ws) {
room.host = room.clients[0];
room.clients.forEach((client) =>
@ -233,12 +256,13 @@ require("uWebSockets.js")
JSON.stringify({ t: "error", e: "not_in_room" })
);
let clientName = client.name
room.clients.forEach((client) => {
client.connection.send(
JSON.stringify({
t: "broadcast",
client: client.name,
data: data.d,
client: clientName,
d: data.d,
})
);
});
@ -274,7 +298,7 @@ require("uWebSockets.js")
if (room) {
room.clients.splice(room.clients.indexOf(client), 1);
if (room.clients.length === 0) {
rooms.delete(room.name);
deleteRoom(room.name);
} else if (room.host == ws) {
room.host = room.clients[0];
room.clients.forEach((client) =>