Spaces:
Paused
Paused
| /** | |
| * UNO | |
| * Pokemon Showdown - http://pokemonshowdown.com/ | |
| * | |
| * This plugin allows rooms to run games of scripted UNO | |
| * | |
| * @license MIT license | |
| */ | |
| import { Utils } from '../../lib'; | |
| type Color = 'Green' | 'Yellow' | 'Red' | 'Blue' | 'Black'; | |
| interface Card { | |
| value: string; | |
| color: Color; | |
| changedColor?: Color; | |
| name: string; | |
| } | |
| const MAX_TIME = 60; // seconds | |
| const rgbGradients: { [k in Color]: string } = { | |
| Green: "rgba(0, 122, 0, 1), rgba(0, 185, 0, 0.9)", | |
| Yellow: "rgba(255, 225, 0, 1), rgba(255, 255, 85, 0.9)", | |
| Blue: "rgba(40, 40, 255, 1), rgba(125, 125, 255, 0.9)", | |
| Red: "rgba(255, 0, 0, 1), rgba(255, 125, 125, 0.9)", | |
| Black: "rgba(0, 0, 0, 1), rgba(0, 0, 0, 0.55)", | |
| }; | |
| const textColors: { [k in Color]: string } = { | |
| Green: "rgb(0, 128, 0)", | |
| Yellow: "rgb(175, 165, 40)", | |
| Blue: "rgb(75, 75, 255)", | |
| Red: "rgb(255, 0, 0)", | |
| Black: 'inherit', | |
| }; | |
| const textShadow = 'text-shadow: 1px 0px black, -1px 0px black, 0px -1px black, 0px 1px black, 2px -2px black;'; | |
| function cardHTML(card: Card, fullsize: boolean) { | |
| let surface = card.value.replace(/[^A-Z0-9+]/g, ""); | |
| const background = rgbGradients[card.color]; | |
| if (surface === 'R') surface = '<i class="fa fa-refresh" aria-hidden="true"></i>'; | |
| return `<button class="button" style="font-size: 14px; font-weight: bold; color: white; ${textShadow} padding-bottom: 117px; text-align: left; height: 135px; width: ${fullsize ? '72' : '37'}px; border-radius: 10px 2px 2px 3px; color: white; background: ${card.color}; background: -webkit-linear-gradient(${background}); background: -o-linear-gradient(${background}); background: -moz-linear-gradient(${background}); background: linear-gradient(${background})" name=send value="/uno play ${card.name}" aria-label="${card.name}">${surface}</button>`; | |
| } | |
| function createDeck() { | |
| const colors: Color[] = ['Red', 'Blue', 'Green', 'Yellow']; | |
| const values = ['1', '2', '3', '4', '5', '6', '7', '8', '9', 'Reverse', 'Skip', '+2']; | |
| const basic: Card[] = []; | |
| for (const color of colors) { | |
| basic.push(...values.map(v => { | |
| const c: Card = { value: v, color, name: `${color} ${v}` }; | |
| return c; | |
| })); | |
| } | |
| return [ | |
| // two copies of the basic stuff (total 96) | |
| ...basic, | |
| ...basic, | |
| // The four 0s | |
| ...[0, 1, 2, 3].map(v => { | |
| const c: Card = { color: colors[v], value: '0', name: `${colors[v]} 0` }; | |
| return c; | |
| }), | |
| // Wild cards | |
| ...[0, 1, 2, 3].map(v => { | |
| const c: Card = { color: 'Black', value: 'Wild', name: 'Wild' }; | |
| return c; | |
| }), | |
| // Wild +4 cards | |
| ...[0, 1, 2, 3].map(v => { | |
| const c: Card = { color: 'Black', value: '+4', name: 'Wild +4' }; | |
| return c; | |
| }), | |
| ]; // 108 cards | |
| } | |
| export class UNO extends Rooms.RoomGame<UNOPlayer> { | |
| override readonly gameid = 'uno' as ID; | |
| override title = 'UNO'; | |
| override readonly allowRenames = true; | |
| override timer: NodeJS.Timeout | null = null; | |
| maxTime = MAX_TIME; | |
| autostartTimer: NodeJS.Timeout | null = null; | |
| state: 'signups' | 'color' | 'play' | 'uno' = 'signups'; | |
| currentPlayer: UNOPlayer | null = null; | |
| deck: Card[] = Utils.shuffle(createDeck()); | |
| discards: Card[] = []; | |
| topCard: Card | null = null; | |
| awaitUnoPlayer: UNOPlayer | null = null; | |
| unoId: ID | null = null; | |
| direction: 1 | -1 = 1; | |
| suppressMessages: boolean; | |
| spectators: { [k: string]: number } = Object.create(null); | |
| isPlusFour = false; | |
| gameNumber: number; | |
| constructor(room: Room, cap: number, suppressMessages: boolean) { | |
| super(room); | |
| this.gameNumber = room.nextGameNumber(); | |
| this.playerCap = cap; | |
| this.suppressMessages = suppressMessages || false; | |
| this.sendToRoom(`|uhtml|uno-${this.gameNumber}|<div class="broadcast-blue"><p style="font-size: 14pt; text-align: center">A new game of <strong>UNO</strong> is starting!</p><p style="font-size: 9pt; text-align: center"><button class="button" name="send" value="/uno join"><strong>Join and play</strong></button> <button class="button" name="send" value="/uno spectate">Watch</button></p>${this.suppressMessages ? `<p style="font-size: 6pt; text-align: center">Game messages won't show up unless you're playing or watching.</p>` : ''}</div>`, true); | |
| } | |
| override onUpdateConnection() {} | |
| override onConnect(user: User, connection: Connection) { | |
| if (this.state === 'signups') { | |
| connection.sendTo( | |
| this.room, | |
| `|uhtml|uno-${this.gameNumber}|<div class="broadcast-blue">` + | |
| `<p style="font-size: 14pt; text-align: center">A new game of <strong>UNO</strong> is starting!</p>` + | |
| `<p style="font-size: 9pt; text-align: center"><button class="button" name="send" value="/uno join"><strong>Join and play</strong></button> ` + | |
| `<button class="button" name="send" value="/uno spectate">Watch</button></p>` + | |
| `${this.suppressMessages ? | |
| `<p style="font-size: 6pt; text-align: center">Game messages won't show up unless you're playing or watching.` : ''}</div>` | |
| ); | |
| } else if (this.onSendHand(user) === false) { | |
| connection.sendTo( | |
| this.room, | |
| `|uhtml|uno-${this.gameNumber}|<div class="infobox"><p>A game of UNO is currently in progress.<button class="button" name="send" value="/uno spectate">Spectate Game</button></p>` + | |
| `${this.suppressMessages ? `<p style="font-size: 6pt">Game messages won't show up unless you're playing or watching.` : ''}</div>` | |
| ); | |
| } | |
| } | |
| onStart(isAutostart?: boolean) { | |
| if (this.playerCount < 2) { | |
| if (isAutostart) { | |
| this.room.add("The game of UNO was forcibly ended because there aren't enough users."); | |
| this.destroy(); | |
| return false; | |
| } else { | |
| throw new Chat.ErrorMessage("There must be at least 2 players to start a game of UNO."); | |
| } | |
| } | |
| if (this.autostartTimer) clearTimeout(this.autostartTimer); | |
| this.sendToRoom(`|uhtmlchange|uno-${this.gameNumber}|<div class="infobox"><p>The game of UNO has started. <button class="button" name="send" value="/uno spectate">Spectate Game</button></p>${this.suppressMessages ? `<p style="font-size: 6pt">Game messages won't show up unless you're playing or watching.` : ''}</div>`, true); | |
| this.state = 'play'; | |
| this.onNextPlayer(); // determines the first player | |
| // give cards to the players | |
| for (const player of this.players) { | |
| player.hand.push(...this.drawCard(7)); | |
| } | |
| // top card of the deck. | |
| do { | |
| this.topCard = this.drawCard(1)[0]; | |
| this.discards.unshift(this.topCard); | |
| } while (this.topCard.color === 'Black'); | |
| this.sendToRoom(`|raw|The top card is <span style="font-weight:bold;color: ${textColors[this.topCard.color]}">${this.topCard.name}</span>.`); | |
| this.onRunEffect(this.topCard.value, true); | |
| this.nextTurn(true); | |
| } | |
| override joinGame(user: User) { | |
| if (user.id in this.playerTable) { | |
| throw new Chat.ErrorMessage("You have already joined the game of UNO."); | |
| } | |
| if (this.state === 'signups' && this.addPlayer(user)) { | |
| this.sendToRoom(`${user.name} has joined the game of UNO.`); | |
| return true; | |
| } | |
| return false; | |
| } | |
| override leaveGame(user: User) { | |
| if (!(user.id in this.playerTable)) return false; | |
| const player = this.playerTable[user.id]; | |
| if ((this.state === 'signups' && this.removePlayer(player)) || this.eliminate(user.id)) { | |
| this.sendToRoom(`${user.name} has left the game of UNO.`); | |
| return true; | |
| } | |
| return false; | |
| } | |
| /** | |
| * Overwrite the default makePlayer so it makes an UNOPlayer instead. | |
| */ | |
| makePlayer(user: User) { | |
| return new UNOPlayer(user, this); | |
| } | |
| override onRename(user: User, oldUserid: ID, isJoining: boolean, isForceRenamed: boolean) { | |
| if (!(oldUserid in this.playerTable) || user.id === oldUserid) return false; | |
| if (!user.named && !isForceRenamed) { | |
| user.games.delete(this.roomid); | |
| user.updateSearch(); | |
| return; // dont set users to their guest accounts. | |
| } | |
| this.renamePlayer(user, oldUserid); | |
| } | |
| eliminate(userid: ID | undefined) { | |
| if (!userid) return null; | |
| const player = this.playerTable[userid]; | |
| if (!player) return false; | |
| const name = player.name; | |
| if (this.playerCount === 2) { | |
| this.removePlayer(player); | |
| this.onWin(this.players[0]); | |
| return name; | |
| } | |
| // handle current player... | |
| const removingCurrentPlayer = player === this.currentPlayer; | |
| if (removingCurrentPlayer) { | |
| if (this.state === 'color') { | |
| if (!this.topCard) { | |
| // should never happen | |
| throw new Error(`No top card in the discard pile.`); | |
| } | |
| this.topCard.changedColor = this.discards[1].changedColor || this.discards[1].color; | |
| this.sendToRoom(`|raw|${Utils.escapeHTML(name)} has not picked a color, the color will stay as <span style="color: ${textColors[this.topCard.changedColor]}">${this.topCard.changedColor}</span>.`); | |
| } | |
| } | |
| if (this.awaitUnoPlayer === player) this.awaitUnoPlayer = null; | |
| if (!this.topCard) { | |
| throw new Chat.ErrorMessage(`Unable to disqualify ${name}.`); | |
| } | |
| // put that player's cards into the discard pile to prevent cards from being permanently lost | |
| this.discards.push(...player.hand); | |
| if (removingCurrentPlayer) { | |
| this.onNextPlayer(); | |
| } | |
| this.removePlayer(player); | |
| if (removingCurrentPlayer) { | |
| this.nextTurn(true); | |
| } | |
| return name; | |
| } | |
| sendToRoom(msg: string, overrideSuppress = false) { | |
| if (!this.suppressMessages || overrideSuppress) { | |
| this.room.add(msg).update(); | |
| } else { | |
| // send to the players first | |
| for (const player of this.players) { | |
| player.sendRoom(msg); | |
| } | |
| // send to spectators | |
| for (const i in this.spectators) { | |
| if (i in this.playerTable) continue; // don't double send to users already in the game. | |
| const user = Users.getExact(i); | |
| if (user) user.sendTo(this.roomid, msg); | |
| } | |
| } | |
| } | |
| getPlayers(showCards?: boolean): string { | |
| let playerList = this.players; | |
| if (this.direction === -1) playerList = [...playerList].reverse(); | |
| if (!showCards) { | |
| return playerList.map(p => Utils.escapeHTML(p.name)).join(', '); | |
| } | |
| let buf = `<ol style="padding-left:0;">`; | |
| for (const player of playerList) { | |
| buf += `<li${this.currentPlayer === player ? ` style="font-weight:bold;"` : ''}>`; | |
| buf += `${Utils.escapeHTML(player.name)} (${player.hand.length})`; | |
| buf += `</li>`; | |
| } | |
| buf += `</ol>`; | |
| return buf; | |
| } | |
| onAwaitUno() { | |
| return new Promise<void>(resolve => { | |
| if (!this.awaitUnoPlayer) return void resolve(); | |
| this.state = "uno"; | |
| // the throttle for sending messages is at 600ms for non-authed users, | |
| // wait 750ms before sending the next person's turn. | |
| // this allows games to be fairer, so the next player would not spam the pass command blindly | |
| // to force the player to draw 2 cards. | |
| // this also makes games with uno bots not always turn in the bot's favour. | |
| // without a delayed turn, 3 bots playing will always result in a endless game | |
| setTimeout(() => resolve(), 750); | |
| }); | |
| } | |
| nextTurn(starting?: boolean) { | |
| void this.onAwaitUno().then(() => { | |
| if (!starting) this.onNextPlayer(); | |
| if (this.timer) clearTimeout(this.timer); | |
| const player = this.currentPlayer!; | |
| this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|${player.name}'s turn.`); | |
| this.state = 'play'; | |
| if (player.cardLock) player.cardLock = null; | |
| player.sendDisplay(); | |
| this.timer = setTimeout(() => { | |
| this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|${player.name} has been automatically disqualified.`); | |
| this.eliminate(player.id); | |
| }, this.maxTime * 1000); | |
| }); | |
| } | |
| onNextPlayer() { | |
| this.currentPlayer = this.getNextPlayer(); | |
| } | |
| getNextPlayer() { | |
| // if none is set | |
| this.currentPlayer ||= this.players[Math.floor(this.playerCount * Math.random())]; | |
| let player = this.players[this.players.indexOf(this.currentPlayer) + this.direction]; | |
| // wraparound | |
| player ||= (this.direction === 1 ? this.players[0] : this.players[this.playerCount - 1]); | |
| return player; | |
| } | |
| onDraw(player: UNOPlayer) { | |
| if (this.currentPlayer !== player || this.state !== 'play') return false; | |
| if (player.cardLock) return true; | |
| this.onCheckUno(); | |
| this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|${player.name} has drawn a card.`); | |
| const card = this.onDrawCard(player, 1); | |
| player.sendDisplay(); | |
| player.cardLock = card[0].name; | |
| } | |
| onPlay(player: UNOPlayer, cardName: string) { | |
| if (this.currentPlayer !== player || this.state !== 'play') return false; | |
| const card = player.hasCard(cardName); | |
| if (!card) return "You do not have that card."; | |
| // check for legal play | |
| if (!this.topCard) { | |
| // should never happen | |
| throw new Error(`No top card in the discard pile.`); | |
| } | |
| if (player.cardLock && player.cardLock !== cardName) return `You can only play ${player.cardLock} after drawing.`; | |
| if ( | |
| card.color !== 'Black' && | |
| card.color !== (this.topCard.changedColor || this.topCard.color) && | |
| card.value !== this.topCard.value | |
| ) { | |
| return `You cannot play this card; you can only play: Wild cards, ${this.topCard.changedColor ? 'and' : ''} ${this.topCard.changedColor || this.topCard.color} cards${this.topCard.changedColor ? "" : ` and cards with the digit ${this.topCard.value}`}.`; | |
| } | |
| if (card.value === '+4' && !player.canPlayWildFour()) { | |
| return "You cannot play Wild +4 when you still have a card with the same color as the top card."; | |
| } | |
| if (this.timer) clearTimeout(this.timer); // reset the autodq timer. | |
| this.onCheckUno(); | |
| // update the game information. | |
| this.topCard = card; | |
| player.removeCard(cardName); | |
| this.discards.unshift(card); | |
| // update the unoId here, so when the display is sent to the player when the play is made | |
| if (player.hand.length === 1) { | |
| this.awaitUnoPlayer = player; | |
| this.unoId = Math.floor(Math.random() * 100).toString() as ID; | |
| } | |
| player.sendDisplay(); // update display without the card in it for purposes such as choosing colors | |
| this.sendToRoom(`|raw|${Utils.escapeHTML(player.name)} has played a <span style="font-weight:bold;color: ${textColors[card.color]}">${card.name}</span>.`); | |
| // handle hand size | |
| if (!player.hand.length) { | |
| this.onWin(player); | |
| return; | |
| } | |
| // continue with effects and next player | |
| this.onRunEffect(card.value); | |
| if (this.state === 'play') this.nextTurn(); | |
| } | |
| onRunEffect(value: string, initialize?: boolean) { | |
| const colorDisplay = `|uhtml|uno-color|<table style="width: 100%; border: 1px solid black"><tr><td style="width: 50%"><button style="width: 100%; background-color: red; border: 2px solid rgba(0 , 0 , 0 , 0.59); border-radius: 5px; padding: 5px" name=send value="/uno color Red">Red</button></td><td style="width: 50%"><button style="width: 100%; background-color: blue; border: 2px solid rgba(0 , 0 , 0 , 0.59); border-radius: 5px; color: white; padding: 5px" name=send value="/uno color Blue">Blue</button></td></tr><tr><td style="width: 50%"><button style="width: 100%; background-color: green; border: 2px solid rgba(0 , 0 , 0 , 0.59); border-radius: 5px; padding: 5px" name=send value="/uno color Green">Green</button></td><td style="width: 50%"><button style="width: 100%; background-color: yellow; border: 2px solid rgba(0 , 0 , 0 , 0.59); border-radius: 5px; padding: 5px" name=send value="/uno color Yellow">Yellow</button></td></tr></table>`; | |
| switch (value) { | |
| case 'Reverse': | |
| this.direction *= -1; | |
| this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|The direction of the game has changed.`); | |
| // in 2 player games, reverse sends the turn back to the player. | |
| if (!initialize && this.playerCount === 2) this.onNextPlayer(); | |
| break; | |
| case 'Skip': | |
| this.onNextPlayer(); | |
| this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|${this.currentPlayer!.name}'s turn has been skipped.`); | |
| break; | |
| case '+2': | |
| this.onNextPlayer(); | |
| this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|${this.currentPlayer!.name} has been forced to draw 2 cards.`); | |
| this.onDrawCard(this.currentPlayer!, 2); | |
| break; | |
| case '+4': | |
| this.currentPlayer!.sendRoom(colorDisplay); | |
| this.state = 'color'; | |
| // apply to the next in line, since the current player still has to choose the color | |
| const next = this.getNextPlayer(); | |
| this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|${next.name} has been forced to draw 4 cards.`); | |
| this.onDrawCard(next, 4); | |
| this.isPlusFour = true; | |
| this.timer = setTimeout(() => { | |
| this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|${this.currentPlayer!.name} has been automatically disqualified.`); | |
| this.eliminate(this.currentPlayer!.id); | |
| }, this.maxTime * 1000); | |
| break; | |
| case 'Wild': | |
| this.currentPlayer!.sendRoom(colorDisplay); | |
| this.state = 'color'; | |
| this.timer = setTimeout(() => { | |
| this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|${this.currentPlayer!.name} has been automatically disqualified.`); | |
| this.eliminate(this.currentPlayer!.id); | |
| }, this.maxTime * 1000); | |
| break; | |
| } | |
| if (initialize) this.onNextPlayer(); | |
| } | |
| onSelectColor(player: UNOPlayer, color: Color) { | |
| if ( | |
| !['Red', 'Blue', 'Green', 'Yellow'].includes(color) || | |
| player !== this.currentPlayer || | |
| this.state !== 'color' | |
| ) { | |
| return false; | |
| } | |
| if (!this.topCard) { | |
| // should never happen | |
| throw new Error(`No top card in the discard pile.`); | |
| } | |
| this.topCard.changedColor = color; | |
| this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|The color has been changed to ${color}.`); | |
| if (this.timer) clearTimeout(this.timer); | |
| // remove color change menu and send the display of their cards again | |
| player.sendRoom("|uhtmlchange|uno-color|"); | |
| player.sendDisplay(); | |
| if (this.isPlusFour) { | |
| this.isPlusFour = false; | |
| this.onNextPlayer(); // handle the skipping here. | |
| } | |
| this.nextTurn(); | |
| } | |
| onDrawCard(player: UNOPlayer, count: number) { | |
| if (typeof count === 'string') count = parseInt(count); | |
| if (!count || isNaN(count) || count < 1) count = 1; | |
| const drawnCards = this.drawCard(count); | |
| player.hand.push(...drawnCards); | |
| player.sendRoom( | |
| `|raw|You have drawn the following card${Chat.plural(drawnCards)}: ` + | |
| `${drawnCards.map(card => `<span style="color: ${textColors[card.color]}">${card.name}</span>`).join(', ')}.` | |
| ); | |
| return drawnCards; | |
| } | |
| drawCard(count: number) { | |
| if (typeof count === 'string') count = parseInt(count); | |
| if (!count || isNaN(count) || count < 1) count = 1; | |
| const drawnCards: Card[] = []; | |
| for (let i = 0; i < count; i++) { | |
| if (!this.deck.length) { | |
| // shuffle the cards back into the deck, or if there are no discards, add another deck into the game. | |
| this.deck = this.discards.length ? Utils.shuffle(this.discards) : Utils.shuffle(createDeck()); | |
| this.discards = []; // clear discard pile | |
| } | |
| drawnCards.push(this.deck[this.deck.length - 1]); | |
| this.deck.pop(); | |
| } | |
| return drawnCards; | |
| } | |
| onUno(player: UNOPlayer, unoId: ID) { | |
| // uno id makes spamming /uno uno impossible | |
| if (this.unoId !== unoId || player !== this.awaitUnoPlayer) return false; | |
| this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|**UNO!** ${player.name} is down to their last card!`); | |
| this.awaitUnoPlayer = null; | |
| this.unoId = null; | |
| } | |
| onCheckUno() { | |
| if (!this.awaitUnoPlayer) return; | |
| // if the previous player hasn't hit UNO before the next player plays something, they are forced to draw 2 cards; | |
| if (this.awaitUnoPlayer !== this.currentPlayer) { | |
| this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|${this.awaitUnoPlayer.name} forgot to say UNO! and is forced to draw 2 cards.`); | |
| this.onDrawCard(this.awaitUnoPlayer, 2); | |
| } | |
| this.awaitUnoPlayer = null; | |
| this.unoId = null; | |
| } | |
| onSendHand(user: User) { | |
| if (this.state === 'signups') return false; | |
| this.playerTable[user.id]?.sendDisplay(); | |
| } | |
| onWin(player: UNOPlayer) { | |
| this.sendToRoom( | |
| Utils.html`|raw|<div class="broadcast-blue">Congratulations to ${player.name} for winning the game of UNO!</div>`, | |
| true | |
| ); | |
| this.destroy(); | |
| } | |
| override destroy() { | |
| if (this.timer) clearTimeout(this.timer); | |
| if (this.autostartTimer) clearTimeout(this.autostartTimer); | |
| this.sendToRoom(`|uhtmlchange|uno-${this.gameNumber}|<div class="infobox">The game of UNO has ended.</div>`, true); | |
| this.setEnded(); | |
| for (const player of this.players) player.destroy(); | |
| this.room.game = null; | |
| } | |
| } | |
| class UNOPlayer extends Rooms.RoomGamePlayer<UNO> { | |
| hand: Card[]; | |
| cardLock: string | null; | |
| constructor(user: User, game: UNO) { | |
| super(user, game); | |
| this.hand = []; | |
| this.cardLock = null; | |
| } | |
| canPlayWildFour() { | |
| if (!this.game.topCard) { | |
| // should never happen | |
| throw new Error(`No top card in the discard pile.`); | |
| } | |
| const color = (this.game.topCard.changedColor || this.game.topCard.color); | |
| if (this.hand.some(c => c.color === color)) return false; | |
| return true; | |
| } | |
| hasCard(cardName: string) { | |
| return this.hand.find(card => card.name === cardName); | |
| } | |
| removeCard(cardName: string) { | |
| for (const [i, card] of this.hand.entries()) { | |
| if (card.name === cardName) { | |
| this.hand.splice(i, 1); | |
| break; | |
| } | |
| } | |
| } | |
| buildHand() { | |
| return Utils.sortBy(this.hand, card => [card.color, card.value]) | |
| .map((card, i) => cardHTML(card, i === this.hand.length - 1)); | |
| } | |
| sendDisplay() { | |
| const hand = this.buildHand().join(''); | |
| const players = `<p><strong>Players (${this.game.playerCount}):</strong></p> ${this.game.getPlayers(true)}`; | |
| const draw = '<button class="button" style="width: 45%; background: rgba(0, 0, 255, 0.05)" name=send value="/uno draw">Draw a card!</button>'; | |
| const pass = '<button class="button" style=" width: 45%; background: rgba(255, 0, 0, 0.05)" name=send value="/uno pass">Pass!</button>'; | |
| const uno = `<button class="button" style=" width: 90%; background: rgba(0, 255, 0, 0.05); height: 60px; margin-top: 2px;" name=send value="/uno uno ${this.game.unoId || '0'}">UNO!</button>`; | |
| if (!this.game.topCard) { | |
| // should never happen | |
| throw new Error(`No top card in the discard pile.`); | |
| } | |
| const top = `<strong>Top Card: <span style="color: ${textColors[this.game.topCard.changedColor || this.game.topCard.color]}">${this.game.topCard.name}</span></strong>`; | |
| // clear previous display and show new display | |
| this.sendRoom("|uhtmlchange|uno-hand|"); | |
| this.sendRoom( | |
| `|uhtml|uno-hand|<div style="border: 1px solid skyblue; padding: 0 0 5px 0"><table style="width: 100%; table-layout: fixed; border-radius: 3px"><tr><td colspan="4" rowspan="2" style="padding: 5px"><div style="overflow-x: auto; white-space: nowrap; width: 100%">${hand}</div></td>${this.game.currentPlayer === this ? `<td colspan="2" style="padding: 5px 5px 0 5px">${top}</td></tr>` : ""}` + | |
| `<tr><td colspan="2" style="vertical-align: top; padding: 0px 5px 5px 5px"><div style="overflow-y: scroll">${players}</div></td></tr></table>` + | |
| `${this.game.currentPlayer === this ? `<div style="text-align: center">${draw}${pass}<br />${uno}</div>` : ""}</div>` | |
| ); | |
| } | |
| } | |
| export const commands: Chat.ChatCommands = { | |
| uno: { | |
| // roomowner commands | |
| off: 'disable', | |
| disable(target, room, user) { | |
| room = this.requireRoom(); | |
| this.checkCan('gamemanagement', null, room); | |
| if (room.settings.unoDisabled) { | |
| throw new Chat.ErrorMessage("UNO is already disabled in this room."); | |
| } | |
| room.settings.unoDisabled = true; | |
| room.saveSettings(); | |
| return this.sendReply("UNO has been disabled for this room."); | |
| }, | |
| on: 'enable', | |
| enable(target, room, user) { | |
| room = this.requireRoom(); | |
| this.checkCan('gamemanagement', null, room); | |
| if (!room.settings.unoDisabled) { | |
| throw new Chat.ErrorMessage("UNO is already enabled in this room."); | |
| } | |
| delete room.settings.unoDisabled; | |
| room.saveSettings(); | |
| return this.sendReply("UNO has been enabled for this room."); | |
| }, | |
| // moderation commands | |
| new: 'create', | |
| make: 'create', | |
| createpublic: 'create', | |
| makepublic: 'create', | |
| createprivate: 'create', | |
| makeprivate: 'create', | |
| create(target, room, user, connection, cmd) { | |
| room = this.requireRoom(); | |
| this.checkCan('minigame', null, room); | |
| if (room.settings.unoDisabled) throw new Chat.ErrorMessage("UNO is currently disabled for this room."); | |
| if (room.game) throw new Chat.ErrorMessage("There is already a game in progress in this room."); | |
| const suppressMessages = cmd.includes('private') || !(cmd.includes('public') || room.roomid === 'gamecorner'); | |
| let cap = parseInt(target); | |
| if (isNaN(cap)) cap = 12; | |
| if (cap < 2) cap = 2; | |
| room.game = new UNO(room, cap, suppressMessages); | |
| this.privateModAction(`A game of UNO was created by ${user.name}.`); | |
| this.modlog('UNO CREATE'); | |
| }, | |
| cap: 'setcap', | |
| setcap(target, room, user) { | |
| room = this.requireRoom(); | |
| this.checkCan('minigame', null, room); | |
| const game = this.requireGame(UNO); | |
| if (game.state !== 'signups') { | |
| throw new Chat.ErrorMessage(`There is no UNO game in the signups phase in this room, so adjusting the player cap would do nothing.`); | |
| } | |
| let cap = parseInt(target); | |
| if (isNaN(cap) || cap < 2) { | |
| cap = 2; | |
| } | |
| game.playerCap = cap; | |
| this.privateModAction(`The playercap was set to ${game.playerCap} by ${user.name}.`); | |
| this.modlog('UNO PLAYERCAP'); | |
| }, | |
| start(target, room, user) { | |
| room = this.requireRoom(); | |
| this.checkCan('minigame', null, room); | |
| const game = this.requireGame(UNO); | |
| if (game.state !== 'signups') { | |
| throw new Chat.ErrorMessage("There is no UNO game in signups phase in this room."); | |
| } | |
| game.onStart(); | |
| this.privateModAction(`The game of UNO was started by ${user.name}.`); | |
| this.modlog('UNO START'); | |
| }, | |
| stop: 'end', | |
| end(target, room, user) { | |
| room = this.requireRoom(); | |
| this.checkCan('minigame', null, room); | |
| if (!room.game || room.game.gameid !== 'uno') { | |
| throw new Chat.ErrorMessage("There is no UNO game going on in this room."); | |
| } | |
| room.game.destroy(); | |
| room.add("The game of UNO was forcibly ended.").update(); | |
| this.privateModAction(`The game of UNO was ended by ${user.name}.`); | |
| this.modlog('UNO END'); | |
| }, | |
| autodq: 'timer', | |
| timer(target, room, user) { | |
| room = this.requireRoom(); | |
| this.checkCan('minigame', null, room); | |
| const game = this.requireGame(UNO); | |
| const amount = parseInt(target); | |
| if (!amount || amount < 5 || amount > 300) { | |
| throw new Chat.ErrorMessage("The amount must be a number between 5 and 300."); | |
| } | |
| game.maxTime = amount; | |
| if (game.timer) clearTimeout(game.timer); | |
| game.timer = setTimeout(() => { | |
| game.eliminate(game.currentPlayer?.id); | |
| }, amount * 1000); | |
| this.addModAction(`${user.name} has set the UNO automatic disqualification timer to ${amount} seconds.`); | |
| this.modlog('UNO TIMER', null, `${amount} seconds`); | |
| }, | |
| autostart(target, room, user) { | |
| room = this.requireRoom(); | |
| this.checkCan('minigame', null, room); | |
| const game = this.requireGame(UNO); | |
| if (toID(target) === 'off') { | |
| if (!game.autostartTimer) throw new Chat.ErrorMessage("There is no autostart timer running on."); | |
| this.addModAction(`${user.name} has turned off the UNO autostart timer.`); | |
| clearTimeout(game.autostartTimer); | |
| return; | |
| } | |
| const amount = parseInt(target); | |
| if (!amount || amount < 30 || amount > 600) { | |
| throw new Chat.ErrorMessage("The amount must be a number between 30 and 600 seconds."); | |
| } | |
| if (game.state !== 'signups') throw new Chat.ErrorMessage("The game of UNO has already started."); | |
| if (game.autostartTimer) clearTimeout(game.autostartTimer); | |
| game.autostartTimer = setTimeout(() => { | |
| game.onStart(true); | |
| }, amount * 1000); | |
| this.addModAction(`${user.name} has set the UNO autostart timer to ${amount} seconds.`); | |
| }, | |
| dq: 'disqualify', | |
| disqualify(target, room, user) { | |
| room = this.requireRoom(); | |
| this.checkCan('minigame', null, room); | |
| const game = this.requireGame(UNO); | |
| const disqualified = game.eliminate(toID(target)); | |
| if (disqualified === false) throw new Chat.ErrorMessage(`Unable to disqualify ${target}.`); | |
| this.privateModAction(`${user.name} has disqualified ${disqualified} from the UNO game.`); | |
| this.modlog('UNO DQ', toID(target)); | |
| room.add(`${disqualified} has been disqualified from the UNO game.`).update(); | |
| }, | |
| // player/user commands | |
| j: 'join', | |
| join(target, room, user) { | |
| const game = this.requireGame(UNO); | |
| this.checkChat(); | |
| if (!game.joinGame(user)) throw new Chat.ErrorMessage("Unable to join the game."); | |
| return this.sendReply("You have joined the game of UNO."); | |
| }, | |
| l: 'leave', | |
| leave(target, room, user) { | |
| const game = this.requireGame(UNO); | |
| if (!game.leaveGame(user)) throw new Chat.ErrorMessage("Unable to leave the game."); | |
| return this.sendReply("You have left the game of UNO."); | |
| }, | |
| play(target, room, user) { | |
| const game = this.requireGame(UNO); | |
| if (!game) throw new Chat.ErrorMessage("There is no UNO game going on in this room right now."); | |
| const player: UNOPlayer | undefined = game.playerTable[user.id]; | |
| if (!player) throw new Chat.ErrorMessage(`You are not in the game of UNO.`); | |
| const error = game.onPlay(player, target); | |
| if (typeof error === 'string') throw new Chat.ErrorMessage(error); | |
| }, | |
| draw(target, room, user) { | |
| room = this.requireRoom(); | |
| const game = room.getGame(UNO); | |
| if (!game) throw new Chat.ErrorMessage("There is no UNO game going on in this room right now."); | |
| const player: UNOPlayer | undefined = game.playerTable[user.id]; | |
| if (!player) throw new Chat.ErrorMessage(`You are not in the game of UNO.`); | |
| const error = game.onDraw(player); | |
| if (error) throw new Chat.ErrorMessage("You have already drawn a card this turn."); | |
| }, | |
| pass(target, room, user) { | |
| const game = this.requireGame(UNO); | |
| if (!game) throw new Chat.ErrorMessage("There is no UNO game going on in this room right now."); | |
| const player: UNOPlayer | undefined = game.playerTable[user.id]; | |
| if (!player) throw new Chat.ErrorMessage(`You are not in the game of UNO.`); | |
| if (game.currentPlayer !== player) throw new Chat.ErrorMessage("It is currently not your turn."); | |
| if (!player.cardLock) throw new Chat.ErrorMessage("You cannot pass until you draw a card."); | |
| if (game.state === 'color') throw new Chat.ErrorMessage("You cannot pass until you choose a color."); | |
| game.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|${user.name} has passed.`); | |
| game.nextTurn(); | |
| }, | |
| color(target, room, user) { | |
| const game = this.requireGame(UNO); | |
| const player: UNOPlayer | undefined = game.playerTable[user.id]; | |
| if (!player) throw new Chat.ErrorMessage(`You are not in the game of UNO.`); | |
| let color: Color; | |
| if (target === 'Red' || target === 'Green' || target === 'Blue' || target === 'Yellow' || target === 'Black') { | |
| color = target; | |
| } else { | |
| throw new Chat.ErrorMessage(`"${target}" is not a valid color.`); | |
| } | |
| game.onSelectColor(player, color); | |
| }, | |
| uno(target, room, user) { | |
| const game = this.requireGame(UNO); | |
| const player: UNOPlayer | undefined = game.playerTable[user.id]; | |
| if (!player) throw new Chat.ErrorMessage(`You are not in the game of UNO.`); | |
| game.onUno(player, toID(target)); | |
| }, | |
| // information commands | |
| '': 'hand', | |
| hand(target, room, user) { | |
| const game = this.requireGame(UNO); | |
| game.onSendHand(user); | |
| }, | |
| 'c': 'cards', | |
| cards(target, room, user) { | |
| const game = this.requireGame(UNO); | |
| if (!this.runBroadcast()) return false; | |
| const players = `<strong>Players (${game.playerCount}):</strong></p>${game.getPlayers(true)}`; | |
| this.sendReplyBox(`<tr><td colspan="2" style="vertical-align: top; padding: 0px 5px 5px 5px"><div style="overflow-y: scroll">${players}</div></td></tr></table>`); | |
| }, | |
| players: 'getusers', | |
| users: 'getusers', | |
| getplayers: 'getusers', | |
| getusers(target, room, user) { | |
| const game = this.requireGame(UNO); | |
| if (!this.runBroadcast()) return false; | |
| this.sendReplyBox(`<strong>Players (${game.playerCount})</strong>:${game.getPlayers()}`); | |
| }, | |
| help(target, room, user) { | |
| this.parse('/help uno'); | |
| }, | |
| // suppression commands | |
| suppress(target, room, user) { | |
| room = this.requireRoom(); | |
| const game = this.requireGame(UNO); | |
| this.checkCan('minigame', null, room); | |
| target = toID(target); | |
| const state = target === 'on' ? true : target === 'off' ? false : undefined; | |
| if (state === undefined) { | |
| return this.sendReply(`Suppression of UNO game messages is currently ${game.suppressMessages ? 'on' : 'off'}.`); | |
| } | |
| if (state === game.suppressMessages) { | |
| throw new Chat.ErrorMessage(`Suppression of UNO game messages is already ${game.suppressMessages ? 'on' : 'off'}.`); | |
| } | |
| game.suppressMessages = state; | |
| this.addModAction(`${user.name} has turned ${state ? 'on' : 'off'} suppression of UNO game messages.`); | |
| this.modlog('UNO SUPRESS', null, (state ? 'ON' : 'OFF')); | |
| }, | |
| spectate(target, room, user) { | |
| const game = this.requireGame(UNO); | |
| if (!game) throw new Chat.ErrorMessage("There is no UNO game going on in this room right now."); | |
| if (!game.suppressMessages) throw new Chat.ErrorMessage("The current UNO game is not suppressing messages."); | |
| if (user.id in game.spectators) throw new Chat.ErrorMessage("You are already spectating this game."); | |
| game.spectators[user.id] = 1; | |
| this.sendReply("You are now spectating this private UNO game."); | |
| }, | |
| unspectate(target, room, user) { | |
| const game = this.requireGame(UNO); | |
| if (!game) throw new Chat.ErrorMessage("There is no UNO game going on in this room right now."); | |
| if (!game.suppressMessages) throw new Chat.ErrorMessage("The current UNO game is not suppressing messages."); | |
| if (!(user.id in game.spectators)) throw new Chat.ErrorMessage("You are currently not spectating this game."); | |
| delete game.spectators[user.id]; | |
| this.sendReply("You are no longer spectating this private UNO game."); | |
| }, | |
| }, | |
| unohelp: [ | |
| `/uno create [player cap] - creates a new UNO game with an optional player cap (default player cap at 12). Use the command [createpublic] to force a public game or [createprivate] to force a private game. Requires: % @ # ~`, | |
| `/uno setcap [player cap] - adjusts the player cap of the current UNO game. Requires: % @ # ~`, | |
| `/uno timer [amount] - sets an auto disqualification timer for [amount] seconds. Requires: % @ # ~`, | |
| `/uno autostart [amount] - sets an auto starting timer for [amount] seconds. Requires: % @ # ~`, | |
| `/uno end - ends the current game of UNO. Requires: % @ # ~`, | |
| `/uno start - starts the current game of UNO. Requires: % @ # ~`, | |
| `/uno disqualify [player] - disqualifies the player from the game. Requires: % @ # ~`, | |
| `/uno hand - displays your own hand.`, | |
| `/uno cards - displays the number of cards for each player.`, | |
| `/uno getusers - displays the players still in the game.`, | |
| `/uno [spectate|unspectate] - spectate / unspectate the current private UNO game.`, | |
| `/uno suppress [on|off] - Toggles suppression of game messages.`, | |
| ], | |
| }; | |
| export const roomSettings: Chat.SettingsHandler = room => ({ | |
| label: "UNO", | |
| permission: 'editroom', | |
| options: [ | |
| [`disabled`, room.settings.unoDisabled || 'uno disable'], | |
| [`enabled`, !room.settings.unoDisabled || 'uno enable'], | |
| ], | |
| }); | |