Web Dev Solutions

Catalin Mititiuc

import { Observable } from "./observable"; import counters from './assets/images/counters.svg'; const weapons = { rifle: { name: 'Rifle', damage: '4L', shortRange: '1-27', longRange: '28-75' }, smg: { name: 'SMG', damage: '3L', shortRange: '1-15', longRange: '16-25' }, blazer: { name: 'Blazer', damage: '4L', shortRange: '1-17', longRange: '18-105' }, hsplaser: { name: 'Hvy Semi-Portable Laser', damage: '14L', shortRange: '1-100', longRange: '101-280' }, lmg: { name: 'Light MG', damage: '5L', shortRange: '1-30', longRange: '31-84' }, srm: { name: 'SRM', damage: '8/4/2 L', shortRange: '1-44', longRange: '45-108' }, smggl: { name: 'SMG w/Grenade Launcher', damage: '4/2/1 L', shortRange: '1-10', longRange: '11-24' }, riflegl: { name: 'Rifle w/Grenade Launcher', damage: '4/2/1 L', shortRange: '1-10', longRange: '11-24' } } function createIcon(number) { const [icon, use, text] = ['svg', 'use', 'text'].map(t => document.createElementNS(svgns, t)); icon.setAttributeNS(null, 'viewBox', '-6 -6 12 12'); icon.setAttribute('xmlns', svgns); use.setAttributeNS(null, 'href', `./${counters}#counter-base`); text.textContent = number; icon.appendChild(use); icon.appendChild(text); return icon; } // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random#getting_a_random_integer_between_two_values_inclusive function getRandomIntInclusive(min, max) { const minCeiled = Math.ceil(min); const maxFloored = Math.floor(max); return Math.floor(Math.random() * (maxFloored - minCeiled + 1) + minCeiled); // The maximum is inclusive and the minimum is inclusive } function createWeaponIcon(type) { const [icon, use] = ['svg', 'use'].map(t => document.createElementNS(svgns, t)); icon.setAttributeNS(null, 'viewBox', '-6 -6 12 12'); icon.setAttribute('xmlns', svgns); icon.classList.add('weapon-icon'); use.setAttributeNS(null, 'href', `${counters}#${type}`); icon.appendChild(use); return icon; } function makeInactiveDivider(parent) { const div = document.createElement('div'); div.classList.add('inactive-divider'); div.textContent = 'Inactive'; parent.append(div); return div; } function deactivationHandler(e) { e.preventDefault(); if (!this.classList.contains('inactive')) { const inactiveDivider = this.parentElement.querySelector('.inactive-divider') || makeInactiveDivider(this.parentElement); this.addEventListener('transitionend', e => { inactiveDivider.after(this); inactiveDivider.scrollIntoView({ behavior: 'smooth' }); }); this.classList.add('inactive'); this.setAttributeNS(null, 'style', 'transform: scale(0.9);'); } else { const squadRecords = this.parentElement.querySelectorAll(`.soldier-record:not(.inactive)[data-squad="${this.dataset.squad}"]`); const sorted = [...squadRecords, this].sort(({ dataset: { number: a }}, { dataset: { number: b }}) => +a > +b); const index = sorted.findIndex(record => record === this); if (index === 0) this.parentElement.prepend(this); else if (index === sorted.length - 1) sorted[sorted.length - 2].after(this) else sorted[index - 1].after(this) this.classList.remove('inactive'); this.removeAttributeNS(null, 'style'); this.scrollIntoView({ behavior: 'smooth' }); } } function closestSibling(el, selector) { let nextMatch, prevMatch; const next = { steps: 0, direction: 'next' }; const prev = { steps: 0, direction: 'previous' }; next.el = prev.el = el; while (next.el || prev.el) { next.el = next.el?.nextElementSibling; prev.el = prev.el?.previousElementSibling; if (next.el) next.steps += 1 if (prev.el) prev.steps += 1 nextMatch = next.el?.matches(selector); prevMatch = prev.el?.matches(selector); if (nextMatch || prevMatch) { const results = []; if (prevMatch) results.push(prev); if (nextMatch) results.push(next); return results; } } return []; } function armorAssignPreviewSel(index, armorPoints, direction) { const range = { previous: { from: index + 2 - armorPoints, to: index + 1 }, next: { from: index + 1, to: index + armorPoints } } return `damage-block:nth-of-type(n + ${range[direction].from}):nth-of-type(-n + ${range[direction].to})`; } function assignArmor({ dataset: { armor: armorPts }}, record) { const previewClass = 'preview'; const armoredClass = 'armor'; const armorableSel = ':nth-of-type(n + 1):nth-of-type(-n + 10)'; const track = record.shadowRoot.querySelector('.physical-status-track'); const armorableBlocks = track.querySelectorAll(`damage-block${armorableSel}`); const initial = `damage-block:nth-of-type(n + 1):nth-of-type(-n + ${armorPts})`; track.querySelectorAll(initial).forEach(el => el.classList.add(armoredClass)); function isUnarmoredBlockNum(el) { const unarmored = track.querySelectorAll(`damage-block:not(.${armoredClass})${armorableSel}`); return [ el.getAttributeNS(null, 'slot') === 'block-number', [...unarmored].includes(el.parentElement) ].every(c => c); }; function armorAssignPreviewHandler(e) { const parent = e.target.parentElement; if (isUnarmoredBlockNum(e.target)) { const [{ direction }] = closestSibling(parent, `.${armoredClass}`); const index = [...armorableBlocks].findIndex(el => el === parent); const previewSel = armorAssignPreviewSel(index, +armorPts, direction); track.querySelectorAll(previewSel).forEach(el => el.classList.add(previewClass)); } } function clearArmorAssignPreviewHandler(e) { track .querySelectorAll(`.${previewClass}`) .forEach(el => el.classList.remove(previewClass)); } function armorAssignHandler(e) { e.stopPropagation(); if (isUnarmoredBlockNum(e.target)) { track .querySelectorAll(`damage-block.${armoredClass}`) .forEach(el => el.classList.remove(armoredClass)); track.querySelectorAll(`damage-block.${previewClass}`).forEach(el => { el.classList.remove(previewClass); el.classList.add(armoredClass); }); } } track.addEventListener('pointerover', armorAssignPreviewHandler); track.addEventListener('pointerout', clearArmorAssignPreviewHandler); track.addEventListener('click', armorAssignHandler); } function createRecord(unit) { const { dataset: { allegiance, number, squad }} = unit, pw = unit.classList && unit.classList.contains('counter') ? unit.querySelector('.primary-weapon').getAttributeNS(null, 'href').split('#').pop() : unit.dataset.weapon || 'rifle', div = document.createElement('soldier-record-block'), spans = Array(6).fill('span').map(t => document.createElement(t)), [tn, sn, pwt, pwd, pwrs, pwrl] = spans; div.classList.add('soldier-record'); if (unit.classList && unit.classList.contains('selected')) div.classList.add('selected'); div.dataset.number = number; div.dataset.squad = squad; div.dataset.allegiance = allegiance; tn.setAttribute('slot', 'troop-number'); tn.appendChild(createIcon(number)); sn.setAttribute('slot', 'squad-number'); sn.appendChild(createIcon(squad || 1)); pwt.setAttribute('slot', 'primary-weapon-type'); pwt.textContent = ' ' + weapons[pw].name; pwt.prepend(createWeaponIcon(pw)); pwd.setAttribute('slot', 'primary-weapon-damage'); pwd.textContent = weapons[pw].damage; pwrs.setAttribute('slot', 'primary-weapon-range-short'); pwrs.textContent = weapons[pw].shortRange; pwrl.setAttribute('slot', 'primary-weapon-range-long'); pwrl.textContent = weapons[pw].longRange; spans.forEach(el => div.appendChild(el)); if (unit.dataset.armor) assignArmor(unit, div); div.addEventListener('contextmenu', deactivationHandler); return div; } function createRecords(units) { return Array.from(units).reduce((acc, unit) => { const record = createRecord(unit), { allegiance, squad } = unit.dataset; if (acc[allegiance]) { acc[allegiance][squad]?.push(record) || (acc[allegiance][squad] = [record]) } else { acc[allegiance] = { [squad]: [record] } } return acc; }, {}); } function getRecord({ dataset: { allegiance: al, number: n, squad: s }}) { const selector = `.soldier-record[data-number="${n}"][data-allegiance="${al}"][data-squad="${s}"]`; return document.querySelector(selector); } function deselect() { const selected = getSelected(); if (selected) { selected.classList.remove('selected'); } } function clear() { document.querySelectorAll('#record-sheet > *').forEach(el => { //el.querySelectorAll('.squad-number').forEach(sn => sn.remove()); const records = el.querySelector('.records'); records.dataset.viewSquadNumber = 1; [...records.children].forEach(c => c.remove()); }); //document.querySelector('#attacker-record .name').textContent = 'attacker'; //document.querySelector('#defender-record .name').textContent = 'defender'; } function reveal(record) { const currentSquadView = document.querySelector(`#record-sheet #${record.dataset.allegiance}-record .records-header .squad-number text`); const records = document.querySelector(`#record-sheet #${record.dataset.allegiance}-record .records`); const target = records.querySelector(`.squad-${record.dataset.squad}`); const currentSquad = records.querySelector(`.squad-${currentSquadView.textContent}`); let direction; let next = prev = currentSquad; while (!direction && (next || prev)) { next = next?.nextElementSibling; prev = prev?.previousElementSibling; if (next === target) direction = 'next'; else if (prev === target) direction = 'previous'; } function showSquad(current, target, direction) { current.addEventListener('transitionend', e => { const toSquad = current[`${direction}ElementSibling`]; currentSquadView.textContent = +toSquad.className.match(/\d+/); current.style.display = 'none'; // There needs to be a delay between making it visible and the // transformation. ScrollTo seems to create enough delay. toSquad.style.display = 'block'; records.scrollTo(0, 0); if (toSquad[`${direction}ElementSibling`] && toSquad !== target) { showSquad(toSquad, target, direction); } else { toSquad.style.transform = 'translateX(0)'; toSquad.addEventListener('transitionend', e => { record.classList.add('selected'); record.scrollIntoView({ behavior: 'smooth' }); }, { once: true }); } }, { once: true }); current.style.transform = `translateX(${direction === 'next' ? '-' : ''}100%)`; } if (currentSquad !== target) showSquad(currentSquad, target, direction); else record.scrollIntoView({ behavior: 'smooth' }); } function select(data, opts) { const record = data && getRecord(data); const isSelected = record?.classList.contains('selected'); deselect(); if (isSelected || !data) return; if (opts?.revealRecord) reveal(record); record.classList.add('selected'); } function endMove() { const selected = getSelected(); if (selected) { const list = selected.closest('.records').querySelectorAll('.soldier-record:not(.movement-ended, .inactive)'); const index = [...list].findIndex(s => s === selected); const next = list.length > 1 ? list[(index + 1) % list.length] : null; selected.classList.toggle('movement-ended'); deselect(); if (next) Observable.notify('select', next, { revealCounter: true, revealRecord: true }); } } export function extractWeaponFromRecord(recordEl) { return recordEl .querySelector('[slot="primary-weapon-type"] use') .getAttributeNS(null, 'href') .split('#') .pop(); } export function isRecord(el) { return el.classList && el.classList.contains('soldier-record'); } export function getSelected() { return document.querySelector('.soldier-record.selected'); } export function start(startLoc, units) { clear(); const forces = createRecords(units); for (const affiliation in forces) { const container = document.querySelector(`#${affiliation}-record`); const records = container.querySelector('.records'); const viewSquadIndicator = container.querySelector('.squad-number svg text'); for (const squadNumber in forces[affiliation]) { const squadContainer = document.createElement('div'); squadContainer.classList.add(`squad-${squadNumber}`); forces[affiliation][squadNumber].forEach(r => squadContainer.append(r)); records.append(squadContainer); } viewSquadIndicator.textContent = Object.keys(forces[affiliation])[0]; } document.querySelectorAll('.soldier-record').forEach(el => el.addEventListener('click', () => Observable.notify('select', el, { revealCounter: true })) ); Observable.subscribe('select', select); Observable.subscribe('endmove', endMove); console.log('records created'); } export function stop() { Observable.unsubscribe('select', select); Observable.unsubscribe('endmove', endMove); }