Proper multiplayer system

main
Shad Amethyst 12 months ago
parent 02f70808b5
commit bc609b6214

@ -3,7 +3,12 @@ import { AcrossTheHex } from "./src/game.js";
const server = Server({
games: [AcrossTheHex],
origins: [Origins.LOCALHOST]
origins: [Origins.LOCALHOST],
});
server.run(3100);
server.run({
port: 3100,
lobbyConfig: {
apiPort: 3200,
}
});

@ -0,0 +1,54 @@
.modal {
width: 100vw;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
}
.card {
padding: 0.5em 2em;
min-height: 4em;
width: fit-content;
border: 1px solid gray;
border-radius: 4px;
box-shadow: 0px 1em 1em -1em black;
display: flex;
flex-direction: column;
align-items: center;
}
.card h2 {
margin-top: 0;
margin-bottom: 2rem;
}
.inputs {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1em;
}
.inputs input {
background: transparent;
color: inherit;
border: 1px solid gray;
}
.setup-data {
text-align: center;
margin-top: 1em;
}
.setup-data h3 {
font-size: inherit;
font-weight: bold;
margin-block: 0;
}
.setup-data ul {
list-style: none;
padding-left: 0;
}

@ -0,0 +1,65 @@
import { LobbyClient } from "boardgame.io/client";
import { LOBBY_SERVER } from "../consts.js";
import { Show, createResource, createSignal } from "solid-js";
import classes from "./Connect.module.css";
import Status from "./Status.tsx";
import { DEFAULT_SETUP_DATA, type SetupData } from "../game.ts";
export type ConnectProps = {
gameName: string,
matchID: string,
setCredentials: (credentials: string) => void,
setPlayerID: (playerID: string) => void,
setSpectating: (spectating: boolean) => void,
};
export default function Connect(props: ConnectProps) {
const client = new LobbyClient({
server: LOBBY_SERVER
});
const [name, setName] = createSignal<string>("");
const [match] = createResource(() => client.getMatch(props.gameName, props.matchID));
return <div class={classes.modal}>
<div class={classes.card}>
<h2>Join game</h2>
<div class={classes.inputs}>
<input type="text" value={name()} onChange={(event) => setName(event.currentTarget.value)} placeholder="Your name" />
<button
onClick={() => {
client.joinMatch(
props.gameName,
props.matchID,
{
playerName: name() || `Anonymous`,
}
).then((res) => {
props.setPlayerID(res.playerID);
props.setCredentials(res.playerCredentials);
}).catch(console.error);
}}
disabled={match()?.players.every((player) => !!player.name) ?? false}
>
Join game
</button>
<button onClick={() => props.setSpectating(true)}>Spectate</button>
</div>
<Status gameName={props.gameName} matchID={props.matchID} />
<Show when={(match()?.setupData as SetupData)}>
{(setupData) => {
return (<div class={classes["setup-data"]}>
<h3>Game settings:</h3>
<ul>
<li>Size: {setupData().size ?? DEFAULT_SETUP_DATA.size}</li>
<li>Initial resources: {setupData().initialResources ?? DEFAULT_SETUP_DATA.initialResources}</li>
</ul>
</div>);
}}
</Show>
</div>
</div>;
}

@ -1,4 +1,5 @@
---
import { BASE_URL } from "../consts.ts";
export type Props = {
title?: string,
@ -21,9 +22,26 @@ export type Props = {
background: #202020;
color: white;
}
h1 {
font-weight: normal;
font-size: 16pt;
font-family: monospace;
text-align: center;
}
h1 a {
color: inherit;
text-decoration: none;
}
h1 a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<h1><a href={BASE_URL}>Across the Hex</a></h1>
<slot />
</body>
</html>

@ -0,0 +1,21 @@
.game {
display: grid;
grid-template:
"board players" auto
"toolbar toolbar" auto
/ auto 20ch;
margin-left: 1em;
margin-right: 1em;
}
.board {
grid-area: board;
}
.players {
grid-area: players;
}
.toolbar {
grid-area: toolbar;
}

@ -8,12 +8,16 @@ import Toolbar, { Tool } from "./Toolbar.jsx";
import { GHOST_COLORS, PLAYER_COLORS, SOCKETIO_SERVER } from "../consts.js";
import { TileImages, loadTileImages } from "./tiles.js";
import clone from "../clone.js";
import classes from "./Game.module.css";
import Status from "./Status.tsx";
export type GameProps = ({
playerID: string,
solo: true,
matchID?: undefined,
} | {
playerID: string,
credentials: string,
solo?: false,
matchID: string,
} | {
@ -31,8 +35,9 @@ export default function Game(props: GameProps) {
server: SOCKETIO_SERVER
}),
debug: props.debug ?? false,
...("matchID" in props ? { matchID: props.matchID } : {}),
...(props.playerID ? { playerID: props.playerID } : {})
...(props.matchID ? { matchID: props.matchID } : {}),
...(props.playerID ? { playerID: props.playerID } : {}),
...("credentials" in props ? { credentials: props.credentials } : {}),
});
const [state, setState] = createStore<game.State>(clone(client.getState()?.G) ?? {
resources: {},
@ -258,23 +263,28 @@ export default function Game(props: GameProps) {
});
});
return <div>
return <div class={classes.game}>
<Show when={props.solo === true ? soloTurn() === props.playerID : true}>
<PixelPerfectCanvas
onAttach={setCanvas}
onResize={(_, width, height) => {
setWidth(width);
setHeight(height);
draw();
}}
style={{
width: "600px",
height: "600px",
}}
/>
<div class={classes.board}>
<PixelPerfectCanvas
onAttach={setCanvas}
onResize={(_, width, height) => {
setWidth(width);
setHeight(height);
draw();
}}
style={{
width: "600px",
height: "600px",
}}
/>
</div>
<Show when={props.matchID}>
{(matchID) => <div class={classes.players}><Status gameName={game.AcrossTheHex.name!} matchID={matchID()} /></div>}
</Show>
<Show when={props.playerID}>
{(playerID) => {
return <Toolbar
return <div class={classes.toolbar}><Toolbar
playerID={playerID()}
selectedTool={selectedTool}
setSelectedTool={setSelectedTool}
@ -282,7 +292,7 @@ export default function Game(props: GameProps) {
resourceGain={() => "cells" in state ? game.getResourceGain(state.cells, playerID()) : 0}
setReady={() => moves().setReady?.()}
ready={() => stage() === "ready"}
/>;
/></div>;
}}
</Show>
</Show>

@ -0,0 +1,31 @@
.matchmaker {
flex: 1;
display: flex;
flex-direction: column;
gap: 1em;
justify-content: center;
align-items: center;
}
.setting > input[type="number"] {
appearance: textfield;
margin-left: 1em;
width: 4em;
background-color: transparent;
color: white;
border: 1px solid gray;
padding: 2px;
border-radius: 2px;
transition: 0.2s background-color;
}
.setting > input[type="number"]:hover {
background-color: black;
}
.buttons {
display: flex;
flex-direction: row;
gap: 1em;
}

@ -0,0 +1,41 @@
import { LobbyClient } from "boardgame.io/client";
import { BASE_URL, DOMAIN, LOBBY_SERVER } from "../consts.ts";
import { AcrossTheHex, SetupData } from "../game.ts";
import classes from "./MatchMaker.module.css";
import { createSignal } from "solid-js";
export default function MatchMaker() {
const client = new LobbyClient({
server: LOBBY_SERVER
});
const [gridSize, setGridSize] = createSignal(4);
const [initialResources, setInitialResources] = createSignal(2);
return <div class={classes.matchmaker}>
<div class={classes.setting}>
Grid size:
<input type="number" value={gridSize()} min={1} max={20} onChange={(event) => setGridSize(+event.currentTarget.value)} />
</div>
<div class={classes.setting}>
Initial resources:
<input type="number" value={initialResources()} min={1} max={100} onChange={(event) => setInitialResources(+event.currentTarget.value)} />
</div>
<div class={classes.buttons}>
<button onClick={() => {
const setupData: SetupData = {
size: gridSize(),
initialResources: initialResources()
};
client.createMatch(AcrossTheHex.name!, {
numPlayers: 2,
setupData,
}).then((match) => {
window.location.href = `${DOMAIN}${BASE_URL}play?match=${match.matchID}`;
});
}}>Create multiplayer match</button>
<button disabled>Play locally</button>
</div>
</div>;
}

@ -1,26 +0,0 @@
---
import Game from "./Game.tsx";
export type Props = {
playerID: string | undefined,
matchID: string,
debug: boolean,
};
---
{
Astro.props.playerID
? <Game
playerID={Astro.props.playerID}
matchID={Astro.props.matchID}
debug={Astro.props.debug}
client:only
/>
: <Game
matchID={Astro.props.matchID}
debug={Astro.props.debug}
client:only
/>
}
<!-- <GameStatus playerID={Astro.props.playerID} matchID={Astro.props.matchID} /> -->

@ -0,0 +1,48 @@
import { Match, Switch, createSignal } from "solid-js";
import Game from "./Game.js";
import Connect from "./Connect.tsx";
import { AcrossTheHex } from "../game.ts";
export type MultiplayerProps = {
matchID: string,
debug: boolean,
};
export default function Multiplayer(props: MultiplayerProps) {
const [credentials, setCredentials] = createSignal<string>();
const [playerID, setPlayerID] = createSignal<string>();
const [spectating, setSpectating] = createSignal(false);
return (
<Switch>
<Match when={playerID() && credentials()}>
{(_) => {
return <Game
playerID={playerID()!}
credentials={credentials()!}
matchID={props.matchID}
debug={props.debug}
/>;
}}
</Match>
<Match when={!spectating()}>
<Connect
gameName={AcrossTheHex.name!}
matchID={props.matchID}
setCredentials={setCredentials}
setPlayerID={setPlayerID}
setSpectating={setSpectating}
/>
</Match>
<Match when={spectating()}>
{(_) => {
return <Game
matchID={props.matchID}
debug={props.debug}
/>;
}}
</Match>
</Switch>
);
}

@ -0,0 +1,28 @@
.users {
list-style: none;
padding-left: 0;
border: 1px solid black;
width: 20ch;
}
.users > li:not(:first-child) {
border-top: 1px solid black;
}
.player-icon {
display: inline-block;
width: 0.75em;
height: 0.75em;
background: var(--color);
margin-inline: 0.5em;
line-height: 0.75em;
border-radius: 50%;
}
.connected {
color: white;
}
.logged-out {
color: gray;
}

@ -0,0 +1,42 @@
import { LobbyClient } from "boardgame.io/client"
import { LOBBY_SERVER, PLAYER_COLORS } from "../consts.ts";
import { For, Show, createEffect, createResource, onCleanup } from "solid-js";
import classes from "./Status.module.css";
export type StatusProps = {
gameName: string,
matchID: string,
}
export default function Status(props: StatusProps) {
const client = new LobbyClient({
server: LOBBY_SERVER
});
const [match, actions] = createResource(() => client.getMatch(props.gameName, props.matchID));
createEffect(() => {
setTimeout(() => {
actions.refetch();
}, 1000);
const interval = setInterval(() => {
actions.refetch();
}, 10000);
onCleanup(() => clearInterval(interval));
});
return <ul class={classes.users}>
<For each={match()?.players} fallback={<li><i>Loading...</i></li>}>
{(player, index) => {
return <li data-index={index()} class={player.isConnected ? classes.connected : classes["logged-out"]}>
<div class={classes["player-icon"]} style={{
"--color": PLAYER_COLORS[player.id % PLAYER_COLORS.length]
}}></div>
<Show when={player.name} fallback={<i>No player</i>}>{player.name}</Show>
</li>;
}}
</For>
</ul>;
}

@ -2,4 +2,6 @@
export const PLAYER_COLORS = ["#f06040", "#4050f0"];
export const GHOST_COLORS = ["#c05a47", "#4750c0"];
export const BASE_URL = "/";
export const DOMAIN = "http://localhost:3000";
export const SOCKETIO_SERVER = "localhost:3100";
export const LOBBY_SERVER = "http://localhost:3200/";

@ -143,11 +143,23 @@ export type Moves = Partial<MapMoves<{
setConnected: (state: {}) => void,
}>>;
export const AcrossTheHex: Game<State, Record<string, unknown>, {
export type SetupData = {
size?: number,
initialResources?: number
}> = {
setup({ ctx }, { size = 4, initialResources = 2 } = {}) {
};
export const DEFAULT_SETUP_DATA: Required<SetupData> = {
size: 4,
initialResources: 2,
}
export const AcrossTheHex: Game<State, Record<string, unknown>, SetupData> = {
name: "across-the-hex",
setup({ ctx }, setupData = {}) {
const { size, initialResources } = {
...DEFAULT_SETUP_DATA,
...setupData
};
const cells = initGrid(size);
const players = new Array(ctx.numPlayers).fill(null).map((_, id) => id.toString());

@ -1,11 +1,11 @@
---
import Document from "../components/Document.astro";
import Game from "../components/Game.tsx";
import MatchMaker from "../components/MatchMaker.tsx";
export const prerender = true;
---
<Document>
<h1>Across the Hex</h1>
<MatchMaker client:only />
</Document>

@ -1,6 +1,6 @@
---
import Document from "../components/Document.astro";
import Multiplayer from "../components/Multiplayer.astro";
import Multiplayer from "../components/Multiplayer.tsx";
import Singleplayer from "../components/Singleplayer.astro";
const playerID = Astro.url.searchParams.get("player") ?? undefined;
@ -10,5 +10,5 @@ const debug = Astro.url.searchParams.has("debug");
---
<Document>
{matchID ? <Multiplayer playerID={playerID} matchID={matchID} debug={debug} /> : <Singleplayer debug={debug} />}
{matchID ? <Multiplayer playerID={playerID} matchID={matchID} debug={debug} client:only /> : <Singleplayer debug={debug} />}
</Document>

Loading…
Cancel
Save