diff --git a/uno/.vscode/launch.json b/uno/.vscode/launch.json new file mode 100644 index 0000000..8aca536 --- /dev/null +++ b/uno/.vscode/launch.json @@ -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" + }, + ] +} \ No newline at end of file diff --git a/uno/.vscode/settings.json b/uno/.vscode/settings.json new file mode 100644 index 0000000..05cb3bf --- /dev/null +++ b/uno/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "liveServer.settings.multiRootWorkspaceName": "uno" +} \ No newline at end of file diff --git a/uno/client/Card.js b/uno/client/Card.js new file mode 100644 index 0000000..f66a9a0 --- /dev/null +++ b/uno/client/Card.js @@ -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(); + } + } +} \ No newline at end of file diff --git a/uno/client/CardDeck.js b/uno/client/CardDeck.js new file mode 100644 index 0000000..5951552 --- /dev/null +++ b/uno/client/CardDeck.js @@ -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); \ No newline at end of file diff --git a/uno/client/EventHandler.js b/uno/client/EventHandler.js new file mode 100644 index 0000000..a331397 --- /dev/null +++ b/uno/client/EventHandler.js @@ -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, + }); + } +} \ No newline at end of file diff --git a/uno/client/Game.js b/uno/client/Game.js new file mode 100644 index 0000000..a0ed0e4 --- /dev/null +++ b/uno/client/Game.js @@ -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(); + } + } +} \ No newline at end of file diff --git a/uno/client/Player.js b/uno/client/Player.js new file mode 100644 index 0000000..796f3e3 --- /dev/null +++ b/uno/client/Player.js @@ -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(); + } +} \ No newline at end of file diff --git a/uno/client/Stack.js b/uno/client/Stack.js new file mode 100644 index 0000000..64aa600 --- /dev/null +++ b/uno/client/Stack.js @@ -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); + } + } +} \ No newline at end of file diff --git a/uno/client/helpers.js b/uno/client/helpers.js new file mode 100644 index 0000000..bae6290 --- /dev/null +++ b/uno/client/helpers.js @@ -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, + }; +} \ No newline at end of file diff --git a/uno/client/index.html b/uno/client/index.html new file mode 100644 index 0000000..97196da --- /dev/null +++ b/uno/client/index.html @@ -0,0 +1,39 @@ + + + + + + + UNO + + + + +
+
+

ETT.IO

+ + + +
+
+ + + + + + + + + \ No newline at end of file diff --git a/uno/client/menu.js b/uno/client/menu.js new file mode 100644 index 0000000..c972a1e --- /dev/null +++ b/uno/client/menu.js @@ -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'); +} \ No newline at end of file diff --git a/uno/client/render.js b/uno/client/render.js new file mode 100644 index 0000000..140ac7a --- /dev/null +++ b/uno/client/render.js @@ -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) +} \ No newline at end of file diff --git a/uno/client/socket.js b/uno/client/socket.js new file mode 100644 index 0000000..a8130a8 --- /dev/null +++ b/uno/client/socket.js @@ -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); + } +} \ No newline at end of file diff --git a/uno/client/styles.css b/uno/client/styles.css new file mode 100644 index 0000000..7bcb38d --- /dev/null +++ b/uno/client/styles.css @@ -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; +} \ No newline at end of file diff --git a/uno/client/uno.js b/uno/client/uno.js new file mode 100644 index 0000000..6a39bcd --- /dev/null +++ b/uno/client/uno.js @@ -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))); + }, + }) +} \ No newline at end of file diff --git a/uno/server/__debug_bin b/uno/server/__debug_bin new file mode 100644 index 0000000..8457719 Binary files /dev/null and b/uno/server/__debug_bin differ diff --git a/uno/server/go.mod b/uno/server/go.mod new file mode 100644 index 0000000..5359ce6 --- /dev/null +++ b/uno/server/go.mod @@ -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 +) diff --git a/uno/server/go.sum b/uno/server/go.sum new file mode 100644 index 0000000..d8bf7d2 --- /dev/null +++ b/uno/server/go.sum @@ -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= diff --git a/uno/server/helpers.go b/uno/server/helpers.go new file mode 100644 index 0000000..e497a4b --- /dev/null +++ b/uno/server/helpers.go @@ -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, + }) +} diff --git a/uno/server/main.go b/uno/server/main.go new file mode 100644 index 0000000..1246129 --- /dev/null +++ b/uno/server/main.go @@ -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) + } +} diff --git a/uno/server/models.go b/uno/server/models.go new file mode 100644 index 0000000..1f09df0 --- /dev/null +++ b/uno/server/models.go @@ -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 +}