589 lines
16 KiB
JavaScript
589 lines
16 KiB
JavaScript
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'
|
|
},
|
|
pistol: {
|
|
name: 'Pistol',
|
|
damage: '3L',
|
|
shortRange: '1-7',
|
|
longRange: '8-20'
|
|
},
|
|
laserpistol: {
|
|
name: 'Laser Pistol',
|
|
damage: '4L',
|
|
shortRange: '1-12',
|
|
longRange: '13-30'
|
|
},
|
|
laserrifle: {
|
|
name: 'Laser Rifle',
|
|
damage: '5L',
|
|
shortRange: '1-17',
|
|
longRange: '18-105'
|
|
},
|
|
gyrojetrifle: {
|
|
name: 'Gyrojet Rifle',
|
|
damage: '6L',
|
|
shortRange: '1-57',
|
|
longRange: '58-180'
|
|
},
|
|
autopistol: {
|
|
name: 'Auto Pistol',
|
|
damage: '2L',
|
|
shortRange: '1-7',
|
|
longRange: '8-20'
|
|
},
|
|
law: {
|
|
name: 'LAW',
|
|
damage: '7/3/1 L',
|
|
shortRange: '1-22',
|
|
longRange: '23-80'
|
|
},
|
|
hsrm: {
|
|
name: 'Heavy SRM',
|
|
damage: '13/6/3/1 L',
|
|
shortRange: '1-54',
|
|
longRange: '55-96'
|
|
},
|
|
gl: {
|
|
name: 'Grenade Launcher',
|
|
damage: '4/2/1 L',
|
|
shortRange: '1-10',
|
|
longRange: '11-24'
|
|
},
|
|
autogl: {
|
|
name: 'Auto Grenade Launcher',
|
|
damage: '3/2/1 L',
|
|
shortRange: '1-22',
|
|
longRange: '23-50'
|
|
},
|
|
lrrifle: {
|
|
name: 'Light Recoilless Rifle',
|
|
damage: '4/2/1 L',
|
|
shortRange: '1-36',
|
|
longRange: '37-70'
|
|
},
|
|
satchelcharge: {
|
|
name: 'Satchel Charge',
|
|
damage: '10/5/2 L',
|
|
shortRange: 'NA',
|
|
longRange: 'NA'
|
|
},
|
|
flamer: {
|
|
name: 'Flamer',
|
|
damage: '2/1 L',
|
|
shortRange: '1-6',
|
|
longRange: '7-12'
|
|
},
|
|
hflamer: {
|
|
name: 'Heavy Flamer',
|
|
damage: '2/2/1 L',
|
|
shortRange: '1-6',
|
|
longRange: '7-12'
|
|
},
|
|
inferno: {
|
|
name: 'Inferno',
|
|
damage: '4/2/1 L',
|
|
shortRange: '1-44',
|
|
longRange: '45-108'
|
|
},
|
|
mmg: {
|
|
name: 'Medium MG',
|
|
damage: '6L',
|
|
shortRange: '1-30',
|
|
longRange: '31-84'
|
|
},
|
|
hmg: {
|
|
name: 'Heavy MG',
|
|
damage: '7L',
|
|
shortRange: '1-36',
|
|
longRange: '37-110'
|
|
},
|
|
splaser: {
|
|
name: 'Semi-Portable Laser',
|
|
damage: '11L',
|
|
shortRange: '1-80',
|
|
longRange: '81-240'
|
|
},
|
|
mpppc: {
|
|
name: 'Man-Pack PPC',
|
|
damage: '8L',
|
|
shortRange: '1-50',
|
|
longRange: '51-110'
|
|
},
|
|
grenade: {
|
|
name: 'Grenade',
|
|
damage: '6/3/1 L',
|
|
shortRange: 'Special',
|
|
longRange: 'Special'
|
|
},
|
|
fist: {
|
|
name: 'Fist',
|
|
damage: '(curr. MP/2)B',
|
|
shortRange: 'Adj.',
|
|
longRange: '-'
|
|
},
|
|
blackjack: {
|
|
name: 'Blackjack',
|
|
damage: '5B',
|
|
shortRange: 'Adjacent',
|
|
longRange: '-'
|
|
},
|
|
stunstick: {
|
|
name: 'Stun Stick',
|
|
damage: '8B',
|
|
shortRange: 'Adjacent',
|
|
longRange: '-'
|
|
},
|
|
neuralwhip: {
|
|
name: 'Neural Whip',
|
|
damage: '8B, 3L',
|
|
shortRange: 'Adjacent',
|
|
longRange: '-'
|
|
},
|
|
sword: {
|
|
name: 'Sword',
|
|
damage: '4L',
|
|
shortRange: 'Adjacent',
|
|
longRange: '-'
|
|
},
|
|
vibroblade: {
|
|
name: 'Vibro-Blade',
|
|
damage: '5L',
|
|
shortRange: 'Adjacent',
|
|
longRange: '-'
|
|
},
|
|
bayonetknife: {
|
|
name: 'Bayonet/Knife',
|
|
damage: '3L',
|
|
shortRange: 'Adjacent',
|
|
longRange: '-'
|
|
},
|
|
club: {
|
|
name: 'Club',
|
|
damage: '4B, 1L',
|
|
shortRange: 'Adjacent',
|
|
longRange: '-'
|
|
},
|
|
}
|
|
|
|
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 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 currentSquadIndicator = 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 currentSquad = records.querySelector(`.squad-${currentSquadIndicator.textContent}`);
|
|
|
|
function revealSquad(current, squad, direction) {
|
|
current.addEventListener('transitionend', e => {
|
|
const next = current[`${direction}ElementSibling`];
|
|
currentSquadIndicator.textContent = +next.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.
|
|
next.style.display = 'block';
|
|
records.scrollTo(0, 0);
|
|
|
|
if (next !== squad && next[`${direction}ElementSibling`])
|
|
revealSquad(next, squad, direction);
|
|
else {
|
|
next.style.transform = 'translateX(0)';
|
|
|
|
next.addEventListener('transitionend', e => {
|
|
record.scrollIntoView({ behavior: 'smooth' });
|
|
}, { once: true });
|
|
}
|
|
}, { once: true });
|
|
|
|
current.style.transform = `translateX(${direction === 'next' ? '-' : ''}100%)`;
|
|
}
|
|
|
|
if (!currentSquad.contains(record)) {
|
|
const [squad] = closestSibling(currentSquad, `.squad-${record.dataset.squad}`);
|
|
revealSquad(currentSquad, squad.el, squad.direction);
|
|
} else
|
|
record.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
|
|
function deselect() {
|
|
const selected = getSelected();
|
|
|
|
if (selected) selected.classList.remove('selected');
|
|
}
|
|
|
|
function select(data, opts) {
|
|
const record = data && getRecord(data);
|
|
const isSelected = record?.classList.contains('selected');
|
|
|
|
deselect();
|
|
|
|
if (isSelected || !data) return;
|
|
|
|
record.classList.add('selected');
|
|
if (opts?.revealRecord) reveal(record);
|
|
}
|
|
|
|
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');
|
|
|
|
if (next)
|
|
Observable.notify('select', next, { revealCounter: true, revealRecord: true });
|
|
else
|
|
deselect();
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|