Skip to content

Instantly share code, notes, and snippets.

@numtel
Last active May 29, 2023 17:29
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save numtel/576d7fae916b8190dec8dc64ac40fb35 to your computer and use it in GitHub Desktop.
Save numtel/576d7fae916b8190dec8dc64ac40fb35 to your computer and use it in GitHub Desktop.
Rummikub
const crypto = require('crypto');
const http = require('http');
const url = require('url');
const ws = require('ws');
// Generate a new tileset with all tiles upside down in a random pile
const newRummikubGame = () => [1,2,3,4,5,6,7,8,9,10,11,12,13]
// Each ordinal in the various color sets, classname first, tile text second
.map(i => [['green', i], ['red', i], ['orange', i], ['blue', i]])
// Don't forget the wilds, ...except the mirror wild!
.concat([[['wild', 'Wild'], ['double wild', 'Dbl Wild'], ['color wild', 'Color Wild']]])
// Make 2 of each tile
.reduce((out, cur) => out.concat(cur).concat(cur), [])
// Add starting x,y coordinates and flipped over status to each tile
.map((tile, index) => ({
index,
x: Math.ceil(1200 * Math.random()) + 1000,
y: Math.ceil(700 * Math.random()) + 20,
className: tile[0],
label: tile[1],
player: null, // Who last moved this tile
flipped: true, // Show blank side
rotatable: false,
rotation: 0,
}));
const newMexTrainGame = () => [0,1,2,3,4,5,6,7,8,9,10,11,12]
// All 91 dominos, classname first, tile text second
.map(i => {
const out = [];
for(let j = 0; j <= i; j++) {
out.push([`top_${i} bot_${j}`, `${i}<br>${j}`]);
}
return out;
})
.concat([[
['piece', '🚂'],
['piece', '🚐'],
['piece', '🚕'],
['piece', '🚗'],
['piece', '🚙'],
['piece', '🚚'],
['piece', '🚜'],
['piece', '🛵'],
['piece', '🚲']
]])
// Flatten array
.reduce((out, cur) => out.concat(cur), [])
.map((tile, index) => ({
index,
x: Math.ceil(1200 * Math.random()) + 1600,
y: Math.ceil(700 * Math.random()) + 20,
className: tile[0] + ' domino',
label: tile[1],
player: null,
flipped: tile[0] === 'piece' ? false : true,
rotatable: true,
rotation: 0,
}));
const init_html = () => `<html><head><title>Rummikub</title>
<style>
body {background:#ccc; user-select: none;}
.tile {
display:inline-block;
position:absolute;
padding:10px;
width:60px;
height:50px;
border:2px outset #ccc;
border-radius:5px;
background:white;
font-size:40px;
text-align:center;
font-weight:bold;
font-family:sans-serif;
cursor:pointer;
touch-action:none;
}
.tile.hidden {display:none;}
.tile.mine {background:grey;}
.tile.mine.fresh {background: #ffc;}
.tile.flipped {font-size: 0 !important;}
.tile.green {color:green;}
.tile.red {color:red;}
.tile.orange {color:orange;}
.tile.blue {color:blue;}
.tile.wild {font-size:20px;}
.tile.double.wild {color:cyan;}
.tile.color.wild {color:orange;}
.rotHandle {
position:absolute;
top: 120%;
left: 50%;
margin-left: -50px;
width: 100px;
height: 100px;
border-radius: 50%;
border: 1px outset #ccc;
background: lightgreen;
cursor: grab;
}
.domino {
height: 100px;
}
.domino:after {
top: 48%;
left: 10%;
width: 80%;
height: 4px;
position:absolute;
background: black;
content: ' ';
}
.domino.piece { background: none; border: 0; font-size: 80px; height: 80px; width:80px; }
.domino.piece:after,
.domino.flipped:after { content: none; }
.domino.mine.top_0::first-line, .domino.mine.bot_0 {color:grey;}
.domino.mine.fresh.top_0::first-line, .domino.mine.fresh.bot_0 {color:#ffc;}
.domino.top_0::first-line, .domino.bot_0 {color:white;}
.domino.top_1::first-line, .domino.bot_1 {color:cyan;}
.domino.top_2::first-line, .domino.bot_2 {color:green;}
.domino.top_3::first-line, .domino.bot_3 {color:red;}
.domino.top_4::first-line, .domino.bot_4 {color:brown;}
.domino.top_5::first-line, .domino.bot_5 {color:blue;}
.domino.top_6::first-line, .domino.bot_6 {color:goldenrod;}
.domino.top_7::first-line, .domino.bot_7 {color:fuchsia;}
.domino.top_8::first-line, .domino.bot_8 {color:#01b045;}
.domino.top_9::first-line, .domino.bot_9 {color:purple;}
.domino.top_10::first-line, .domino.bot_10 {color:orange;}
.domino.top_11::first-line, .domino.bot_11 {color:#e30c67;}
.domino.mine.top_12::first-line, .domino.mine.bot_12 {color:white;}
.domino.mine.fresh.top_12::first-line, .domino.mine.fresh.bot_12 {color:grey;}
.domino.top_12::first-line, .domino.bot_12 {color:grey;}
</style></head><body>
<script>
// Global data
const topMarginSize = 20, bodyPadding = 200;
const EL = Symbol();
let websocket;
let tiles = ${JSON.stringify(server_tiles)};
const playerId = localStorage['player_id'] =
localStorage['player_id'] || "${crypto.randomBytes(4).toString('hex')}"
let original14 = false; // Alert about full hand
let dragStartX, dragStartY, dragPrevX, dragPrevY;
// Client functions
${client_drag.toString()}
${client_sortMyHand.toString()}
${client_update.toString()}
(${client_init.toString()})(); // Invoke immediately
</script>`;
function client_drag(tile, event) {
event.preventDefault();
const ctrlKey = event.ctrlKey;
dragStartX = event.clientX;
dragStartY = event.clientY;
if(event.target.classList.contains('tile')) {
document.querySelectorAll('.rotHandle').forEach(rotHandle =>
rotHandle.parentNode.removeChild(rotHandle));;
document.onpointerup = (event) => {
document.onpointerup = null;
document.onpointermove = null;
if(tile.rotatable || ctrlKey) {
const rotHandle = document.createElement('div');
rotHandle.className = 'rotHandle';
tile[EL].appendChild(rotHandle);
}
};
document.onpointermove = (event) => {
event.preventDefault();
const oldTop = tile.y;
const oldLeft = tile.x;
const wasMine = tile[EL].classList.contains('mine');
dragPrevX = dragStartX - event.clientX;
dragPrevY = dragStartY - event.clientY;
dragStartX = event.clientX;
dragStartY = event.clientY;
tile.x = oldLeft - dragPrevX;
tile.y = oldTop - dragPrevY;
if(tile.x < 0) tile.x = 0;
if(tile.y < 0) tile.y = 0;
tile.player = playerId;
client_update([ tile ]);
const isMine = tile[EL].classList.contains('mine');
if(isMine && !wasMine) {
// Tile added to hand
document.onpointerup();
tile[EL].classList.toggle('fresh', true);
setTimeout(() => { tile[EL].classList.toggle('fresh', false); }, 5000);
tile.flipped = false;
client_sortMyHand();
} else if(!isMine && wasMine) {
// Tile is no longer in hand
client_sortMyHand();
}
websocket.send(JSON.stringify(tile));
};
} else if(event.target.classList.contains('rotHandle')) {
const centerX = tile[EL].offsetLeft + (tile[EL].offsetWidth / 2);
const centerY = tile[EL].offsetTop + (tile[EL].offsetHeight / 2);
document.onpointerup = (event) => {
document.onpointerup = null;
document.onpointermove = null;
};
document.onpointermove = (event) => {
event.preventDefault();
const angle = Math.atan2(
(event.clientX + document.body.scrollLeft) - centerX,
centerY - (event.clientY + document.body.scrollTop)
) * 180 / Math.PI;
tile.rotation = (Math.round(angle / 30) * 30) - 180;
client_update([ tile ]);
websocket.send(JSON.stringify(tile));
};
}
}
function client_sortMyHand() {
const mine = tiles.filter(tile => tile[EL].classList.contains('mine'));
function colorIndex(el) {
return el.classList.contains('red') ? 1 :
el.classList.contains('green') ? 2 :
el.classList.contains('blue') ? 3 :
el.classList.contains('orange') ? 4 : 0
}
mine.sort((a,b) => {
const a1 = parseInt(a[EL].innerHTML, 10);
const b1 = parseInt(b[EL].innerHTML, 10);
if(isNaN(a1) && isNaN(b1)) return 0;
if(isNaN(a1)) return 1;
if(isNaN(b1)) return -1;
if(a1 === b1) {
const ac = colorIndex(a[EL]);
const bc = colorIndex(b[EL]);
return ac > bc ? 1 : ac<bc ? -1 : 0;
}
return a1 > b1 ? 1 : a1<b1 ? -1 : 0;
});
for(let i = 0; i < mine.length; i++) {
mine[i].y = 0;
mine[i].x = (90 * i) + 20;
}
client_update(mine);
if(mine.length === 14 && !original14) {
original14 = true;
alert('You now have fourteen!');
}
}
function client_update(updates) {
for(let update of updates) {
const tile = tiles[update.index];
Object.assign(tile, update);
tile[EL].style.left = tile.x + 'px';
tile[EL].style.top = tile.y + 'px';
if('zIndex' in tile) tile[EL].style.zIndex = tile.zIndex;
if(tile.y + tile[EL].offsetHeight > parseInt(document.body.style.height || 0, 10) - bodyPadding)
document.body.style.height = (tile.y + tile[EL].offsetHeight + bodyPadding) + 'px';
if(tile.x + tile[EL].offsetWidth > parseInt(document.body.style.width || 0, 10) - bodyPadding)
document.body.style.width = (tile.x + tile[EL].offsetWidth + bodyPadding) + 'px';
tile[EL].style.transform = `rotate(${tile.rotation}deg)`;
tile[EL].classList.toggle('flipped', tile.flipped);
const inTopMargin = tile.y < topMarginSize;
tile[EL].classList.toggle('hidden', tile.player !== playerId && inTopMargin);
tile[EL].classList.toggle('mine', tile.player === playerId && inTopMargin);
}
}
function client_init() {
websocket = new WebSocket(location.protocol.replace('http', 'ws') + '//' + location.host + '/');
websocket.onerror = function() { alert('Connection Error'); };
websocket.onclose = function() { alert('Client Closed'); };
websocket.onmessage = function(event) {
const msg = JSON.parse(event.data);
// New Game, reset full hand alert
if('init' in msg) {
original14 = false;
tiles = msg.init;
document.querySelectorAll('.tile').forEach(tile =>
tile.parentNode.removeChild(tile));
createTiles();
} else {
// Tile property change array
client_update(msg.filter(update => update.player !== playerId));
}
};
// Initialize game board
function createTiles() {
tiles.forEach((tile, index) => {
const el = document.createElement('div');
el.onpointerdown = client_drag.bind(null, tile);
el.className = 'tile ' + tile.className;
el.innerHTML = tile.label;
document.body.appendChild(el);
tile[EL] = el;
});
client_update(tiles);
}
createTiles();
// Reconnected to game in progress
if(document.querySelectorAll('.tile.mine').length !== 0) client_sortMyHand();
document.onkeypress = (event) => {
if(event.key === 'Z' && confirm('New Rummikub game?')) websocket.send('{ "newgame": "rummikub" }');
if(event.key === 'X' && confirm('New Mexican Train game?')) websocket.send('{ "newgame": "mextrain" }');
if(event.key === 'A' && confirm('Clear table?')) websocket.send('{ "newgame": null }');
}
}
// Begin server
let server_tiles = shuffle(newRummikubGame());
const server_clients = [];
const server = http.createServer((req, res) => {
console.log((new Date()) + ' Received request for ' + req.url);
const parsedUrl = url.parse(req.url);
switch(parsedUrl.pathname) {
case '/':
res.writeHead(200, { 'Content-Type': 'text/html; charset=UTF-8' });
res.end(init_html());
break;
default:
res.writeHead(404);
res.end('Not found');
}
}).listen(process.env.PORT || 8080);
const wss = new ws.Server({ server });
wss.on('connection', (socket) => {
server_clients.push(socket);
console.log((new Date()) + ' Socket connected, ' + server_clients.length + ' online');
socket.on('close', () => {
server_clients.splice(server_clients.indexOf(socket), 1);
console.log((new Date()) + ' Socket disconnected, ' + server_clients.length + ' online');
});
socket.on('message', (raw) => {
const msg = JSON.parse(raw);
if('index' in msg) {
const tile = server_tiles[msg.index];
Object.assign(tile, msg);
const update = JSON.stringify([ tile ]);
for(let client of server_clients) client.send(update);
} else if('newgame' in msg) {
server_tiles = shuffle(
msg.newgame === 'rummikub' ? newRummikubGame() :
msg.newgame === 'mextrain' ? newMexTrainGame() : []);
const update = JSON.stringify({init: server_tiles });
for(let client of server_clients) client.send(update);
}
});
});
function shuffle(tiles) {
return tiles.map((tile, index, tiles) => {
const otherTile = tiles[Math.floor(tiles.length * Math.random())];
// Use index value as initial zIndex
const newZ = 'zIndex' in otherTile ? otherTile.zIndex : otherTile.index;
otherTile.zIndex = 'zIndex' in tile ? tile.zIndex : tile.index;
tile.zIndex = newZ;
return tile;
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment