can creater & join lobby

This commit is contained in:
evanpelle
2024-10-14 20:45:31 -07:00
parent e490e23add
commit 42a6a2fcef
12 changed files with 481 additions and 347 deletions
+211
View File
@@ -0,0 +1,211 @@
import {LitElement, html, css} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {GameMap} from '../core/game/Game';
import {Lobby} from '../core/Schemas';
@customElement('host-lobby-modal')
export class HostLobbyModal extends LitElement {
@state() private isModalOpen = false;
@state() private selectedMap: GameMap = GameMap.World;
@state() private lobbyId = 'a345d';
@state() private copySuccess = false;
static styles = css`
.modal-overlay {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
background-color: white;
margin: 15% auto;
padding: 20px;
border-radius: 8px;
width: 80%;
max-width: 500px;
text-align: center;
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover,
.close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
button {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
transition: background-color 0.3s;
display: inline-block;
margin-top: 20px;
}
button:hover {
background-color: #0056b3;
}
select {
padding: 8px;
font-size: 16px;
margin-top: 10px;
width: 200px;
}
.lobby-id-container {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.clipboard-icon {
cursor: pointer;
transition: opacity 0.3s;
}
.clipboard-icon:hover {
opacity: 0.7;
}
.copy-success {
color: green;
font-size: 14px;
margin-top: 5px;
}
`;
render() {
return html`
<div class="modal-overlay" style="display: ${this.isModalOpen ? 'block' : 'none'}">
<div class="modal-content">
<span class="close" @click=${this.close}>&times;</span>
<h2>Private Lobby</h2>
<div class="lobby-id-container">
<h3>Lobby ID: ${this.lobbyId}</h3>
<svg @click=${this.copyToClipboard} class="clipboard-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
</svg>
</div>
${this.copySuccess ? html`<p class="copy-success">Copied to clipboard!</p>` : ''}
<div>
<label for="map-select">Map: </label>
<select id="map-select" @change=${this.handleMapChange}>
${Object.entries(GameMap)
.filter(([key]) => isNaN(Number(key)))
.map(([key, value]) => html`
<option value=${value} ?selected=${this.selectedMap === value}>
${key}
</option>
`)}
</select>
</div>
<button @click=${this.startGame}>Start Game</button>
</div>
</div>
`;
}
public open() {
createLobby().then((lobby) => {
this.lobbyId = lobby.id
// join lobby
}).then(() => {
this.dispatchEvent(new CustomEvent('join-lobby', {
detail: {
singlePlayer: false,
lobby: {
id: this.lobbyId,
},
map: this.selectedMap,
},
bubbles: true,
composed: true
}));
})
this.isModalOpen = true;
}
public close() {
this.isModalOpen = false;
this.copySuccess = false;
}
private handleMapChange(e: Event) {
this.selectedMap = Number((e.target as HTMLSelectElement).value) as GameMap;
}
private async startGame() {
console.log(`Starting single player game with map: ${GameMap[this.selectedMap]}`);
this.close();
const response = await fetch(`/start_private_lobby/${this.lobbyId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
}
private async copyToClipboard() {
try {
await navigator.clipboard.writeText(this.lobbyId);
this.copySuccess = true;
setTimeout(() => {
this.copySuccess = false;
}, 2000);
} catch (err) {
console.error('Failed to copy text: ', err);
}
}
}
async function createLobby(): Promise<Lobby> {
try {
const response = await fetch('/private_lobby', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
// body: JSON.stringify(data), // Include this if you need to send data
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log('Success:', data);
// Assuming the server returns an object with an 'id' property
const lobby: Lobby = {
id: data.id,
// Add other properties as needed
};
return lobby;
} catch (error) {
console.error('Error creating lobby:', error);
throw error; // Re-throw the error so the caller can handle it
}
}
+128
View File
@@ -0,0 +1,128 @@
import {LitElement, html, css} from 'lit';
import {customElement, property, state, query} from 'lit/decorators.js';
import {GameMap} from '../core/game/Game';
@customElement('join-private-lobby-modal')
export class JoinPrivateLobbyModal extends LitElement {
@state() private isModalOpen = false;
@query('#lobbyIdInput') private lobbyIdInput!: HTMLInputElement;
static styles = css`
.modal-overlay {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
background-color: white;
margin: 15% auto;
padding: 20px;
border-radius: 8px;
width: 80%;
max-width: 500px;
text-align: center;
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover,
.close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
button {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
transition: background-color 0.3s;
}
button:hover {
background-color: #0056b3;
}
.lobby-id-container {
display: flex;
align-items: stretch;
justify-content: center;
gap: 10px;
margin: 20px 0;
}
.lobby-id-container input {
flex-grow: 1;
max-width: 200px;
padding: 10px;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 4px;
}
.lobby-id-container button {
padding: 10px 15px;
}
.join-button {
margin-top: 10px;
}
`;
render() {
return html`
<div class="modal-overlay" style="display: ${this.isModalOpen ? 'block' : 'none'}">
<div class="modal-content">
<span class="close" @click=${this.close}>&times;</span>
<h2>Join Private Lobby</h2>
<div class="lobby-id-container">
<input type="text" id="lobbyIdInput" placeholder="Enter Lobby ID">
<button @click=${this.pasteFromClipboard}>Paste</button>
</div>
<button class="join-button" @click=${this.joinLobby}>Join Lobby</button>
</div>
</div>
`;
}
public open() {
this.isModalOpen = true;
}
public close() {
this.isModalOpen = false;
}
private async pasteFromClipboard() {
try {
const clipText = await navigator.clipboard.readText();
this.lobbyIdInput.value = clipText;
} catch (err) {
console.error('Failed to read clipboard contents: ', err);
}
}
private joinLobby() {
const lobbyId = this.lobbyIdInput.value;
// Add your logic here to join the lobby using the lobbyId
console.log(`Joining lobby with ID: ${lobbyId}`);
this.dispatchEvent(new CustomEvent('join-lobby', {
detail: {
lobby: {id: lobbyId},
singlePlayer: false,
map: GameMap.World,
},
bubbles: true,
composed: true
}))
this.close();
}
}
+19 -11
View File
@@ -4,15 +4,13 @@ import favicon from '../../resources/images/Favicon.png';
import './PublicLobby';
import './UsernameInput';
import './styles.css';
import {UsernameInput} from "./UsernameInput";
import {SinglePlayerModal} from "./SinglePlayerModal";
import {GameMap} from "../core/game/Game";
import {HostLobbyModal as HostPrivateLobbyModal} from "./HostLobbyModal";
import {JoinPrivateLobbyModal} from "./JoinPrivateLobbyModal";
const usernameKey: string = 'username';
class Client {
@@ -39,14 +37,24 @@ class Client {
document.addEventListener('single-player', this.handleSinglePlayer.bind(this));
const singlePlayerButton = document.getElementById('single-player');
const modal = document.querySelector('single-player-modal') as SinglePlayerModal;
const spModal = document.querySelector('single-player-modal') as SinglePlayerModal;
spModal instanceof SinglePlayerModal
document.getElementById('single-player').addEventListener('click', () => {
spModal.open();
})
const hostModal = document.querySelector('host-lobby-modal') as HostPrivateLobbyModal;
hostModal instanceof HostPrivateLobbyModal
document.getElementById('host-lobby-button').addEventListener('click', () => {
hostModal.open();
})
const joinModal = document.querySelector('join-private-lobby-modal') as JoinPrivateLobbyModal;
joinModal instanceof JoinPrivateLobbyModal
document.getElementById('join-private-lobby-button').addEventListener('click', () => {
joinModal.open();
})
if (singlePlayerButton && modal instanceof SinglePlayerModal) {
singlePlayerButton.addEventListener('click', () => {
modal.open();
});
}
}
+1 -1
View File
@@ -126,4 +126,4 @@ export class PublicLobby extends LitElement {
this.currLobby = null
}
}
}
}
+4 -2
View File
@@ -42,11 +42,11 @@
<!-- Create and Join Lobby buttons stacked -->
<div class="flex-1 space-y-4">
<button id="create-lobby"
<button id="host-lobby-button"
class="w-full h-12 px-4 py-4 text-sm font-medium text-blue-700 bg-blue-100 rounded-md hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition duration-300 ease-in-out">
Create Lobby
</button>
<button id="join-lobby"
<button id="join-private-lobby-button"
class="w-full h-12 px-4 py-4 text-sm font-medium text-blue-700 bg-blue-100 rounded-md hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition duration-300 ease-in-out">
Join Lobby
</button>
@@ -68,6 +68,8 @@
<div id="radialMenu" class="radial-menu"></div>
<single-player-modal></single-player-modal>
<host-lobby-modal></host-lobby-modal>
<join-private-lobby-modal></join-private-lobby-modal>
<emoji-table></emoji-table>
<leader-board></leader-board>
+2 -2
View File
@@ -45,8 +45,8 @@ const PlayerTypeSchema = z.nativeEnum(PlayerType);
export interface Lobby {
id: string;
msUntilStart: number;
numClients: number;
msUntilStart?: number;
numClients?: number;
}
const EmojiSchema = z.string().refine(
+30 -6
View File
@@ -1,6 +1,4 @@
import {Config} from "../core/configuration/Config";
import {PseudoRandom} from "../core/PseudoRandom";
import WebSocket from 'ws';
import {ClientID, GameID} from "../core/Schemas";
import {v4 as uuidv4} from 'uuid';
import {Client} from "./Client";
@@ -14,8 +12,6 @@ export class GameManager {
private games: GameServer[] = []
private random = new PseudoRandom(123)
constructor(private config: Config) { }
gamesByPhase(phase: GamePhase): GameServer[] {
@@ -31,6 +27,23 @@ export class GameManager {
game.addClient(client, lastTurn)
}
createPrivateGame(): string {
const id = genSmallGameID()
this.games.push(new GameServer(id, Date.now(), false, this.config))
return id
}
// TODO: stop private games to prevent memory leak.
startPrivateGame(gameID: GameID) {
const game = this.games.find(g => g.id == gameID)
console.log(`found game ${game}`)
if (game) {
game.start()
} else {
throw new Error(`cannot start private game, game ${gameID} not found`)
}
}
tick() {
const lobbies = this.gamesByPhase(GamePhase.Lobby)
const active = this.gamesByPhase(GamePhase.Active)
@@ -40,10 +53,10 @@ export class GameManager {
if (now > this.lastNewLobby + this.config.gameCreationRate()) {
this.lastNewLobby = now
const id = uuidv4()
lobbies.push(new GameServer(id, now, this.config))
lobbies.push(new GameServer(id, now, true, this.config))
}
active.filter(g => !g.hasStarted()).forEach(g => {
active.filter(g => !g.hasStarted() && g.isPublic).forEach(g => {
g.start()
})
finished.forEach(g => {
@@ -51,4 +64,15 @@ export class GameManager {
})
this.games = [...lobbies, ...active]
}
}
function genSmallGameID(): string {
// Generate a UUID
const uuid: string = uuidv4();
// Convert UUID to base64
const base64: string = btoa(uuid);
// Take the first 4 characters of the base64 string
return base64.slice(0, 4);
}
+17 -5
View File
@@ -26,6 +26,7 @@ export class GameServer {
constructor(
public readonly id: string,
public readonly createdAt: number,
public readonly isPublic: boolean,
private config: Config,
) { }
@@ -129,6 +130,22 @@ export class GameServer {
}
phase(): GamePhase {
if (Date.now() > this.createdAt + this.config.lobbyLifetime() + this.maxGameDuration) {
console.warn(`game past max duration ${this.id}`)
return GamePhase.Finished
}
if (!this.isPublic) {
if (this._hasStarted) {
if (this.clients.length == 0) {
return GamePhase.Finished
} else {
return GamePhase.Active
}
} else {
return GamePhase.Lobby
}
}
if (Date.now() - this.createdAt < this.config.lobbyLifetime()) {
return GamePhase.Lobby
}
@@ -137,11 +154,6 @@ export class GameServer {
return GamePhase.Finished
}
if (Date.now() > this.createdAt + this.config.lobbyLifetime() + this.maxGameDuration) {
console.warn(`game past max duration ${this.id}`)
return GamePhase.Finished
}
return GamePhase.Active
}
+28 -2
View File
@@ -8,7 +8,8 @@ import {ClientMessage, ClientMessageSchema} from '../core/Schemas';
import {getConfig} from '../core/configuration/Config';
import {LogSeverity, slog} from './StructuredLog';
import {Client} from './Client';
import {GamePhase} from './GameServer';
import {GamePhase, GameServer} from './GameServer';
import {v4 as uuidv4} from 'uuid';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -22,6 +23,8 @@ app.use(express.static(path.join(__dirname, '../../out')));
app.use(express.json())
const gm = new GameManager(getConfig())
const privateGames = new Map<string, GameServer>()
// New GET endpoint to list lobbies
app.get('/lobbies', (req, res) => {
const now = Date.now()
@@ -32,6 +35,30 @@ app.get('/lobbies', (req, res) => {
});
});
app.post('/private_lobby', (req, res) => {
const id = gm.createPrivateGame()
console.log('creating private lobby with id ${id}')
res.json({
id: id
});
});
app.post('/start_private_lobby/:id', (req, res) => {
console.log(`starting private lobby with id ${req.params.id}`)
gm.startPrivateGame(req.params.id)
});
app.put('/private_lobby/:id', (req, res) => {
});
app.get('/private_lobby/:id', (req, res) => {
res.json({
hi: '5'
});
});
wss.on('connection', (ws) => {
ws.on('message', (message: string) => {
@@ -61,4 +88,3 @@ server.listen(PORT, () => {
});
runGame()