| .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)) { |