Last active
May 29, 2023 17:29
-
-
Save numtel/576d7fae916b8190dec8dc64ac40fb35 to your computer and use it in GitHub Desktop.
Rummikub
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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