.hanabi { | |||||
padding-top: 3em; | |||||
grid-area: media; | |||||
margin: 0 auto; | |||||
width: 40vh; | |||||
} | |||||
.stacks { | |||||
display: grid; | |||||
background-color: forestgreen; | |||||
padding: 1px; | |||||
grid-gap: 1px; | |||||
grid-template-columns: repeat(var(--ncols), 1fr); | |||||
height: 40vh; | |||||
} | |||||
.col:before { | |||||
position: relative; | |||||
} | |||||
.stack:after { | |||||
content: attr(owner); | |||||
position: absolute; | |||||
top: 0; | |||||
left: 0; | |||||
} | |||||
.stack { | |||||
position: relative; | |||||
height: 100%; | |||||
background-color: #eee; | |||||
} | |||||
.own .stack { | |||||
background-color: #aaa; | |||||
} | |||||
.rival .stack { | |||||
background-color: #ccc; | |||||
} | |||||
.hand { | |||||
position: absolute; | |||||
} | |||||
.stack .actions { | |||||
display: none; | |||||
} | |||||
.stack:hover .actions { | |||||
position: absolute; | |||||
display: block; | |||||
top: 100%; | |||||
z-index: 999; | |||||
} | |||||
.card { | |||||
position: absolute; | |||||
left: 0; | |||||
display: block; | |||||
width: 100%; | |||||
height: 100%; | |||||
outline: 1px solid black; | |||||
background-color: #555; | |||||
} | |||||
.card:after { | |||||
content: attr(value); | |||||
} | |||||
.card.red { | |||||
background-color: crimson; | |||||
} | |||||
.card.green { | |||||
background-color: forestgreen; | |||||
} | |||||
.card.blue { | |||||
background-color: royalblue; | |||||
} | |||||
.card.yellow { | |||||
background-color: gold; | |||||
} | |||||
.card.white { | |||||
background-color: ivory; | |||||
} | |||||
.col:not(.rival) .card:not(.face-up), .own .card { | |||||
color: transparent; | |||||
background-color: #555; | |||||
} | |||||
.counter { | |||||
font-family: monospace; | |||||
} |
const initCards = () => { | |||||
const cards = new Map() | |||||
for(const color of ['red', 'green', 'blue', 'yellow', 'white']) { | |||||
for(const value of [1, 1, 1, 2, 2, 3, 3, 4, 4, 5]) { | |||||
const id = cards.size | |||||
const pos = Hanabi.nrows * Hanabi.ncols - 1 | |||||
const ts = Math.random() | |||||
cards.set(id, {id, pos, color, value, ts, faceUp: false}) | |||||
} | |||||
} | |||||
return cards | |||||
} | |||||
const Counter = { | |||||
step: (name, delta) => () => { | |||||
Hanabi.counters[name] += delta | |||||
Hanabi.sync() | |||||
}, | |||||
view: ({attrs}) => m('.counter', | |||||
m('span', attrs.name), | |||||
m('button', {onclick: Counter.step(attrs.name, -1)}, '-'), | |||||
m('span', Hanabi.counters[attrs.name]), | |||||
m('button', {onclick: Counter.step(attrs.name, +1)}, '+'), | |||||
), | |||||
} | |||||
const Hanabi = { | |||||
nrows: 8, | |||||
ncols: 7, | |||||
counters: {clues: 8, bombs: 3}, | |||||
players: new Map(), | |||||
cards: [], | |||||
oninit: () => { | |||||
Hanabi.cards = initCards() | |||||
listen('hanabi', ({detail}) => { | |||||
Hanabi.counters = detail.value.counters | |||||
Hanabi.cards = new Map(detail.value.cards) | |||||
}) | |||||
listen('join', () => setTimeout(Hanabi.sync, 100)) | |||||
doNotLog.add('hanabi') | |||||
}, | |||||
sync: () => { | |||||
wire({kind: 'hanabi', value: { | |||||
counters: Hanabi.counters, | |||||
cards: [...Hanabi.cards.entries()], | |||||
}}) | |||||
}, | |||||
getStacks: () => { | |||||
const totalCount = Hanabi.nrows * Hanabi.ncols | |||||
const stacks = new Array(totalCount).fill(null).map(_ => []) | |||||
Hanabi.cards.forEach((card) => stacks[card.pos].push(card)) | |||||
return stacks | |||||
}, | |||||
renderCard: (card, i) => { | |||||
const attrs = { | |||||
id: card.id, | |||||
value: card.value, | |||||
style: {top: `${-3 * i}px`}, | |||||
ondragstart: (ev) => { | |||||
ev.dataTransfer.setData('idx', card.id) | |||||
ev.dataTransfer.dropEffect = 'move' | |||||
}, | |||||
class: ['card', card.color, card.faceUp && 'face-up'].join(' '), | |||||
draggable: true, | |||||
} | |||||
return m('div', attrs) | |||||
}, | |||||
renderStack: (pos, stacks) => { | |||||
const stack = stacks[pos] | |||||
stack.sort((a, b) => a.ts - b.ts) | |||||
const attrs = { | |||||
ondrop: (ev) => { | |||||
ev.preventDefault() | |||||
const card = Hanabi.cards.get(+ev.dataTransfer.getData('idx')) | |||||
card.pos = pos | |||||
card.ts = +new Date() | |||||
Hanabi.sync() | |||||
}, | |||||
ondragover: (ev) => { | |||||
ev.preventDefault() | |||||
ev.dataTransfer.dropEffect = 'move' | |||||
}, | |||||
} | |||||
const doMany = (fn) => { | |||||
return {onclick: () => {stack.forEach(fn); Hanabi.sync()}} | |||||
} | |||||
const actions = [ | |||||
m('button', doMany(card => card.faceUp = !card.faceUp), 'flip'), | |||||
m('button', doMany(card => card.ts = card.id), 'sort'), | |||||
m('button', doMany(card => card.ts = Math.random()), 'shuffle'), | |||||
m('button', doMany(card => card.faceUp = true), 'reveal'), | |||||
] | |||||
return m('.stack', attrs, stack.map(Hanabi.renderCard), | |||||
stack.length ? m('.actions', actions) : null, | |||||
) | |||||
}, | |||||
renderStacks: () => { | |||||
const stacks = Hanabi.getStacks() | |||||
return m('.stacks', {style: {'--ncols': Hanabi.ncols}}, | |||||
Array(Hanabi.nrows).fill(null).map((_, y) => | |||||
Array(Hanabi.ncols).fill(null).map((_, x) => | |||||
m('.col', | |||||
{owner: State.online[y], class: y < 5 ? State.username === State.online[y] ? 'own' : 'rival' : ''}, | |||||
Hanabi.renderStack(y * Hanabi.ncols + x, stacks)), | |||||
) | |||||
) | |||||
) | |||||
}, | |||||
view: () => m('.hanabi', | |||||
m(Counter, {name: 'clues'}), | |||||
m(Counter, {name: 'bombs'}), | |||||
Hanabi.renderStacks(), | |||||
), | |||||
} |
<script src="https://unpkg.com/mithril/mithril.min.js"></script> | <script src="https://unpkg.com/mithril/mithril.min.js"></script> | ||||
<script src="https://unpkg.com/marked/marked.min.js"></script> | <script src="https://unpkg.com/marked/marked.min.js"></script> | ||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.0.2/purify.min.js"></script> | <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.0.2/purify.min.js"></script> | ||||
<link rel="stylesheet" href="/hanabi.css" /> | |||||
<script src="/hanabi.js" defer></script> | |||||
<script src="/pico.js" defer></script> | <script src="/pico.js" defer></script> | ||||
<link rel="stylesheet" href="/pico.css" /> | <link rel="stylesheet" href="/pico.css" /> | ||||
<link rel="icon" href="/pico.svg" /> | <link rel="icon" href="/pico.svg" /> |
listen('state', ({detail}) => Object.assign(State, detail)) | listen('state', ({detail}) => Object.assign(State, detail)) | ||||
listen('post', ({detail}) => State.posts.push(detail)) | listen('post', ({detail}) => State.posts.push(detail)) | ||||
listen('peerInfo', (e) => onPeerInfo(e)) | listen('peerInfo', (e) => onPeerInfo(e)) | ||||
const doNotLog = new Set(['login', 'state', 'post', 'peerInfo']) | |||||
const doNotLog = new Set(['login', 'state', 'post', 'peerInfo', 'join', 'leave']) | |||||
/* | /* | ||||
* | * | ||||
), | ), | ||||
)), | )), | ||||
), | ), | ||||
/* we could make these into like tabs .... */ | |||||
m(Media), | m(Media), | ||||
m(Hanabi), | |||||
) | ) | ||||
}, | }, | ||||
} | } | ||||
if(message.online) { | if(message.online) { | ||||
const difference = (l1, l2) => l1.filter(u => !l2.includes(u)) | const difference = (l1, l2) => l1.filter(u => !l2.includes(u)) | ||||
difference(message.online, State.online).forEach(username => | |||||
State.posts.push({ts: message.ts, value: `${username} joined`})) | |||||
difference(State.online, message.online).forEach(username => | |||||
State.posts.push({ts: message.ts, value: `${username} left`})) | |||||
difference(message.online, State.online).forEach(username => { | |||||
State.posts.push({ts: message.ts, value: `${username} joined`}) | |||||
signal({kind: 'join', username: username}) | |||||
}) | |||||
difference(State.online, message.online).forEach(username => { | |||||
State.posts.push({ts: message.ts, value: `${username} left`}) | |||||
signal({kind: 'leave', username: username}) | |||||
}) | |||||
} | } | ||||
if(!doNotLog.has(message.kind)) { | if(!doNotLog.has(message.kind)) { |