Merge everything to main branch #1

Merged
Ta180m merged 4 commits from dev into main 2021-05-06 03:31:09 +00:00
37 changed files with 9881 additions and 1 deletions

106
.gitignore vendored Normal file
View file

@ -0,0 +1,106 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
.vercel

View file

@ -1,2 +1,2 @@
# BSX
The card game B.S., but better!
The card game B.S., but better! Currently under development.

5
back/nodemon.json Normal file
View file

@ -0,0 +1,5 @@
{
"ext": "js, ts, json",
"exec": "npm run dev2 || exit 1",
"ignore": ["dist/*"]
}

2731
back/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
back/package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "bsx-back",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node dist/index.js",
"build": "tsc",
"dev": "nodemon",
"dev2": "npm run build && npm start",
"preinstall": "cd ../core && npm i && npm run build"
},
"author": "",
"license": "ISC",
"dependencies": {
"bsx-core": "file:../core",
"dotenv": "^8.2.0",
"socket.io": "^3.1.1"
},
"devDependencies": {
"@types/node": "^14.14.28",
"@types/socket.io": "^2.1.13",
"nodemon": "^2.0.7",
"typescript": "^4.1.5"
}
}

56
back/src/Client.ts Normal file
View file

@ -0,0 +1,56 @@
import {Socket} from 'socket.io';
import Room from './Room';
export const clients = new Map();
export const usernameToId = new Map();
let newClientId = 0;
export default class Client {
id: number;
username: string;
socket: Socket;
disconnectTimeout: NodeJS.Timeout | null = null;
room: Room | null = null;
onListeners = new Map();
onceListeners = new Map();
constructor(username: string, socket: Socket) {
this.id = newClientId++;
this.username = username;
this.socket = socket;
}
on(e: string, f: (...args: any[]) => void) {
if (!this.onListeners.has(e))
this.onListeners.set(e, []);
this.onListeners.get(e).push(f);
this.socket.on(e, f);
}
once(e: string, f: (...args: any[]) => void) {
if (!this.onceListeners.has(e))
this.onceListeners.set(e, []);
this.onceListeners.get(e).push(f);
this.socket.once(e, (...args) => {
this.onceListeners.get(e).splice(this.onceListeners.get(e).indexOf(f), 1);
f(...args);
});
}
removeAllListeners(e: string) {
this.socket.removeAllListeners(e);
this.onListeners.delete(e);
this.onceListeners.delete(e);
}
updateSocket(socket: Socket) {
this.socket = socket;
// Add listeners
this.onListeners.forEach((fs, e) => fs.forEach((f: (...args: any[]) => void) => socket.on(e, f)));
this.onceListeners.forEach((fs, e) => fs.forEach((f: (...args: any[]) => void) => {
socket.once(e, (...args) => {
this.onceListeners.get(e).splice(this.onceListeners.get(e).indexOf(f), 1);
f(...args);
});
}));
if (this.room)
this.room.updateSocket(this);
}
}

169
back/src/Game.ts Normal file
View file

@ -0,0 +1,169 @@
import {canPlay, Card, cmpCard, Suit} from 'bsx-core';
import Client from './Client';
import logSocket from './logSocket';
import Room from './Room';
import server from './server';
class Player {
game: Game;
client: Client;
cards: Card[] = [];
disconnected = false;
disconnectListener?: () => void;
rank = 0;
passed = false;
constructor(game: Game, client: Client) {
this.game = game;
this.client = client;
}
sendGameState() {
this.cards.sort(cmpCard);
const i = this.game.players.indexOf(this);
const otherPlayers = [];
for (let j = 1; j < this.game.players.length; ++j)
otherPlayers.push(this.game.players[(i + j) % this.game.players.length]);
this.client.socket.emit('gameState', {
cards: this.cards,
rank: this.rank,
passed: this.passed,
players: otherPlayers.map((p: Player) => ({
username: p.client.username,
numCards: p.cards.length,
rank: p.rank,
passed: p.passed
})),
lastPlayed: this.game.lastPlayed,
lastPlayedPlayer: this.game.lastPlayedPlayer < 0 ? null : this.game.players[this.game.lastPlayedPlayer].client.username,
playerTurn: this.game.players[this.game.playerTurn].client.username
});
}
}
export default class Game {
room: Room;
players: Player[] = [];
lastPlayed: Card[] | null = null;
lastPlayedPlayer = -1;
playerTurn = 0;
playersFinished = 0;
constructor(room: Room) {
this.room = room;
this.start();
}
async start() {
const cards = [];
for (let i = 1; i <= 13; ++i)
for (let j = 0; j < 4; ++j)
cards.push({rank: i, suit: j});
for (let i = 0; i < 52; ++i) {
const j = Math.floor(Math.random() * (i+1));
[cards[i], cards[j]] = [cards[j], cards[i]];
}
const handSize = Math.floor(52 / this.room.clients.length);
for (let i = 0; i < this.room.clients.length; ++i) {
this.players.push(new Player(this, this.room.clients[i]));
this.players[i].cards = cards.slice(i * handSize, (i + 1) * handSize);
}
const startingPlayer = this.players.find((p: Player) => p.cards.some(
(card: Card) => card.rank === 3 && card.suit === Suit.Clubs
)) || this.players[0];
if (this.room.clients.length === 3)
startingPlayer.cards.push(cards[51]);
this.playerTurn = this.players.indexOf(startingPlayer);
while (true) {
// Check if game ended
const playersLeft: Player[] = [];
this.players.forEach((p: Player) => {
if (!p.rank && !p.disconnected)
playersLeft.push(p);
});
if (playersLeft.length < 2) {
if (playersLeft.length === 1)
playersLeft[0].rank = ++this.playersFinished;
break;
}
await this.round();
}
this.broadcastGameState();
setTimeout(() => {
server.to(this.room.name).emit('endGame');
this.room.host.once('startGame', () => this.room.startGame());
this.room.game = null;
}, 5000);
}
broadcastGameState() {
this.players.forEach((p: Player) => p.sendGameState());
}
async round() {
while (true) {
// Everyone passes
if (this.playerTurn === this.lastPlayedPlayer) break;
const p = this.players[this.playerTurn];
// Guy passes
if (p.rank || p.disconnected || p.passed) {
this.playerTurn = (this.playerTurn + 1) % this.players.length;
continue;
}
await this.turn();
this.playerTurn = (this.playerTurn + 1) % this.players.length;
// Check if person ends
if (p.rank)
break;
}
this.lastPlayed = null;
this.lastPlayedPlayer = -1;
this.players.forEach((p: Player) => p.passed = false);
}
async turn() {
const p = this.players[this.playerTurn];
if (p.passed) return;
this.broadcastGameState();
await new Promise<void>(resolve => {
p.client.once('turn', cards => {
delete p.disconnectListener;
(() => {
// Pass
if (cards === null) {
p.passed = true;
return;
}
// Play
if (cards && Array.isArray(cards) && cards.every((a: Card) => a && p.cards.some((b: Card) => !cmpCard(a, b))) && canPlay(this.lastPlayed, cards)) {
// Cards have to be ascending
let ok = true;
for (let i = 0; i + 1 < cards.length; ++i)
ok = ok && cmpCard(cards[i], cards[i + 1]) < 0;
if (ok) {
// Remove cards
p.cards = p.cards.filter((a: Card) => !cards.some((b: Card) => !cmpCard(a, b)));
// Check if won
if (!p.cards.length)
p.rank = ++this.playersFinished;
this.lastPlayed = cards;
this.lastPlayedPlayer = this.playerTurn;
return;
}
}
p.client.socket.disconnect();
logSocket(p.client.socket, 'Bad cards argument on turn');
})();
resolve();
});
p.disconnectListener = () => {
delete p.disconnectListener;
p.client.removeAllListeners('turn');
resolve();
};
});
}
updateSocket(client: Client) {
this.players.find((p: Player) => p.client === client)!.sendGameState();
}
remove(client: Client) {
const p = this.players.find((p: Player) => p.client === client)!;
p.disconnected = true;
if (p.disconnectListener)
p.disconnectListener();
}
};

74
back/src/Room.ts Normal file
View file

@ -0,0 +1,74 @@
import Client from './Client';
import Game from './Game';
import logSocket from './logSocket';
import server from './server';
export default class Room {
static rooms = new Map();
static get(name: string) {
return this.rooms.get(name);
}
name: string;
password: string;
clients: Client[] = [];
host: Client;
game: Game | null = null;
constructor(name: string, password: string, host: Client) {
this.name = name;
this.password = password;
this.host = host;
this.add(host);
host.once('startGame', () => this.startGame());
Room.rooms.set(name, this);
}
add(client: Client) {
this.clients.push(client);
client.room = this;
server.to(this.name).emit('roomUpdate', {
users: this.clients.map((client: Client) => client.username),
host: this.host.username
});
client.once('leaveRoom', () => this.remove(client));
this.updateSocket(client);
}
updateSocket(client: Client) {
client.socket.join(this.name);
client.socket.emit('joinRoom', {name: this.name});
client.socket.emit('roomUpdate', {
users: this.clients.map((client: Client) => client.username),
host: this.host.username
});
if (this.game)
this.game.updateSocket(client);
}
remove(client: Client) {
this.clients.splice(this.clients.indexOf(client), 1);
client.room = null;
client.socket.leave(this.name);
client.socket.emit('leaveRoom');
if (!this.clients.length) {
Room.rooms.delete(this.name);
return;
}
if (this.host === client) {
this.host.removeAllListeners('startGame');
this.host = this.clients[0];
if (!this.game)
this.host.once('startGame', () => this.startGame());
}
server.to(this.name).emit('roomUpdate', {
users: this.clients.map((client: Client) => client.username),
host: this.host.username
});
if (this.game)
this.game.remove(client);
}
startGame() {
if (this.clients.length < 3) {
this.host.socket.disconnect();
logSocket(this.host.socket, 'Not enough users for startGame');
return;
}
this.game = new Game(this);
}
};

155
back/src/index.ts Normal file
View file

@ -0,0 +1,155 @@
import dotenv from 'dotenv';
import {Socket} from 'socket.io';
dotenv.config();
import Client, {clients, usernameToId} from './Client';
import logSocket from './logSocket';
import Room from './Room';
import server from './server';
server.on('connection', (socket: Socket) => {
logSocket(socket, 'Connect');
let client: Client | null = null;
socket.on('login', username => {
logSocket(socket, `Login as ${username}`);
// Shouldn't login again???
if (client) {
socket.disconnect();
logSocket(socket, 'Already logged in');
return;
}
// Bad username argument
if (typeof username !== 'string' || username.length < 1 || username.length > 15) {
socket.disconnect();
logSocket(socket, 'Bad username argument on login');
return;
}
// Character filter
if (!username.match(/^[0-9a-zA-Z]+$/)) {
socket.emit('loginRes', {success: false, error: 'Username can only consist of alphanumeric characters'});
return;
}
if (usernameToId.has(username)) {
const client2 = clients.get(usernameToId.get(username)) as Client;
if(client2.socket.connected || client2.socket.handshake.address !== socket.handshake.address) {
socket.emit('loginRes', {success: false, error: 'Username taken'});
logSocket(socket, 'Username taken');
return;
}
client = client2;
clearTimeout(client.disconnectTimeout!);
// client.socket = socket;
client.updateSocket(socket);
socket.emit('loginRes', {success: true});
// if (client.room)
// client.room.updateSocket(client);
logSocket(socket, 'Reconnect login successful');
} else {
client = new Client(username, socket);
clients.set(client.id, client);
usernameToId.set(username, client.id);
socket.emit('loginRes', {success: true});
logSocket(socket, 'Login successful');
client.on('joinRoom', (name, password) => {
client = client as Client;
// Bad name argument
if (typeof name !== 'string' || name.length < 1 || name.length > 15) {
socket.disconnect();
logSocket(socket, 'Bad name argument on joinRoom');
return;
}
// Bad password argument
if (typeof password !== 'string' || password.length > 15) {
socket.disconnect();
logSocket(socket, 'Bad password argument on joinRoom');
return;
}
// Character filter
if (!name.match(/^[0-9a-zA-Z ]+$/)) {
socket.emit('joinRoomRes', {success: false, error: 'Room name can only consist of alphanumeric characters and spaces'});
return;
}
// Make sure not in room
if (client.room) {
socket.disconnect();
logSocket(socket, 'Already in room on joinRoom');
return;
}
// Make sure room exists
if (!Room.get(name)) {
socket.emit('joinRoomRes', {success: false, error: `Room "${name}" does not exist`});
return;
}
const room = Room.get(name);
// Make sure password is correct
if (password !== room.password) {
socket.emit('joinRoomRes', {success: false, error: 'Password is incorrect'});
return;
}
// Make sure game has not yet started
if (room.game) {
socket.emit('joinRoomRes', {success: false, error: 'The game has already started'});
return;
}
// Make sure room has space
if (room.clients.length > 3) {
socket.emit('joinRoomRes', {success: false, error: 'Room does not have enough space'});
return;
}
// Success
socket.emit('joinRoomRes', {success: true});
room.add(client);
});
client.on('createRoom', (name, password) => {
client = client as Client;
// Bad name argument
if (typeof name !== 'string' || name.length < 1 || name.length > 15) {
socket.disconnect();
logSocket(socket, 'Bad name argument on createRoom');
return;
}
// Bad password argument
if (typeof password !== 'string' || password.length > 15) {
socket.disconnect();
logSocket(socket, 'Bad password argument on createRoom');
return;
}
// Character filter
if (!name.match(/^[0-9a-zA-Z ]+$/)) {
socket.emit('createRoomRes', {success: false, error: 'Room name can only consist of alphanumeric characters and spaces'});
return;
}
// Make sure not in room
if (client.room) {
socket.disconnect();
logSocket(socket, 'Already in room on createRoom');
return;
}
// Make sure room does not exist
if (Room.get(name)) {
socket.emit('createRoomRes', {success: false, error: `Room "${name}" already exists`});
return;
}
// Success
socket.emit('createRoomRes', {success: true});
new Room(name, password, client);
});
}
});
socket.on('disconnect', () => {
logSocket(socket, 'Disconnect');
if (!client) return;
client.disconnectTimeout = setTimeout(() => {
client = client as Client;
if (client.room) client.room.remove(client);
logSocket(socket, 'Full disconnect');
clients.delete(client.id);
usernameToId.delete(client.username);
}, 60000);
});
});

5
back/src/logSocket.ts Normal file
View file

@ -0,0 +1,5 @@
import {Socket} from 'socket.io';
export default (socket: Socket, s: string) => {
console.log(`[${socket.id}, ${socket.handshake.address}] ${s}`);
};

17
back/src/server.ts Normal file
View file

@ -0,0 +1,17 @@
import fs from 'fs';
import http from 'http';
import https from 'https';
import {Server} from 'socket.io';
//export default new Server({cors: {origin: process.env.ORIGIN, methods: ['GET', 'POST']}});
const base = process.env.SSL_KEY && process.env.SSL_CERT && process.env.SSL_CA ? https.createServer({
"key": fs.readFileSync(process.env.SSL_KEY),
"cert": fs.readFileSync(process.env.SSL_CERT),
"ca": fs.readFileSync(process.env.SSL_CA)
}) : http.createServer();
base.listen(+process.env.PORT!, () => {
console.log(`Listening on port ${process.env.PORT}`);
});
export default new Server(base, {cors: {origin: process.env.ORIGIN, methods: ['GET', 'POST']}});

11
back/tsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}

37
core/package-lock.json generated Normal file
View file

@ -0,0 +1,37 @@
{
"name": "bsx-core",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "bsx-core",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"typescript": "^4.1.5"
}
},
"node_modules/typescript": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.5.tgz",
"integrity": "sha512-6OSu9PTIzmn9TCDiovULTnET6BgXtDYL4Gg4szY+cGsc3JP1dQL8qvE8kShTRx1NIw4Q9IBHlwODjkjWEtMUyA==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
}
}
},
"dependencies": {
"typescript": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.5.tgz",
"integrity": "sha512-6OSu9PTIzmn9TCDiovULTnET6BgXtDYL4Gg4szY+cGsc3JP1dQL8qvE8kShTRx1NIw4Q9IBHlwODjkjWEtMUyA==",
"dev": true
}
}
}

14
core/package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "bsx-core",
"version": "1.0.0",
"description": "",
"main": "dist/index.js",
"scripts": {
"build": "tsc"
},
"author": "",
"license": "ISC",
"devDependencies": {
"typescript": "^4.1.5"
}
}

8
core/src/Card.ts Normal file
View file

@ -0,0 +1,8 @@
import Suit from './Suit';
interface Card {
rank: number;
suit: Suit;
}
export default Card;

8
core/src/Suit.ts Normal file
View file

@ -0,0 +1,8 @@
enum Suit {
Clubs,
Diamonds,
Hearts,
Spades
}
export default Suit;

72
core/src/canPlay.ts Normal file
View file

@ -0,0 +1,72 @@
import Card from './Card';
import cmpCard from './cmpCard';
import realRank from './realRank';
class Hand {
size: number;
overrideSize: boolean;
matchFunc: (cards: Card[]) => Card | null;
constructor(size: number, overrideSize: boolean, matchFunc: (cards: Card[]) => Card | null) {
this.size = size;
this.overrideSize = overrideSize;
this.matchFunc = matchFunc;
}
match(cards: Card[]) {
return cards.length === this.size && this.matchFunc(cards);
}
}
const chkFlush = (cards: Card[]) => cards.every((card: Card) => card.suit === cards[0].suit);
const chkStraight = (cards: Card[]) => {
let ok = true;
for (let i = 1; i < 5; ++i)
ok = ok && realRank(cards[0]) + i === realRank(cards[i]);
if (ok) return true;
// 3 4 5 6 2
ok = cards[4].rank === 2;
for (let i = 0; i < 4; ++i)
ok = ok && cards[0].rank === 3 + i;
if (ok) return true;
return false;
};
const hands = [
// Straight Flush
new Hand(5, true, (cards: Card[]) => chkFlush(cards) && chkStraight(cards) ? cards[4] : null),
// Four of a Kind
new Hand(5, true, (cards: Card[]) =>
(cards[0].rank === cards[3].rank ? cards[3] : null) ||
(cards[1].rank === cards[4].rank ? cards[4] : null)
),
// Full House
new Hand(5, false, (cards: Card[]) =>
(cards[0].rank === cards[2].rank && cards[3].rank === cards[4].rank ? cards[2] : null) ||
(cards[0].rank === cards[1].rank && cards[2].rank === cards[4].rank ? cards[4] : null)
),
// Straight
new Hand(5, false, (cards: Card[]) => chkStraight(cards) ? cards[4] : null),
// Flush
new Hand(5, false, (cards: Card[]) => chkFlush(cards) ? cards[4] : null),
// Pair
new Hand(2, false, (cards: Card[]) => cards[0].rank === cards[1].rank ? cards[1] : null),
// Single
new Hand(1, false, (cards: Card[]) => cards[0])
];
const parseHand = (cards: Card[]) => {
for (let i = 0; i < hands.length; ++i) {
const card = hands[i].match(cards);
if (card) return {id: i, card};
}
return null;
};
export default function canPlay(prev: Card[] | null, cur: Card[]) {
const curHand = parseHand(cur);
if (!curHand) return false;
if (!prev) return true;
const prevHand = parseHand(prev)!;
if (!hands[curHand.id].overrideSize && cur.length !== prev.length) return false;
if (curHand.id !== prevHand.id) return curHand.id < prevHand.id;
return cmpCard(prevHand.card, curHand.card) < 0;
};

8
core/src/cmpCard.ts Normal file
View file

@ -0,0 +1,8 @@
import Card from './Card';
import realRank from './realRank';
export default function cmpCard(a: Card, b: Card) {
if (a.rank !== b.rank)
return realRank(a) - realRank(b);
return a.suit - b.suit;
};

5
core/src/index.ts Normal file
View file

@ -0,0 +1,5 @@
export {default as canPlay} from './canPlay';
export {default as Card} from './Card';
export {default as cmpCard} from './cmpCard';
export {default as realRank} from './realRank';
export {default as Suit} from './Suit';

5
core/src/realRank.ts Normal file
View file

@ -0,0 +1,5 @@
import Card from './Card';
export default function realRank(card: Card) {
return (card.rank + 10) % 13;
}

12
core/tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true
}
}

36
front.md Normal file
View file

@ -0,0 +1,36 @@
# Front
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View file

@ -0,0 +1,46 @@
import {useState} from 'react';
export default function CreateRoomForm({socket} : {socket: SocketIOClient.Socket}) {
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [waiting, setWaiting] = useState(false);
const [error, setError] = useState('');
return (
<>
<p>Create Room</p>
<input
type="text"
maxLength={15}
placeholder="Room name..."
value={name}
onChange={(e) => setName(e.target.value)}
disabled={waiting}
/>
<input
type="text"
maxLength={15}
placeholder="Password..."
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={waiting}
/>
<button
onClick={() => {
setWaiting(true);
socket.emit('createRoom', name, password);
socket.once('createRoomRes', (data: {success: boolean; error?: string}) => {
setWaiting(false);
if (!data.success)
setError(data.error!);
});
}}
disabled={!name || waiting}
>
Create
</button>
{waiting && 'Creating room...'}
<p style={{color: 'red'}}>{error}</p>
</>
);
};

View file

@ -0,0 +1,46 @@
import {useState} from 'react';
export default function JoinRoomForm({socket} : {socket: SocketIOClient.Socket}) {
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [waiting, setWaiting] = useState(false);
const [error, setError] = useState('');
return (
<>
<p>Join Room</p>
<input
type="text"
maxLength={15}
placeholder="Room name..."
value={name}
onChange={(e) => setName(e.target.value)}
disabled={waiting}
/>
<input
type="text"
maxLength={15}
placeholder="Password..."
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={waiting}
/>
<button
onClick={() => {
setWaiting(true);
socket.emit('joinRoom', name, password);
socket.once('joinRoomRes', (data: {success: boolean; error?: string}) => {
setWaiting(false);
if (!data.success)
setError(data.error!);
});
}}
disabled={!name || waiting}
>
Join
</button>
{waiting && 'Joining room...'}
<p style={{color: 'red'}}>{error}</p>
</>
);
};

View file

@ -0,0 +1,41 @@
import {useEffect, useState} from 'react';
export default function LoginForm({finish, socket, username}: {finish: (s: string) => void, socket: SocketIOClient.Socket, username: string | null}) {
const [value, setValue] = useState(username || '');
const [waiting, setWaiting] = useState(false);
const [error, setError] = useState('');
const login = () => {
setWaiting(true);
socket.emit('login', value);
socket.once('loginRes', (data: {success: boolean; error?: string}) => {
setWaiting(false);
if (data.success)
finish(value);
else
setError(data.error!);
});
};
useEffect(() => {
if (username) login();
}, []);
return (
<>
<input
type="text"
maxLength={15}
placeholder="Your username..."
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={waiting}
/>
<button onClick={login} disabled={!value || waiting}>
Ok
</button>
{waiting && 'Logging in...'}
<p style={{color: 'red'}}>{error}</p>
</>
);
};

2
front/next-env.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
/// <reference types="next" />
/// <reference types="next/types/global" />

5763
front/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

25
front/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "bsx-front",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"tsc": "tsc",
"preinstall": "cd ../core && npm i && npm run build"
},
"dependencies": {
"bsx-core": "file:../core",
"next": "10.0.6",
"react": "17.0.1",
"react-dom": "17.0.1",
"socket.io-client": "^3.1.1"
},
"devDependencies": {
"@types/node": "^14.14.27",
"@types/react": "^17.0.2",
"@types/socket.io-client": "^1.4.35",
"typescript": "^4.1.5"
}
}

7
front/pages/_app.js Normal file
View file

@ -0,0 +1,7 @@
import '../styles/globals.css'
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />
}
export default MyApp

5
front/pages/api/hello.js Normal file
View file

@ -0,0 +1,5 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
export default (req, res) => {
res.status(200).json({ name: 'John Doe' })
}

159
front/pages/game.tsx Normal file
View file

@ -0,0 +1,159 @@
import {canPlay, Card} from 'bsx-core';
import {useEffect, useState} from 'react';
import io from 'socket.io-client';
import CreateRoomForm from '../components/CreateRoomForm';
import JoinRoomForm from '../components/JoinRoomForm';
import LoginForm from '../components/LoginForm';
interface GameState {
cards: Card[],
players: {username: string, numCards: number, rank: number}[],
lastPlayed: Card[] | null,
lastPlayedPlayer: string | null,
playerTurn: string
}
const rankStrs = ['', 'A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'];
const suitChars = ['♣', '♦', '♥', '♠'];
export default function Game() {
const [socket, setSocket] = useState<SocketIOClient.Socket | null>(null);
const [connected, setConnected] = useState(false);
const [loggedIn, setLoggedIn] = useState(false);
const [username, setUsername] = useState<string | null>(null);
const [room, setRoom] = useState<string | null>(null);
const [roomUsers, setRoomUsers] = useState<string[]>([]);
const [roomHost, setRoomHost] = useState('');
const [gameState, setGameState] = useState<GameState | null>(null);
const [cardSelected, setCardSelected] = useState<boolean[]>([]);
useEffect(() => {
const socket = io(process.env.NEXT_PUBLIC_BACK_HOST!);
setSocket(socket);
socket.on('connect', () => setConnected(true));
socket.on('disconnect', () => {
setConnected(false);
setLoggedIn(false);
setRoom(null);
setGameState(null);
});
socket.on('joinRoom', (data: {name: string}) => setRoom(data.name));
socket.on('leaveRoom', () => {
setRoom(null);
setGameState(null);
});
socket.on('roomUpdate', (data: {users: string[], host: string}) => {
setRoomUsers(data.users);
setRoomHost(data.host);
});
socket.on('gameState', (data: GameState) => {
setGameState(data);
if (data.cards.length !== cardSelected.length)
setCardSelected(new Array(data.cards.length).fill(false));
});
socket.on('endGame', () => setGameState(null));
return () => {socket.close()};
}, []);
if (!socket) return null;
if (!loggedIn) {
return (
<LoginForm
socket={socket}
finish={(s) => {
setLoggedIn(true);
setUsername(s);
}}
username={username}
/>
);
}
if (!room) {
return (
<>
<p>Logged in as {username}</p>
<hr />
<JoinRoomForm socket={socket} />
<hr />
<CreateRoomForm socket={socket} />
</>
);
}
if (!gameState) {
return (
<>
<p>Room {room}</p>
<p>Users:</p>
<ul>
{roomUsers.map(user => <li key={user}>{user + (user === roomHost ? ' (Host)':'')}</li>)}
</ul>
<button onClick={() => socket.emit('leaveRoom')}>Leave</button>
{username === roomHost &&
<button
onClick={() => socket.emit('startGame')}
disabled={roomUsers.length < 3}
>
Start
</button>
}
</>
);
}
const selectedCards = gameState.cards.filter((_, i) => cardSelected[i]);
return (
<>
<ul>
{gameState.players.map(p => (
<li key={p.username}>
{p.username + (p.rank ? ` (Rank ${p.rank})` : '') + (p.numCards ? ` (${p.numCards} cards)` : '')}
</li>
))}
</ul>
<div>
<p>Last played:</p>
{gameState.lastPlayed ? (
<>
{gameState.lastPlayed.map((card, i) => <span key={i}>{rankStrs[card.rank]+' '+suitChars[card.suit]+'|'}</span>)}
{` by ${gameState.lastPlayedPlayer}`}
</>
) : '(Nothing)'}
</div>
{`It's ${gameState.playerTurn}'s turn!`}
<div>
<p>Your cards:</p>
{gameState.cards.map((card, i) => (
<label key={card.rank+' '+card.suit}>
<input
type="checkbox"
checked={cardSelected[i] || false}
onChange={() => {
const cardSelected2 = [...cardSelected];
cardSelected2[i] = !cardSelected[i];
setCardSelected(cardSelected2);
}}
/>
{rankStrs[card.rank]+' '+suitChars[card.suit]}
</label>
))}
<button
onClick={() => socket.emit('turn', selectedCards)}
disabled={username !== gameState.playerTurn || !canPlay(gameState.lastPlayed, selectedCards)}
>
Play
</button>
<button
onClick={() => socket.emit('turn', null)}
disabled={username !== gameState.playerTurn}
>
Pass
</button>
</div>
</>
);
}

65
front/pages/index.js Normal file
View file

@ -0,0 +1,65 @@
import Head from 'next/head'
import styles from '../styles/Home.module.css'
export default function Home() {
return (
<div className={styles.container}>
<Head>
<title>Create Next App</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className={styles.main}>
<h1 className={styles.title}>
Welcome to <a href="https://nextjs.org">Next.js!</a>
</h1>
<p className={styles.description}>
Get started by editing{' '}
<code className={styles.code}>pages/index.js</code>
</p>
<div className={styles.grid}>
<a href="https://nextjs.org/docs" className={styles.card}>
<h3>Documentation &rarr;</h3>
<p>Find in-depth information about Next.js features and API.</p>
</a>
<a href="https://nextjs.org/learn" className={styles.card}>
<h3>Learn &rarr;</h3>
<p>Learn about Next.js in an interactive course with quizzes!</p>
</a>
<a
href="https://github.com/vercel/next.js/tree/master/examples"
className={styles.card}
>
<h3>Examples &rarr;</h3>
<p>Discover and deploy boilerplate example Next.js projects.</p>
</a>
<a
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
className={styles.card}
>
<h3>Deploy &rarr;</h3>
<p>
Instantly deploy your Next.js site to a public URL with Vercel.
</p>
</a>
</div>
</main>
<footer className={styles.footer}>
<a
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Powered by{' '}
<img src="/vercel.svg" alt="Vercel Logo" className={styles.logo} />
</a>
</footer>
</div>
)
}

BIN
front/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

4
front/public/vercel.svg Normal file
View file

@ -0,0 +1,4 @@
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,122 @@
.container {
min-height: 100vh;
padding: 0 0.5rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.main {
padding: 5rem 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.footer {
width: 100%;
height: 100px;
border-top: 1px solid #eaeaea;
display: flex;
justify-content: center;
align-items: center;
}
.footer img {
margin-left: 0.5rem;
}
.footer a {
display: flex;
justify-content: center;
align-items: center;
}
.title a {
color: #0070f3;
text-decoration: none;
}
.title a:hover,
.title a:focus,
.title a:active {
text-decoration: underline;
}
.title {
margin: 0;
line-height: 1.15;
font-size: 4rem;
}
.title,
.description {
text-align: center;
}
.description {
line-height: 1.5;
font-size: 1.5rem;
}
.code {
background: #fafafa;
border-radius: 5px;
padding: 0.75rem;
font-size: 1.1rem;
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
Bitstream Vera Sans Mono, Courier New, monospace;
}
.grid {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
max-width: 800px;
margin-top: 3rem;
}
.card {
margin: 1rem;
flex-basis: 45%;
padding: 1.5rem;
text-align: left;
color: inherit;
text-decoration: none;
border: 1px solid #eaeaea;
border-radius: 10px;
transition: color 0.15s ease, border-color 0.15s ease;
}
.card:hover,
.card:focus,
.card:active {
color: #0070f3;
border-color: #0070f3;
}
.card h3 {
margin: 0 0 1rem 0;
font-size: 1.5rem;
}
.card p {
margin: 0;
font-size: 1.25rem;
line-height: 1.5;
}
.logo {
height: 1em;
}
@media (max-width: 600px) {
.grid {
width: 100%;
flex-direction: column;
}
}

16
front/styles/globals.css Normal file
View file

@ -0,0 +1,16 @@
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
* {
box-sizing: border-box;
}

19
front/tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "commonjs",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}