Initial commit

This commit is contained in:
KingOfDog 2020-05-05 22:35:48 +02:00
parent 3c4ec2f6dc
commit 9922e5470c
21 changed files with 2019 additions and 0 deletions

22
uno/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,22 @@
{
"version": "0.2.0",
"configurations": [{
"name": "Launch Go",
"type": "go",
"request": "launch",
"mode": "remote",
"remotePath": "",
"port": 2345,
"host": "127.0.0.1",
"program": "${workspaceFolder}/server",
"showLog": true
},
{
"name": "Launch index.html",
"type": "firefox",
"request": "launch",
"reAttach": true,
"file": "${workspaceFolder}/client/index.html"
},
]
}

3
uno/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"liveServer.settings.multiRootWorkspaceName": "uno"
}

42
uno/client/Card.js Normal file
View File

@ -0,0 +1,42 @@
export class Card {
constructor(color, value, turnedUp) {
this.color = color;
this.value = value;
this.turnedUp = turnedUp;
}
allows(card) {
return this.color === card.color || this.value === card.value;
}
/**
* Draws the card
* @param {Renderer} renderer
*/
draw(renderer, x = 0, y = 0) {
const cardBody = renderer.begin().roundedRect(x, y, 100, 150, 10);
if (this.turnedUp) {
cardBody.fill(this.color).stroke(255).strokeWidth(5).close();
renderer.begin().center(x, y, 100, 150).circle(0, 0, 40).fill(255).close();
switch (this.value) {
case "CHOOSE":
if (!this.actualColor) {
renderer.begin().center(x, y, 100, 150).arc(0, 0, 35, 0, 0.5 * Math.PI).finish().fill('RED').close();
renderer.begin().center(x, y, 100, 150).arc(0, 0, 35, 0.5 * Math.PI, Math.PI).finish().fill('GREEN').close();
renderer.begin().center(x, y, 100, 150).arc(0, 0, 35, Math.PI, 1.5 * Math.PI).finish().fill('YELLOW').close();
renderer.begin().center(x, y, 100, 150).arc(0, 0, 35, 1.5 * Math.PI, 2 * Math.PI).finish().fill('BLUE').close();
} else {
renderer.begin().center(x, y, 100, 150).circle(0, 0, 35).fill(this.actualColor).close();
}
break;
default:
renderer.begin().center(x, y, 100, 150).text(this.value[0]).align('center').fontSize(48).baseline('middle').fill(0).close();
break;
}
} else {
const temp = cardBody.offset.x;
cardBody.fill(150, 0, 255).stroke(255).strokeWidth(5).close();
}
}
}

30
uno/client/CardDeck.js Normal file
View File

@ -0,0 +1,30 @@
import {
Card
} from "./Card.js";
export class CardDeck {
constructor() {
this.cards = [];
}
/**
* Returns a random card of this deck
* @returns {Card}
*/
getRandomCard() {
return this.cards[Math.floor(Math.random() * this.cards.length)];
}
}
export const cardDecks = [];
// Default card deck
const defaultDeck = new CardDeck();
const colors = ['RED', 'GREEN', 'BLUE', 'YELLOW'];
colors.forEach(color => {
for (let i = 0; i < 10; i++) {
defaultDeck.cards.push(new Card(color, i));
}
});
cardDecks.push(defaultDeck);

View File

@ -0,0 +1,66 @@
import {
isInRegion,
getRelativeRegionCoordinates,
getAbsoluteRegionCoordinates
} from "./helpers.js";
export class EventHandler {
constructor(element) {
this.element = element;
this.listeners = [];
this.element.addEventListener('mousemove', e => {
const {
x,
y,
relX,
relY,
} = this.getCoordinates(e.clientX, e.clientY);
this.listeners.filter(l => l.event === 'hover' && isInRegion(relX, relY, l.region)).forEach(l => {
const coords = getAbsoluteRegionCoordinates(x, y, l.region, this.element.width, this.element.height);
const relCoords = getRelativeRegionCoordinates(relX, relY, l.region);
const event = {
x: coords.x,
y: coords.y,
relX: relCoords.x,
relY: relCoords.y,
event: e,
};
l.callback(event);
});
});
this.element.addEventListener('click', e => {
const event = {
x: e.clientX - this.element.offsetLeft,
y: e.clientY - this.element.offsetTop,
event: e,
};
this.listeners.filter(l => l.event === 'click').forEach(listener => listener.callback(event));
});
}
getCoordinates(x, y) {
return {
x: x - this.element.offsetLeft,
y: y - this.element.offsetTop,
relX: (x - this.element.offsetLeft) / this.element.clientWidth,
relY: (y - this.element.offsetTop) / this.element.clientHeight,
};
}
on(type, callback, x = 0, y = 0, width = 1, height = 1) {
this.listeners.push({
event: type,
region: {
x,
y,
width,
height
},
callback: callback,
});
}
}

206
uno/client/Game.js Normal file
View File

@ -0,0 +1,206 @@
import {
cardDecks
} from "./CardDeck.js";
import {
Player
} from "./Player.js";
import {
Stack
} from "./Stack.js";
import {
SocketHandler
} from "./socket.js";
import {
Card
} from "./Card.js";
export class Game {
constructor() {
this.players = [];
this.player = new Player(this);
this.players.push(this.player);
this.activePlayerID = null;
this.players[0].isTurn = true;
this.clockwise = true;
this.deck = cardDecks[0];
this.drawingStack = new Stack();
this.playingStack = new Stack();
this.config = {
startingCards: 10,
};
this.eventHandlers = [];
this.eventListeners = [];
this.arrowRotation = 0;
this.choosingColor = false;
}
init() {
this.player.init();
for (let i = 0; i < 5; i++) {
this.drawingStack.add(new Card("", "", false));
}
this.registerListener('click', (data) => {
if (this.choosingColor) {
return;
}
console.log(data);
const minX = this.renderer.width / 2 - 330;
const maxX = minX + 130;
const minY = this.renderer.height / 2 - 330;
const maxY = minY + 180;
if (data.x > minX && data.x < maxX && data.y > minY && data.y < maxX) {
console.log('found');
this.onCardDrawn();
}
});
this.registerListener('click', (data) => {
if (!this.choosingColor) {
return;
}
const centerX = this.renderer.width / 2;
const centerY = this.renderer.height / 2;
const dx = data.x - centerX;
const dy = data.y - centerY;
const d = Math.sqrt(dx ** 2, dy ** 2)
if (d <= 125) {
if (dx > 0 && dy > 0) {
this.onColorSelected('RED');
} else if (dx > 0 && dy < 0) {
this.onColorSelected('BLUE');
} else if (dx < 0 && dy > 0) {
this.onColorSelected('GREEN');
} else {
this.onColorSelected('YELLOW');
}
}
})
}
registerEventHandler(eventHandler) {
this.eventHandlers.push(eventHandler);
this.eventListeners.forEach(listener => eventHandler.on(...listener));
}
registerListener(type, eventListener, x = 0, y = 0, width = 1, height = 1) {
this.eventListeners.push([
type,
eventListener,
x,
y,
width,
height
]);
this.eventHandlers.forEach(handler => handler.on(type, eventListener), x, y, width, height);
}
onCardPlayed() {}
onCardDrawn() {}
onColorSelected(color) {}
playerJoined(player, cardCount) {
this.players.push(player);
for (let i = 0; i < cardCount; i++) {
player.hand.push(new Card("", "", false));
}
}
playCard(card) {
if (this.activePlayerID === this.player.id) {
this.onCardPlayed(card);
}
}
draw(renderer) {
// Drawing main stack
renderer.push();
renderer.anchor('center', 'center');
this.playingStack.draw(renderer);
renderer.pop();
// Drawing direction
renderer.push();
renderer.anchor('center', 'center');
renderer.rotate(this.arrowRotation);
this.renderer.begin().arc(0, 0, 200, 0, 0.8 * Math.PI).stroke(0).strokeWidth(20).close();
this.renderer.begin().arc(0, 0, 200, Math.PI, 1.8 * Math.PI).stroke(0).strokeWidth(20).close();
if (this.clockwise) {
this.renderer.begin().rotate(0.8, 'RADIANS').translate(200, 0).triangle(-30, 0, 60, 60).fill(0).close();
this.renderer.begin().rotate(1.8, 'RADIANS').translate(200, 0).triangle(-30, 0, 60, 60).fill(0).close();
this.arrowRotation++;
} else {
this.renderer.begin().rotate(0, 'RADIANS').translate(200, 0).triangle(-30, 0, 60, -60).fill(0).close();
this.renderer.begin().rotate(1, 'RADIANS').translate(200, 0).triangle(-30, 0, 60, -60).fill(0).close();
this.arrowRotation--;
}
if (this.arrowRotation > 360) {
this.arrowRotation -= 360;
} else if (this.arrowRotation < 0) {
this.arrowRotation += 360;
}
renderer.pop();
// Drawing backup stack
renderer.push();
renderer.anchor('center', 'center');
renderer.translate(-250, -250);
this.drawingStack.draw(renderer);
renderer.pop();
// Drawing color selection
if (this.choosingColor) {
renderer.push();
renderer.anchor('center', 'center');
renderer.begin().arc(0, 0, 125, 0, 0.5 * Math.PI).finish().fill('RED').close();
renderer.begin().arc(0, 0, 125, 0.5 * Math.PI, Math.PI).finish().fill('GREEN').close();
renderer.begin().arc(0, 0, 125, Math.PI, 1.5 * Math.PI).finish().fill('YELLOW').close();
renderer.begin().arc(0, 0, 125, 1.5 * Math.PI, 2 * Math.PI).finish().fill('BLUE').close();
renderer.pop();
}
// Drawing own hand
renderer.push();
renderer.anchor('center', 'bottom');
if (this.activePlayerID === this.player.id) {
renderer.begin().text("Your turn", 0, -200).fill(0).close();
}
this.player.draw(renderer);
renderer.pop();
// Drawing other players
if (this.players.length > 1) {
renderer.push();
renderer.anchor('right', 'center');
renderer.rotate(-90);
this.players[1].draw(renderer);
renderer.pop();
}
if (this.players.length > 2) {
renderer.push();
renderer.anchor('center', 'top');
renderer.rotate(-180);
renderer.begin().roundedRect(0, 0, 100, 150, 10).fill(0).close();
this.players[2].draw(renderer);
renderer.pop();
}
if (this.players.length > 3) {
renderer.push();
renderer.anchor('left', 'center');
renderer.rotate(90);
renderer.begin().roundedRect(0, 0, 100, 150, 10).fill(0).close();
this.players[3].draw(renderer);
renderer.pop();
}
}
}

90
uno/client/Player.js Normal file
View File

@ -0,0 +1,90 @@
export class Player {
constructor(game, id, name) {
this.game = game;
this.id = id;
this.name = name;
this.hand = [];
this.isTurn = false;
this.highlightedCard = null;
this.cardArea = {
currentWidth: 0.5,
allowedWidth: 0.5,
currentCard: null,
cardWidth: 0.1,
};
}
init() {
this.registerEventListeners();
}
registerEventListeners() {
this.game.registerListener('hover', event => {
const width = this.cardArea.currentWidth;
const left = 0.5 - width / 2;
const right = left + width - this.cardArea.cardWidth;
this.highlightedCard = Math.min(Math.max(Math.floor((event.relX - left) / (right - left) * this.hand.length), 0), this.hand.length - 1);
}, 0, 0.8, 1, 0.2);
this.game.registerListener('click', event => {
console.log('clicked card');
if (!this.isTurn) {
return;
}
if (this.highlightedCard !== null) {
this.playCard(this.highlightedCard);
}
});
}
addCard(card) {
this.hand.push(card);
}
setHand(cards) {
this.hand = cards
.sort((a, b) => a.value.localeCompare(b.value))
.sort((a, b) => a.color.localeCompare(b.color));
}
playCard(index) {
console.log('played', this.name);
const card = this.hand[index];
this.game.playCard(card);
}
getCardOffset() {
const width = 800;
const offset = Math.min((width - 100) / this.hand.length, 60);
return offset;
}
getWidth(offset) {
return offset * (this.hand.length - 1) + 100;
}
draw(renderer) {
const offset = this.getCardOffset();
const width = this.getWidth(offset);
let x = -width / 2;
this.cardArea.currentWidth = width / this.game.renderer.width;
this.cardArea.cardWidth = 100 / this.game.renderer.width;
this.hand.forEach((card, index) => {
if (this.highlightedCard === index) {
this.cardArea.currentCard = x / this.game.renderer.width;
card.draw(renderer, x);
x += 100;
} else {
card.draw(renderer, x);
x += offset;
}
});
renderer.pop();
}
}

31
uno/client/Stack.js Normal file
View File

@ -0,0 +1,31 @@
export class Stack {
constructor() {
this.cards = [];
}
allows(card) {
return card.color === 'BLACK' || this.cards.length === 0 || this.cards[this.cards.length - 1].allows(card);
}
add(card) {
this.cards.push(card);
if (this.cards.length > 5) {
this.cards.splice(0, 1);
}
return true;
}
setTopColor(color) {
this.cards[this.cards.length - 1].actualColor = color;
}
draw(renderer) {
if (this.cards.length === 0) {
return;
}
for (let i = 0; i < 5 && i < this.cards.length; i++) {
this.cards[i].draw(renderer, -i * 6, -i * 6);
}
}
}

18
uno/client/helpers.js Normal file
View File

@ -0,0 +1,18 @@
export function isInRegion(x, y, region) {
return x >= region.x && x <= region.x + region.width &&
y >= region.y && y <= region.y + region.height;
}
export function getRelativeRegionCoordinates(x, y, region) {
return {
x: (x - region.x) / region.width,
y: (y - region.y) / region.height,
};
}
export function getAbsoluteRegionCoordinates(x, y, region, width, height) {
return {
x: 0,
y: 0,
};
}

39
uno/client/index.html Normal file
View File

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UNO</title>
<link rel="stylesheet" href="styles.css" type="text/css">
</head>
<body>
<div class="dialog-container title-screen">
<div class="container">
<h1 class="title margin-bottom">ETT.IO</h1>
<button class="btn primary" id="btnCreateGame">Create New Game</button>
<button class="btn primary margin-bottom" id="btnJoinGame">Join Game</button>
<button class="btn">Credits</button>
</div>
</div>
<div class="dialog-container dialog hidden" id="dialogJoinGame">
<div class="container">
<div class="dialog-content">
<p>Enter a game code:</p>
<input type="text" id="inputGameCode">
<div class="dialog-footer">
<button class="btn primary" id="btnJoinGameConfirm">Join Game</button>
</div>
</div>
</div>
</div>
<canvas id="canvas"></canvas>
<script type="module" src="menu.js"></script>
</body>
</html>

36
uno/client/menu.js Normal file
View File

@ -0,0 +1,36 @@
import {
triggerCreateGame,
triggerJoinGame,
} from './uno.js';
const btnCreateGame = document.getElementById('btnCreateGame');
const btnJoinGame = document.getElementById('btnJoinGame');
const btnJoinGameConfirm = document.getElementById('btnJoinGameConfirm');
const inputGameCode = document.getElementById('inputGameCode');
btnCreateGame.addEventListener('click', () => {
triggerCreateGame();
fadeOutTitleScreen();
});
btnJoinGame.addEventListener('click', () => {
showJoinDialog();
});
btnJoinGameConfirm.addEventListener('click', () => {
const gameCode = inputGameCode.value;
triggerJoinGame(gameCode);
fadeOutTitleScreen();
closeJoinDialog();
})
function showJoinDialog() {
document.getElementById('dialogJoinGame').classList.remove('hidden');
}
function closeJoinDialog() {
document.getElementById('dialogJoinGame').classList.add('hidden');
}
function fadeOutTitleScreen() {
document.getElementsByClassName('title-screen')[0].classList.add('hidden');
}

574
uno/client/render.js Normal file
View File

@ -0,0 +1,574 @@
export class Renderer {
constructor(canvas, game) {
this.game = game;
game.renderer = this;
this.canvas = canvas;
this.ctx = this.canvas.getContext('2d');
this.width = canvas.width;
this.height = canvas.height;
this.anchorOption = {
hor: 'left',
ver: 'top'
};
this.virtualWidth = 100;
this.virtualHeight = 100;
}
setSize(width, height) {
this.width = width;
this.height = height;
this.canvas.width = width;
this.canvas.height = height;
}
draw() {
this.ctx.clearRect(0, 0, this.width, this.height);
this.game.draw(this);
window.requestAnimationFrame(() => this.draw());
}
begin() {
const drawing = new Drawing(this);
drawing.anchor(this.anchorOption.hor, this.anchorOption.ver);
return drawing;
}
push() {
this.ctx.save();
}
pop() {
this.ctx.restore();
}
anchor(hor, ver) {
let x = 0;
let y = 0;
switch (hor) {
case 'center':
x = this.width / 2;
break;
case 'right':
x = this.width;
break;
}
switch (ver) {
case 'center':
y = this.height / 2;
break;
case 'bottom':
y = this.height;
}
this.translate(x, y);
this.anchorOption = {
hor,
ver
};
}
rotate(degrees) {
this.ctx.rotate(degrees * Math.PI / 180);
}
translate(x, y) {
this.ctx.translate(x, y);
}
scale(x, y) {
this.ctx.scale(x, y);
}
}
class Drawing {
constructor(renderer) {
this.renderer = renderer;
this.ctx = renderer.ctx;
this.ctx.save();
this.anchorPoint = {
x: 0,
y: 0,
};
this.offset = {
x: 0,
y: 0,
};
this.translation = {
x: 0,
y: 0,
};
this.rotation = 0;
this.fillColor = undefined;
this.strokeColor = undefined;
this.lineWidth = 1;
this.lineCap = 'butt';
this.lineJoin = 'miter';
this.type = undefined;
this.args = {};
this.textOptions = {
font: 'sans-serif',
fontSize: 16,
alignment: 'left',
baseline: 'alphabetic',
};
}
/*
Shapes
*/
arc(x, y, radius, startAngle, endAngle) {
this.type = 'arc';
this.args = {
x,
y,
radius,
startAngle,
endAngle
};
return this;
}
circle(x, y, radius) {
this.type = 'circle';
this.args = {
x,
y,
radius
};
return this;
}
rect(x, y, width, height) {
this.type = 'rect';
this.args = {
x,
y,
width,
height
};
return this;
}
roundedRect(x, y, width, height, roundness) {
this.type = 'roundedRect';
this.args = {
x,
y,
width,
height,
roundness
};
return this;
}
triangle(x, y, width, height) {
this.type = 'triangle';
this.args = {
x1: x,
y1: y,
x2: x + width,
y2: y,
x3: x + width / 2,
y3: y + height
};
return this;
}
/*
Path
*/
vertex(x, y) {
this.type = 'line';
if (!this.args.vertices) {
this.args.vertices = [];
}
this.args.vertices.push([x, y]);
return this;
}
finish() {
this.args.close = true;
return this;
}
/*
Text
*/
text(string, x = 0, y = 0) {
this.type = 'text';
this.args = {
x,
y,
string
};
return this;
}
/**
* Set text alignment
* @param {'start'|'end'|'left'|'center'|'right'} alignment
* @returns {this}
*/
align(alignment) {
this.textOptions.alignment = alignment;
return this;
}
/**
* Sets text baseline
* @param {'alphabetic'|'top'|'hanging'|'middle'|'ideographic'|'bottom'} base
* @return {Drawing}
*/
baseline(base) {
this.textOptions.baseline = base;
return this;
}
fontSize(fontSize) {
this.textOptions.fontSize = fontSize;
return this;
}
/*
Transformations
*/
anchor(hor, ver) {
switch (hor) {
case 'center':
this.anchorPoint.x = this.renderer.width / 2;
this.offset.x = -0.5;
break;
case 'right':
this.anchorPoint.x = this.renderer.width;
this.offset.x = -1;
break;
default:
this.anchorPoint.x = 0;
this.offset.x = 0;
break;
}
switch (ver) {
case 'center':
this.anchorPoint.y = this.renderer.height / 2;
this.offset.y = -0.5;
break;
case 'bottom':
this.anchorPoint.y = this.renderer.height;
this.offset.y = -1;
break;
default:
this.anchorPoint.y = 0;
this.offset.y = 0;
break;
}
return this;
}
rotate(amount, mode = 'DEGREES') {
if (mode === 'DEGREES') {
amount *= 180;
}
this.rotation += amount * Math.PI;
return this;
}
translate(x, y) {
this.translation.x += x;
this.translation.y += y;
return this;
}
/**
* Translate to center of rectangle
* @param {number} x x position
* @param {number} y y position
* @param {number} width width
* @param {number} height height
* @returns {this}
*/
center(x, y, width, height) {
this.translation.x += x + this.offset.x * width + width / 2;
this.translation.y += y + this.offset.y * height + height / 2;
return this;
}
/*
Colors
*/
fill(...color) {
this.fillColor = parseColor('rgb', ...color);
return this;
}
stroke(...color) {
this.strokeColor = parseColor('rgb', ...color);
return this;
}
/**
* Sets line width for strokes
* @param {number} width
*/
strokeWidth(width) {
this.lineWidth = width;
return this;
}
/**
* Sets line cap for strokes
* @param {'butt'|'round'|'square'} style
*/
strokeCap(style) {
this.lineCap = style;
return this;
}
calcCoords(x, y, width, height) {
return [
x + this.offset.x * width,
y + this.offset.y * height,
];
}
close() {
if (this.fillColor) {
this.ctx.fillStyle = this.fillColor;
}
if (this.strokeColor) {
this.ctx.strokeStyle = this.strokeColor;
this.ctx.lineWidth = this.lineWidth;
this.ctx.lineCap = this.lineCap;
}
this.ctx.rotate(this.rotation);
this.ctx.translate(this.translation.x, this.translation.y);
switch (this.type) {
case 'arc':
drawArc(this.ctx, this.args, !!this.fillColor, !!this.strokeColor);
break;
case 'circle':
drawCircle(this.ctx, this.args, !!this.fillColor, !!this.strokeColor);
break;
case 'line':
drawShape(this.ctx, this.args.close, this.args.vertices, !!this.fillColor, !!this.strokeColor);
break;
case 'rect':
/*if (this.fillColor) {
this.ctx.fillRect(...this.args);
}
if (this.strokeColor) {
this.ctx.strokeRect(...this.args);
}*/
break;
case 'roundedRect':
this.args.x = this.calcCoords(this.args.x, 0, this.args.width, 0)[0];
this.args.y = this.calcCoords(0, this.args.y, 0, this.args.height)[1];
drawRoundedRect(this.ctx, this.args, !!this.fillColor, !!this.strokeColor);
break;
case 'shape':
console.log('test');
drawShape(this.ctx, true, this.args, !!this.fillColor, !!this.strokeColor);
break;
case 'text':
this.ctx.font = this.textOptions.fontSize + 'px ' + this.textOptions.font;
this.args.x = this.calcCoords(this.args.x, 0, 0)[0];
this.args.y = this.calcCoords(0, this.args.y, 0, 0)[1];
drawText(this.ctx, this.args, !!this.fillColor, !!this.strokeColor, this.textOptions);
break;
case 'triangle':
drawTriangle(this.ctx, this.args, !!this.fillColor, !!this.strokeColor);
break;
}
this.ctx.restore();
}
}
function parseColor(colorMode, ...color) {
if (color.length === 0) {
return '#000000';
}
if (color.length === 1 && typeof color[0] === 'string') {
return color[0];
}
if (color.length === 1 && typeof color[0] === 'number') {
color.push(color[0]);
}
if (color.length === 2 && typeof color[0] === 'number') {
color.push(color[0]);
}
if (color.length === 3) {
return `${colorMode}(${color[0]}, ${color[1]}, ${color[2]})`;
}
return '#000000';
}
function drawArc(ctx, args, fill, stroke) {
const {
x,
y,
radius,
startAngle,
endAngle,
close,
} = args;
ctx.beginPath();
ctx.arc(x, y, radius, startAngle, endAngle);
if (close) {
ctx.lineTo(x, y);
ctx.closePath();
}
if (fill) {
ctx.fill();
}
if (stroke) {
ctx.stroke();
}
}
function drawCircle(ctx, args, fill, stroke) {
const {
x,
y,
radius
} = args;
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI);
if (fill) {
ctx.fill();
}
if (stroke) {
ctx.stroke();
}
}
function drawShape(ctx, close, vertices, fill, stroke) {
ctx.beginPath();
ctx.moveTo(...vertices[0]);
for (let i = 1; i < vertices.length; i++) {
ctx.lineTo(...vertices[i]);
}
if (close) {
ctx.closePath();
}
if (fill) {
ctx.fill();
}
if (stroke) {
ctx.stroke();
}
}
function drawRoundedRect(ctx, args, fill, stroke) {
const {
x,
y,
width,
height,
roundness
} = args;
ctx.beginPath();
ctx.moveTo(x + roundness, y);
ctx.arc(x + width - roundness, y + roundness, roundness, -0.5 * Math.PI, 0);
ctx.arc(x + width - roundness, y + height - roundness, roundness, 0, 0.5 * Math.PI);
ctx.arc(x + roundness, y + height - roundness, roundness, 0.5 * Math.PI, Math.PI);
ctx.arc(x + roundness, y + roundness, roundness, Math.PI, 1.5 * Math.PI);
if (fill) {
ctx.fill();
}
if (stroke) {
ctx.stroke();
}
}
function drawText(ctx, args, fill, stroke, options) {
const {
x,
y,
string
} = args;
ctx.font = options.fontSize + 'px ' + options.font;
ctx.textAlign = options.alignment;
ctx.textBaseline = options.baseline;
if (fill) {
ctx.fillText(string, x, y);
}
if (stroke) {
ctx.strokeText(string, x, y);
}
}
function drawTriangle(ctx, args, fill, stroke) {
const {
x1,
y1,
x2,
y2,
x3,
y3
} = args;
drawShape(ctx, true, [
[x1, y1],
[x2, y2],
[x3, y3]
], fill, stroke)
}

104
uno/client/socket.js Normal file
View File

@ -0,0 +1,104 @@
export class SocketHandler {
constructor(url) {
this.url = url;
this.socket = new WebSocket(url);
this.socket.addEventListener('open', (e) => {
this.callbacks.open.forEach(cb => cb(e));
})
this.socket.addEventListener('message', (e) => {
const data = JSON.parse(e.data);
this.callbacks.message.forEach((listener, index) => {
if (listener.type && listener.type !== data.type) {
return;
}
listener.cb(data.type, data.data, e);
if (listener.once) {
this.callbacks.message.splice(index, 1);
}
});
});
this.callbacks = {
open: [],
message: [],
close: [],
};
this.initialized = false;
this.gameToken = null;
}
async initGame() {
this.initialized = false;
this.gameToken = null;
return this.sendAndWaitForResult('game.init', {
playerCount: 4,
});
}
async joinGame(gameId) {
this.initialized = false;
this.gameToken = null;
return this.sendAndWaitForResult('game.join', {
gameID: gameId,
});
}
playCard(card) {
if (!this.initialized) {
return;
}
this.send('card.play', {
color: card.color,
value: card.value,
});
}
drawCard() {
if (!this.initialized) {
return;
}
this.send('card.draw', {})
}
selectColor(color) {
if (!this.initialized) {
return;
}
this.send('card.color', {
color
});
}
send(type, data) {
this.socket.send(JSON.stringify({
type,
data,
}));
}
async sendAndWaitForResult(type, data) {
return new Promise((resolve, reject) => {
this.send(type, data);
this.callbacks.message.push({
type: type + '.result',
once: true,
cb: (type, data) => {
resolve(data);
},
});
});
}
onOpen(callback) {
this.callbacks.open.push(callback);
}
onMessage(callback) {
this.callbacks.message.push(callback);
}
}

124
uno/client/styles.css Normal file
View File

@ -0,0 +1,124 @@
* {
font-family: Roboto;
--color-red: #f92f2f;
--red-dark: #d01313;
--color-orange: #e07535;
--orange-dark: #bd5b20;
}
body {
margin: 0;
padding: 0;
overflow: hidden;
}
.dialog-container {
width: 100%;
height: 100%;
position: absolute;
display: flex;
justify-content: center;
align-items: center;
top: 0;
left: 0;
}
.dialog-container.hidden {
background: transparent;
pointer-events: none;
}
.dialog-container .container {
transition: transform 200ms, opacity 200ms;
}
.dialog-container.hidden .container {
transform: translateY(-100%);
opacity: 0;
pointer-events: none;
}
.container {
width: 250px;
display: flex;
flex-direction: column;
}
.dialog {
background: rgba(0, 0, 0, .75);
}
.dialog .container {
width: 400px;
}
.dialog-content {
width: 100%;
height: 100%;
border-radius: 10px;
background: #fff;
padding: 16px;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.dialog-footer {
display: flex;
flex-direction: row-reverse;
}
.title-screen {
background: var(--color-red) radial-gradient(var(--color-orange), var(--color-red));
transition: background-color 200ms;
}
.btn {
padding: 16px 32px;
text-align: center;
background: var(--color-orange);
color: #ffffff;
border-radius: 10px;
border: 3px var(--orange-dark) solid;
outline: 0;
font-weight: bold;
font-size: 16px;
cursor: pointer;
margin-bottom: 16px;
transition: transform 200ms, box-shadow 200ms;
text-shadow: 3px
}
.btn:hover {
transform: scale(1.1);
}
.btn:active {
transform: scale(0.9);
}
input {
border: 3px rgba(0, 0, 0, .15) solid;
border-radius: 10px;
margin-bottom: 16px;
font-size: 16px;
padding: 8px 16px;
}
.primary {
background: var(--color-red);
color: #ffffff;
border-color: var(--red-dark);
}
.title {
font-size: 4em;
text-align: center;
color: #fff;
border: 5px #fff solid;
}
.margin-bottom {
margin-bottom: 32px;
}

137
uno/client/uno.js Normal file
View File

@ -0,0 +1,137 @@
import {
Renderer
} from './render.js';
import {
EventHandler
} from './EventHandler.js';
import {
SocketHandler
} from './socket.js';
import {
Game
} from './Game.js';
import {
Card
} from './Card.js';
import {
Player
} from './Player.js';
const socketHandler = new SocketHandler('ws://localhost:8000');
socketHandler.onMessage({
cb: (type, data, e) => {
console.log(type, data, e);
}
});
export function triggerCreateGame() {
socketHandler.initGame().then(result => {
createGame(result);
});
}
export function triggerJoinGame(id) {
socketHandler.joinGame(id).then(result => {
createGame(result);
});
}
function createGame(data) {
socketHandler.initialized = true;
socketHandler.gameToken = data.gameID;
const game = new Game();
game.player.id = data.playerID;
game.activePlayerID = data.activePlayerID;
game.onCardPlayed = (card) => {
console.log(card);
socketHandler.playCard(card);
};
game.onCardDrawn = () => {
socketHandler.drawCard();
};
game.onColorSelected = (color) => {
socketHandler.selectColor(color);
};
if (data.players) {
data.players.forEach(player => game.playerJoined(new Player(game, player.playerID, player.playerName), player.cardCount));
}
game.init();
console.log(game);
game.player.setHand(data.hand.map(card => new Card(card.Color, card.Value, true)));
data.playingStack.forEach(card => {
game.playingStack.cards.unshift(new Card(card.Color, card.Value, true));
})
const canvas = document.getElementById('canvas');
const renderer = new Renderer(canvas, game);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.draw();
window.addEventListener('resize', () => {
console.log('test');
renderer.setSize(window.innerWidth, window.innerHeight);
});
const eventHandler = new EventHandler(canvas);
game.registerEventHandler(eventHandler);
socketHandler.onMessage({
type: 'game.joined',
cb: (type, data) => {
game.playerJoined(new Player(game, data.playerID, data.playerName), data.cardCount);
},
})
socketHandler.onMessage({
type: 'card.played',
cb: (type, data) => {
console.log(data);
game.playingStack.add(new Card(data.card.Color, data.card.Value, true));
},
});
socketHandler.onMessage({
type: 'card.color',
cb: (type, data) => {
console.log(data);
game.playingStack.setTopColor(data.color);
},
});
socketHandler.onMessage({
type: 'card.drawn',
cb: (type, data) => {
const player = game.players.find(p => p.id === data.playerID)
for (let i = 0; i < data.count; i++) {
player.addCard(new Card("", "", false));
}
},
});
socketHandler.onMessage({
type: 'color.choose',
cb: (type, data) => {
game.choosingColor = true;
},
});
socketHandler.onMessage({
type: 'turn.completed',
cb: (type, data) => {
game.choosingColor = false;
game.activePlayerID = data.activePlayerID;
game.clockwise = data.directionClockwise;
},
});
socketHandler.onMessage({
type: 'player.hand',
cb: (type, data) => {
console.log(data);
game.player.setHand(data.cards.map(card => new Card(card.Color, card.Value, true)));
},
})
}

BIN
uno/server/__debug_bin Normal file

Binary file not shown.

8
uno/server/go.mod Normal file
View File

@ -0,0 +1,8 @@
module kingofdog.de/projects/uno
go 1.14
require (
github.com/google/uuid v1.1.1
github.com/gorilla/websocket v1.4.2
)

4
uno/server/go.sum Normal file
View File

@ -0,0 +1,4 @@
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

87
uno/server/helpers.go Normal file
View File

@ -0,0 +1,87 @@
package main
import (
"math/rand"
"strconv"
)
func GetDefaultDeck() []Card {
cards := make([]Card, 0)
for _, color := range []string{"GREEN", "BLUE", "RED", "YELLOW"} {
for i := 0; i < 10; i++ {
cards = append(cards, Card{
color, strconv.Itoa(i),
})
}
cards = append(cards, Card{
color, "RETURN",
})
cards = append(cards, Card{
color, "BLOCK",
})
cards = append(cards, Card{
color, "DRAW2",
})
}
cards = append(cards, Card{
"BLACK", "CHOOSE",
})
cards = append(cards, Card{
"BLACK", "DRAW4",
})
return cards
}
func GetGame(id string) *Game {
return games[id]
}
func GetRandomCard(cards []Card) *Card {
index := rand.Intn(len(cards))
return &cards[index]
}
func FindCard(cards []Card, color string, value string) (*Card, bool) {
for _, card := range cards {
if card.Color == color && card.Value == value {
return &card, true
}
}
return nil, false
}
func FindOnHand(player *Player, card *Card) int {
for index, handCard := range player.Hand {
if handCard.Color == card.Color && handCard.Value == card.Value {
return index
}
}
return -1
}
func RemoveFromHand(player *Player, index int) []*Card {
player.Hand[len(player.Hand)-1], player.Hand[index] = player.Hand[index], player.Hand[len(player.Hand)-1]
return player.Hand[:len(player.Hand)-1]
}
func InitPlayer(game *Game, player *Player) {
player.Game = game
for i := 0; i < 10; i++ {
player.Hand = append(player.Hand, GetRandomCard(game.AvailableCards))
}
}
func DrawNextPlayer(game *Game, count int) {
nextPlayer := game.GetNextPlayer()
for i := 0; i < count; i++ {
nextPlayer.Hand = append(nextPlayer.Hand, GetRandomCard(game.AvailableCards))
}
game.send("card.drawn", map[string]interface{}{
"playerID": nextPlayer.ID,
"count": count,
})
nextPlayer.send("player.hand", map[string]interface{}{
"cards": nextPlayer.Hand,
})
}

352
uno/server/main.go Normal file
View File

@ -0,0 +1,352 @@
package main
import (
"flag"
"log"
"net/http"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
var games = make(map[string]*Game, 0)
var players = make(map[string]*Player, 0)
var addr = flag.String("addr", ":8000", "http service address")
func (p *Player) send(messageType string, data map[string]interface{}) {
dataObject := DataObject{
Type: messageType,
Data: data,
}
p.Connection.WriteJSON(dataObject)
}
func (g *Game) send(messageType string, data map[string]interface{}) {
for _, player := range g.Players {
log.Println("sending message", player)
player.send(messageType, data)
}
}
func (g *Game) GetNextPlayer() *Player {
index := g.CurrentPlayerIndex
if g.DirectionClockwise {
index++
} else {
index--
}
if index < 0 {
index += len(g.Players)
} else if index >= len(g.Players) {
index -= len(g.Players)
}
return g.Players[index]
}
func (g *Game) nextTurn(block bool) {
count := 1
if block {
count++
}
if g.DirectionClockwise {
g.CurrentPlayerIndex += count
} else {
g.CurrentPlayerIndex -= count
}
if g.CurrentPlayerIndex < 0 {
g.CurrentPlayerIndex += len(g.Players)
} else if g.CurrentPlayerIndex >= len(g.Players) {
g.CurrentPlayerIndex -= len(g.Players)
}
g.send("game.nextTurn", map[string]interface{}{
"playerID": g.Players[g.CurrentPlayerIndex].ID,
})
}
func gameInitHandler(player *Player, data map[string]interface{}) {
game := Game{
//ID: uuid.New().String(),
ID: "1234",
MaxPlayers: 4,
DirectionClockwise: true,
AvailableCards: GetDefaultDeck(),
}
game.Players = append(game.Players, player)
game.PlayingStack.Cards = append(game.PlayingStack.Cards, GetRandomCard(game.AvailableCards))
game.CurrentPlayerIndex = 0
games[game.ID] = &game
InitPlayer(&game, player)
player.send("game.init.result", map[string]interface{}{
"gameID": game.ID,
"hand": player.Hand,
"playerID": player.ID,
"activePlayerID": game.Players[game.CurrentPlayerIndex].ID,
"playingStack": game.PlayingStack.Cards,
})
}
func joinGameHandler(player *Player, data map[string]interface{}) {
gameID, ok := data["gameID"]
if !ok {
log.Println("not found game id")
return
}
game, ok := games[gameID.(string)]
if !ok {
log.Println("game not found")
return
}
InitPlayer(game, player)
game.send("game.joined", map[string]interface{}{
"playerID": player.ID,
"playerName": player.Name,
"cardCount": len(player.Hand),
})
players := make([]struct {
PlayerID string `json:"playerID"`
PlayerName string `json:"playerName"`
CardCount int `json:"cardCount"`
}, len(game.Players))
for index, p := range game.Players {
players[index] = struct {
PlayerID string `json:"playerID"`
PlayerName string `json:"playerName"`
CardCount int `json:"cardCount"`
}{
PlayerID: p.ID,
PlayerName: p.Name,
CardCount: len(p.Hand),
}
}
game.Players = append(game.Players, player)
log.Println(game)
player.send("game.join.result", map[string]interface{}{
"hand": player.Hand,
"playerID": player.ID,
"activePlayerID": game.Players[game.CurrentPlayerIndex].ID,
"players": players,
"playingStack": game.PlayingStack.Cards,
})
}
func playCardHandler(player *Player, data map[string]interface{}) {
log.Println("card")
if player.Game == nil {
return
}
log.Println("color")
if player.Game.Players[player.Game.CurrentPlayerIndex].ID != player.ID {
return
}
if player.Game.ChoosingColor {
return
}
color, ok := data["color"]
if !ok {
return
}
value, ok := data["value"]
if !ok {
return
}
card, ok := FindCard(player.Game.AvailableCards, color.(string), value.(string))
if !ok {
return
}
handIndex := FindOnHand(player, card)
if handIndex == -1 {
return
}
topCard := player.Game.PlayingStack.Cards[len(player.Game.PlayingStack.Cards)-1]
log.Println(topCard)
if card.Color != "BLACK" && topCard.Value != card.Value && topCard.Color != card.Color {
return
}
player.Game.PlayingStack.Cards = append(player.Game.PlayingStack.Cards, card)
player.Hand = RemoveFromHand(player, handIndex)
if card.Value == "RETURN" {
player.Game.DirectionClockwise = !player.Game.DirectionClockwise
}
if card.Value == "DRAW2" {
DrawNextPlayer(player.Game, 2)
}
if card.Value == "DRAW4" {
DrawNextPlayer(player.Game, 4)
}
chooseColor := card.Value == "CHOOSE" || card.Value == "DRAW4"
if !chooseColor {
block := card.Value == "BLOCK" || card.Value == "DRAW2"
player.Game.nextTurn(block)
}
log.Println(card)
player.Game.send("card.played", map[string]interface{}{
"playerID": player.ID,
"card": &card,
})
player.send("player.hand", map[string]interface{}{
"cards": player.Hand,
})
if chooseColor {
player.Game.ChoosingColor = true
player.send("color.choose", map[string]interface{}{})
}
if !chooseColor {
player.Game.send("turn.completed", map[string]interface{}{
"activePlayerID": player.Game.Players[player.Game.CurrentPlayerIndex].ID,
"directionClockwise": player.Game.DirectionClockwise,
})
}
}
func chooseColorHandler(player *Player, data map[string]interface{}) {
if player.Game == nil {
return
}
if player.Game.Players[player.Game.CurrentPlayerIndex].ID != player.ID {
return
}
if !player.Game.ChoosingColor {
return
}
color := data["color"]
if color != "GREEN" && color != "BLUE" && color != "RED" && color != "YELLOW" {
return
}
topCard := player.Game.PlayingStack.Cards[len(player.Game.PlayingStack.Cards)-1]
topCard.Color = color.(string)
player.Game.ChoosingColor = false
player.Game.send("card.color", map[string]interface{}{
"color": color,
})
player.Game.nextTurn(topCard.Value == "DRAW4")
player.Game.send("turn.completed", map[string]interface{}{
"activePlayerID": player.Game.Players[player.Game.CurrentPlayerIndex].ID,
"directionClockwise": player.Game.DirectionClockwise,
})
}
func drawCardHandler(player *Player, data map[string]interface{}) {
if player.Game == nil {
return
}
if player.Game.Players[player.Game.CurrentPlayerIndex].ID != player.ID {
return
}
player.Hand = append(player.Hand, GetRandomCard(player.Game.AvailableCards))
player.Game.nextTurn(false)
log.Println(player.Hand)
player.Game.send("card.drawn", map[string]interface{}{
"playerID": player.ID,
"count": 1,
})
player.send("player.hand", map[string]interface{}{
"cards": player.Hand,
})
player.Game.send("turn.completed", map[string]interface{}{
"activePlayerID": player.Game.Players[player.Game.CurrentPlayerIndex].ID,
"directionClockwise": player.Game.DirectionClockwise,
})
}
func playerLogoutHandler(player *Player) {
delete(players, player.ID)
}
func ws(w http.ResponseWriter, r *http.Request) {
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
player := Player{
ID: uuid.New().String(),
Name: "Test Player",
Connection: conn,
}
players[player.ID] = &player
for {
var data DataObject
err := conn.ReadJSON(&data)
if err != nil {
if websocket.IsCloseError(err, 1001) {
log.Println("closed connection")
playerLogoutHandler(&player)
}
log.Printf("Failed to read message %v", err)
conn.Close()
return
}
log.Println(data)
switch data.Type {
case "game.init":
gameInitHandler(&player, data.Data)
log.Println("Initializing game")
case "game.join":
joinGameHandler(&player, data.Data)
case "card.play":
playCardHandler(&player, data.Data)
case "card.color":
chooseColorHandler(&player, data.Data)
case "card.draw":
drawCardHandler(&player, data.Data)
}
}
}
func main() {
flag.Parse()
http.HandleFunc("/", ws)
log.Println("listening")
err := http.ListenAndServe(*addr, nil)
if err != nil {
log.Fatal("listen and serve", err)
}
}

46
uno/server/models.go Normal file
View File

@ -0,0 +1,46 @@
package main
import (
"github.com/gorilla/websocket"
)
// DataObject is used for parsing and encoding the communication with WebSocket clients
type DataObject struct {
Type string `json:"type"`
Data map[string]interface{} `json:"data"`
}
// Game is a model for a game instance of UNO which can be joined by users
type Game struct {
ID string
MaxPlayers int
Players []*Player
DirectionClockwise bool
CurrentPlayerIndex int
ChoosingColor bool
DrawingStack Stack
PlayingStack Stack
AvailableCards []Card
}
// Player contains data about a connected player
type Player struct {
ID string
Name string
Connection *websocket.Conn
Game *Game
Hand []*Card
}
type Stack struct {
Cards []*Card
}
type Card struct {
Color string
Value string
}