@@ -0,0 +1,80 @@ | |||
.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; | |||
} |
@@ -0,0 +1,114 @@ | |||
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(), | |||
), | |||
} |
@@ -4,6 +4,8 @@ | |||
<script src="https://unpkg.com/mithril/mithril.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> | |||
<link rel="stylesheet" href="/hanabi.css" /> | |||
<script src="/hanabi.js" defer></script> | |||
<script src="/pico.js" defer></script> | |||
<link rel="stylesheet" href="/pico.css" /> | |||
<link rel="icon" href="/pico.svg" /> |
@@ -27,7 +27,7 @@ listen('login', ({detail}) => State.username = detail.value) | |||
listen('state', ({detail}) => Object.assign(State, detail)) | |||
listen('post', ({detail}) => State.posts.push(detail)) | |||
listen('peerInfo', (e) => onPeerInfo(e)) | |||
const doNotLog = new Set(['login', 'state', 'post', 'peerInfo']) | |||
const doNotLog = new Set(['login', 'state', 'post', 'peerInfo', 'join', 'leave']) | |||
/* | |||
* | |||
@@ -410,7 +410,9 @@ const Chat = { | |||
), | |||
)), | |||
), | |||
/* we could make these into like tabs .... */ | |||
m(Media), | |||
m(Hanabi), | |||
) | |||
}, | |||
} | |||
@@ -441,10 +443,14 @@ const connect = (username) => { | |||
if(message.online) { | |||
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)) { |