Start migration to Express/Vue/Parcel

This commit is contained in:
Adam Goldsmith 2018-12-28 16:46:01 +01:00
parent c071cd133c
commit f6bebb4402
31 changed files with 7151 additions and 6982 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
.tern-port .tern-port
/node_modules/ /node_modules/
/decks /decks
/dist/
/.cache/

View File

@ -1,4 +0,0 @@
body {
background-color: #eee;
color: #444;
}

View File

@ -1,8 +0,0 @@
#cardEditor {
position: fixed;
top: 0;
right: 0;
background-color: gray;
padding: 10px;
border-radius: 3px;
}

View File

@ -1,88 +0,0 @@
* {
touch-action: pan-y;
}
body {
margin: 0 0 0 0;
}
.card {
display: inline-block;
border-radius: 5px;
width: 142px;
height: 200px;
}
#card-container {
width: 100%;
height: 100%;
}
#hand {
position: absolute;
bottom: 0px;
width: 100%;
height: 20%;
background-color: #ccc;
}
.card-pile {
width: 100px;
height: 100px;
color: #eee;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
.shake {
animation: shake 0.8s;
}
@keyframes shake {
10%, 90% {transform: translate(-1px, 0);}
20%, 80% {transform: translate(2px, 0);}
30%, 50%, 70% {transform: translate(-4px, 0);}
40%, 60% {transform: translate(4px, 0);}
}
.deck {
background-color: blue;
}
.discard {
background-color: red;
}
#shade {
position: fixed;
z-index: 100;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: grey;
opacity: 0.5;
filter: alpha(opacity=50);
}
#modal-content {
padding: 5px;
border-radius: 5px;
position: fixed;
z-index: 101;
top: 5%;
left: 5%;
width: 90%;
background-color: white;
overflow-y: auto;
max-height: 90%;
}
#modal-content .card {
margin: 2px;
}

View File

@ -1,34 +0,0 @@
<html>
<head>
<script src="/js/editor.js"></script>
<link rel="stylesheet" type="text/css" href="/css/editor.css">
<link rel="stylesheet" type="text/css" href="/css/common.css">
<title>Editor</title>
</head>
<body>
<div>
<button type="button" id="saveButton">Save Deck</button>
<button type="button" id="jsonInputDownload">Download Input JSON</button>
<button type="button" id="outputDownload">Download Tabletop Output JSON</button>
</div>
<div>
<label> Upload JSON: WARNING: WILL CLEAR DECK
<input id="jsonUpload" type="file">
</label>
</div>
<form id="deckForm">
<div> <label> Deck Name: <input type="text" id="deckName"></label> </div>
<div>
<label> Deck Type: <select id="deckType">
<option value="hero">hero</option>
<option value="villain">villain</option>
<option value="environment">environment</option>
</select> </label>
</div>
</form>
<form id="cardForm"></form>
<div id="deck"></div>
</body>
</html>

View File

@ -1,16 +0,0 @@
<html>
<head>
<script src="/js/interact.js"></script>
<script src="/js/playfield.js"></script>
<link rel="stylesheet" type="text/css" href="/css/playfield.css">
<link rel="stylesheet" type="text/css" href="/css/common.css">
<title>Playfield</title>
</head>
<body>
<div id="card-container">
<div class="card-pile deck" data-pile="deck">DECK</div>
<div class="card-pile discard" data-pile="discard">DISCARD</div>
<div id="hand"></div>
</div>
</body>
</html>

View File

@ -1,253 +0,0 @@
//jshint browser:true
//jshint esversion:6
//jshint latedef:nofunc
let deckJSON, template;
let selected;
let deckName = window.location.pathname.split('/')[2];
document.title = "Editor|" + deckName;
window.addEventListener("load", () => {
// load deck input json
fetch("deck.input.json")
.then(data => data.json())
.then(json => {
deckJSON = json;
makeSVGs(deckJSON);
})
.catch(error => console.error(error));
// deck JSON uploader
document.querySelector('#jsonUpload').addEventListener('change', event => {
let files = event.target.files;
let reader = new FileReader();
reader.onload = event => {
deckJSON = JSON.parse(event.target.result);
makeSVGs(deckJSON);
};
reader.readAsText(files[0]);
});
// Upload on save button
document.querySelector('#saveButton').addEventListener('click', upload);
// download input JSON
document.querySelector('#jsonInputDownload').addEventListener(
'click',
() => downloadFile('data:application/json;charset=utf-8,' +
encodeURIComponent(JSON.stringify(deckJSON)),
deckName + '.input.json'));
// download input JSON
document.querySelector('#outputDownload').addEventListener(
'click',
() => downloadFile('deck.tts.json',
deckName + '.json'));
// handle changes to deck editor
document.querySelector('#deckForm').addEventListener('input', event => {
let prop = event.target.id.substring(4).toLowerCase();
deckJSON[prop] = event.target.value;
if (prop === 'type') {
makeSVGs(deckJSON);
}
});
});
// handle changes to card editor
document.querySelector('#cardForm').addEventListener('input', event => {
let prop = event.target.id.substring(5);
if (prop !== "count") {
wrapSVGText(selected.svg.querySelector('#' + prop),
String(event.target.value));
}
if (event.target.value) {
selected.json[prop] = event.target.value;
}
else {
delete selected.json[prop];
}
});
// chrome doesn't seem to send input event on file select
document.querySelector('#cardForm').addEventListener('change', event => {
let prop = event.target.id.substring(5);
if (prop === "image") {
let files = event.target.files;
let reader = new FileReader();
reader.onload = e => {
selected.svg.querySelector('#' + prop)
.setAttributeNS("http://www.w3.org/1999/xlink", "href", e.target.result);
selected.json[prop] = e.target.result;
};
reader.readAsDataURL(files[0]);
}
});
window.addEventListener('beforeunload',
e => e.returnValue = "Unsaved changes blah blah");
});
function downloadFile(file, name) {
let dl = document.createElement('a');
dl.setAttribute('href', file);
dl.setAttribute('download', name);
document.body.appendChild(dl);
dl.click();
document.body.removeChild(dl);
}
function getSVGTemplate(name, callback) {
return fetch("/template/" + name + ".svg")
.then(response => response.text())
.then(str => (new window.DOMParser()).parseFromString(str, "text/xml").activeElement);
}
async function makeSVGs(deckJSON) {
document.querySelector('#deckName').value = deckJSON.name || "";
document.querySelector('#deckType').value = deckJSON.type || "";
let deck = document.querySelector('#deck');
deck.innerHTML = "";
let template = await fetch(`/template/${deckJSON.type}/input.json`)
.then(data => data.json());
let cardCount = Object.entries(template.cardTypes)
.map(ct => deckJSON[ct[0]].length * (ct[1].back ? 2 : 1))
.reduce((sum, current) => sum + current, 0);
// note: needs to be a for loop because it needs to be synchronous
// and also have await
// Although I suppose I could prefetch the SVGs and then do the rest...
for (let cardType of Object.entries(template.cardTypes)) {
let backSVG;
if (cardType[1].back) {
let backTemplate = cardType[1].back.template || (cardType[0] + "-back");
backSVG = await getSVGTemplate(deckJSON.type + "/" + backTemplate);
}
let templateSVG = await getSVGTemplate(deckJSON.type + "/" + cardType[0]);
console.log(templateSVG);
// build card SVGs
deckJSON[cardType[0]].forEach(card => {
makeCardSVG(deck, cardType[1], templateSVG, card);
// if there is a back, build it too
if (cardType[1].back) {
makeCardSVG(deck, cardType[1].back, backSVG, card, back=true);
}
});
// set div width/height based on number of cards
deck.style.width = Math.ceil(Math.sqrt(cardCount)) *
parseInt(templateSVG.getAttribute("width")) + "pt";
deck.style.height = Math.ceil(Math.sqrt(cardCount)) *
parseInt(templateSVG.getAttribute("height")) + "pt";
};
}
function setForm(cardTemplate, card) {
let form = document.querySelector('#cardForm');
form.innerHTML = "";
Object.entries(cardTemplate.inputs).forEach(prop => {
let div = form.appendChild(document.createElement('div'));
let label = div.appendChild(document.createElement('label'));
label.textContent = prop[0];
let input = label.appendChild(
document.createElement(prop[1] === 'textarea' ? 'textarea' : 'input'));
input.id = "card-" + prop[0];
if (prop[1] === "image") {
input.type = "file";
}
else {
input.type = prop[1];
input.value = card[prop[0]] || "";
}
});
}
function makeCardSVG(deck, cardInputTemplate, templateSVG, card, back=false) {
let propSource = (back && card.back) ? card.back : card;
let cardSVG = deck.appendChild(templateSVG.cloneNode(true));
cardSVG.addEventListener('click', () => {
selected = {svg: cardSVG, json: card};
setForm(cardInputTemplate, card);
}, true);
Object.keys(cardInputTemplate.inputs).forEach(prop => {
let inputProp = propSource[prop] || card[prop] || "";
wrapSVGText(cardSVG.querySelector('#' + prop), String(inputProp));
});
Object.entries(cardInputTemplate.hide || []).forEach(hidable => {
cardSVG.querySelector('#' + hidable[0])
.setAttribute('display', hidable[1] in propSource ? '' : 'none');
});
}
function upload() {
let deck = document.querySelector('#deck');
// POST the generated SVGs to the server
let data = (new XMLSerializer()).serializeToString(deck);
fetch('upload', {
method: 'post',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({body: data, json: deckJSON})
});
}
function wrapSVGText(e, string) {
// TODO: bold or italic text
e.innerHTML = ""; // clear element
let lines = string.split("\n");
if (e.getAttribute('default-font-size'))
e.setAttribute('font-size', e.getAttribute('default-font-size'));
e.setAttribute('default-font-size', e.getAttribute('font-size'));
while (lines.length > 0) {
let words = lines.shift().split(" ");
let tspan = document.createElementNS("http://www.w3.org/2000/svg", "tspan");
tspan.setAttribute('x', e.getAttribute('x'));
if (e.innerHTML !== "") tspan.setAttribute('dy', e.getAttribute('font-size'));
e.appendChild(tspan);
let line = [];
while(words.length > 0) {
let word = words.shift();
if (word === "") word = " ";
line.push(word);
tspan.innerHTML = line.join(" ");
// horizontal overflow
// TODO: actually use units (also applies to vertical)
if (parseFloat(e.getAttribute("width")) &&
tspan.getComputedTextLength() > parseFloat(e.getAttribute("width"))) {
// if we have height, we can line wrap
if (parseFloat(e.getAttribute("height")) &&
e.children.length * parseFloat(e.getAttribute('font-size')) <
parseFloat(e.getAttribute('height'))) {
words.unshift(line.pop());
tspan.innerHTML = line.join(" ");
line = [];
tspan = document.createElementNS("http://www.w3.org/2000/svg", "tspan");
tspan.setAttribute('x', e.getAttribute('x'));
tspan.setAttribute('dy', e.getAttribute('font-size'));
e.appendChild(tspan);
}
// vertical overflow or horizontal overflow with no height variable
// TODO: better with recursion instead?
else {
e.innerHTML = ""; // remove all tspans
// TODO: maybe binary search font size later if I really care
e.setAttribute('font-size', parseFloat(e.getAttribute('font-size')) * 0.9);
words = [];
lines = string.split('\n');
console.log("resetting, size= " + e.getAttribute('font-size'));
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,319 +0,0 @@
//jshint browser:true
//jshint esversion:6
//jshint latedef:nofunc
/* globals interact:true */
let deckNum, deckJSON, cardCount, deckWidth, deckHeight,
piles = {'deck': [], discard: []};
document.title = "Playfield|" + window.location.pathname.split('/')[2];
interact.dynamicDrop(true);
window.addEventListener('load', () => {
fetch("deck.json")
.then(data => data.json())
.then(json => {
deckJSON = json;
deckNum = Object.keys(deckJSON.CustomDeck)[0];
piles.deck = deckJSON.DeckIDs.map(c => c - deckNum * 100);
cardCount = piles.deck.length;
shuffle(piles.deck);
deckWidth = deckJSON.CustomDeck[deckNum].NumWidth;
deckHeight = deckJSON.CustomDeck[deckNum].NumHeight;
});
window.addEventListener("contextmenu", event => event.preventDefault());
});
let cardInteract = interact('.card', {ignoreFrom: '.in-list'})
.draggable({
restrict: {
restriction: "parent",
endOnly: true,
elementRect: { top: 0, left: 0, bottom: 1, right: 1 }
},
snap: {
targets: [() => {
// TODO: maybe change to dropzone?
let pos = document.body.getBoundingClientRect(),
hand = document.getElementById('hand'),
style = window.getComputedStyle(hand),
handHeight = parseInt(style.getPropertyValue("height"));
return {y: pos.bottom,
range: handHeight/2};
}],
relativePoints: [{x: 0.5 , y: 1}]
},
onmove: event => {
dragMoveListener(event);
// raise to top
event.target.parentElement.removeChild(event.target);
document.querySelector("#card-container").appendChild(event.target);
}
})
.on('doubletap', event => {
let scale = parseFloat(event.target.getAttribute('data-scale')) === 2 ? 1 : 2;
event.target.setAttribute('data-scale', scale);
dragMoveListener({target: event.target, dx: 0, dy: 0});
})
.on('tap', event => {
let card = event.target;
let cardNum = parseInt(card.getAttribute('data-num'));
if (card.dataset.flipped === "true") {
card.dataset.flipped = false;
let style = window.getComputedStyle(card);
card.style.backgroundPositionX =
-(cardNum % deckWidth) * parseInt(style.getPropertyValue("width")) + "px";
card.style.backgroundPositionY =
-Math.floor(cardNum/deckWidth) * parseInt(style.getPropertyValue("height")) + "px";
return;
}
let cardData = deckJSON.ContainedObjects.find(c => c.CardID === (cardNum + deckNum * 100));
let backNum = cardData.States && cardData.States["2"].CardID;
if (backNum) {
card.dataset.flipped = true;
let style = window.getComputedStyle(card);
card.style.backgroundPositionX =
-(backNum % deckWidth) * parseInt(style.getPropertyValue("width")) + "px";
card.style.backgroundPositionY =
-Math.floor(backNum/deckWidth) * parseInt(style.getPropertyValue("height")) + "px";
}
});
interact('.card.in-list')
.draggable({
restrict: {
restriction: "parent",
endOnly: false,
elementRect: { top: 0, left: 0, bottom: 1, right: 1 }
},
autoScroll: false,
onmove: dragMoveListener,
onend: event => {
// reset transform and data attributes
event.target.style.webkitTransform = event.target.style.transform = '';
event.target.removeAttribute('data-x');
event.target.removeAttribute('data-y');
}
})
.dropzone({
accept: '.card',
ondropmove: event => {
let target = event.target;
// TODO: there must be a better way to do this
let relCursorPos = (event.dragEvent.pageX -
target.getBoundingClientRect().left) /
parseInt(window.getComputedStyle(target).width);
let oldOffsetLeft = event.relatedTarget.offsetLeft;
let oldOffsetTop = event.relatedTarget.offsetTop;
// move the object in the DOM
if (relCursorPos < 0.5 || relCursorPos === Infinity) {
target.parentElement.insertBefore(event.relatedTarget, target);
}
else {
target.parentElement.insertBefore(event.relatedTarget, target.nextSibling);
}
// update position
dragMoveListener({target: event.relatedTarget,
dx: oldOffsetLeft - event.relatedTarget.offsetLeft,
dy: oldOffsetTop - event.relatedTarget.offsetTop});
// rebuild source pile
piles[target.getAttribute('data-pile')] =
Array.from(target.parentElement.children).map(
c => c.getAttribute('data-num'));
}
})
.on('tap', event => {
let target = event.target;
console.log(`Drawing ${target.getAttribute('data-num')} from ${target.getAttribute('data-pile')}`);
let listDiv = target.parentElement;
// re-parent
document.querySelector("#card-container").appendChild(target);
// rebuild source pile
piles[target.getAttribute('data-pile')] =
Array.from(listDiv.children).map(c => c.getAttribute('data-num'));
// remove list class
target.classList.remove('in-list');
// fix position
target.style.position = "fixed";
});
interact('.card-pile')
.dropzone({
accept: '.card:not(.in-pile)',
ondrop: event => {
// TODO: fix possible duped zeros
let pileName = event.target.getAttribute('data-pile');
let cardNum = event.relatedTarget.getAttribute('data-num');
if (event.dragEvent.shiftKey) {
console.log(`Adding ${cardNum} bottom of to ${pileName}`);
piles[pileName].unshift(cardNum);
}
else {
console.log(`Adding ${cardNum} to ${pileName}`);
piles[pileName].push(cardNum);
}
// remove from DOM
event.relatedTarget.parentElement.removeChild(event.relatedTarget);
// update deck text
event.target.innerHTML = `${pileName.toUpperCase()}<br>${piles[pileName].length}/${cardCount}`;
}
})
.draggable({manualStart: true})
.on('move', event => {
let interaction = event.interaction;
let pileName = event.target.getAttribute('data-pile');
let pile = piles[pileName];
// if the pointer was moved while being held down
// and an interaction hasn't started yet
// and there are cards in the pile
if (interaction.pointerIsDown &&
!interaction.interacting() &&
pile.length > 0) {
// draw a new card
let newCard = makeCard(pile.pop());
newCard.style.position = "fixed";
newCard.style.top = event.pageY;
newCard.style.left = event.pageX;
// insert the card to the page
document.querySelector("#card-container").appendChild(newCard);
// update deck text
event.target.innerHTML = `${pileName.toUpperCase()}<br>${pile.length}/${cardCount}`;
// start a drag interaction targeting the clone
interaction.start({name: 'drag'}, cardInteract, newCard);
}
})
.on('hold', event => {
let pile = piles[event.target.getAttribute('data-pile')];
let searchBox = document.createElement('input');
let container = document.createElement('div');
let cardList = document.createElement('div');
searchBox.setAttribute('type', 'search');
searchBox.setAttribute('placeholder', 'Filter');
searchBox.addEventListener('input', event => {
let input = event.target.value;
Array.from(cardList.children).forEach(card => {
let cardNum = parseInt(card.getAttribute('data-num'));
let cardData = deckJSON.ContainedObjects.find(c => c.CardID === (cardNum + deckNum * 100));
card.style.display =
(cardData.Nickname.toLowerCase().includes(input.toLowerCase()) ||
cardData.Description.toLowerCase().includes(input.toLowerCase())) ?
"": "none";
});
});
container.appendChild(searchBox);
pile.forEach(cardNum => {
let newCard = makeCard(cardNum);
newCard.classList.add('in-list');
newCard.setAttribute('data-pile', event.target.getAttribute('data-pile'));
cardList.appendChild(newCard);
});
container.appendChild(cardList);
showModal(container);
})
.on('tap', event => {
shuffle(piles[event.target.getAttribute('data-pile')]);
event.target.classList.add("shake");
// reset animation so it can be played again
event.target.onanimationend = e => {
event.target.classList.remove("shake");
};
});
function makeCard(cardNum) {
// draw a new card
let card = document.createElement('div');
card.className = "card";
// temporary add so getComputedStyle works on Chrome
document.body.appendChild(card);
let style = window.getComputedStyle(card);
card.setAttribute('data-num', cardNum);
card.style.backgroundPositionX =
-(cardNum % deckWidth) * parseInt(style.getPropertyValue("width")) + "px";
card.style.backgroundPositionY =
-Math.floor(cardNum/deckWidth) * parseInt(style.getPropertyValue("height")) + "px";
let faceURI = encodeURI(deckJSON.CustomDeck[deckNum].FaceURL);
card.style.backgroundImage = `url("${faceURI}")`;
card.style.backgroundSize = `${deckWidth * 100}% ${deckHeight * 100}%`;
document.body.removeChild(card);
return card;
}
function showModal(content) {
let shade = document.createElement('div');
shade.id = "shade";
shade.className = "modal";
shade.addEventListener('click', hideModal);
document.body.appendChild(shade);
let modal = document.createElement('div');
modal.id = "modal-content";
modal.className = "modal";
modal.appendChild(content);
document.body.appendChild(modal);
}
function hideModal() {
document.querySelectorAll('.modal').forEach(
e => e.parentElement.removeChild(e));
}
// Fisher-Yates shuffle from https://bost.ocks.org/mike/shuffle/
function shuffle(array) {
let m = array.length, t, i;
// While there remain elements to shuffle…
while (m) {
// Pick a remaining element…
i = Math.floor(Math.random() * m--);
// And swap it with the current element.
t = array[m];
array[m] = array[i];
array[i] = t;
}
return array;
}
function dragMoveListener (event) {
let target = event.target;
// keep the dragged position in the data-x/data-y attribute
let x = (parseFloat(target.getAttribute('data-x')) || 0) + event.dx;
let y = (parseFloat(target.getAttribute('data-y')) || 0) + event.dy;
let scale = (parseFloat(target.getAttribute('data-scale')) || 1);
// translate and scale the element
target.style.webkitTransform =
target.style.transform =
'translate(' + x + 'px, ' + y + 'px) scale(' + scale + ')';
// update the posiion attributes
target.setAttribute('data-x', x);
target.setAttribute('data-y', y);
}

6739
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,11 +5,28 @@
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js" "start": "node src/server.js",
"start-parcel": "parcel src/index.html",
"build": "parcel build --public-url ./ src/index.html"
}, },
"author": "Adam Goldsmith", "author": "Adam Goldsmith",
"license": " CC-BY-SA-4.0", "license": " CC-BY-SA-4.0",
"dependencies": { "dependencies": {
"phantom": "^4.0.12" "express": "^4.16.4",
"interactjs": "^1.3.4",
"parcel-bundler": "^1.11.0",
"phantom": "^4.0.12",
"v-runtime-template": "^1.5.2",
"vue": "^2.5.21",
"vue-hot-reload-api": "^2.3.1",
"vue-router": "^3.0.2"
},
"devDependencies": {
"@vue/component-compiler-utils": "^2.3.1",
"parcel": "^1.11.0",
"vue-template-compiler": "^2.5.21"
},
"alias": {
"vue": "./node_modules/vue/dist/vue.common.js"
} }
} }

263
server.js
View File

@ -1,263 +0,0 @@
// jshint node:true
// jshint esversion:6
"use strict";
const http = require('http'),
fs = require('fs'),
path = require('path'),
url = require('url'),
phantom = require('phantom'),
port = 8080;
const decks = ["the_Unholy_Priest_update_2", "NZoths_Invasion_1.2", "Puffer_Fish_input_1.3"];
const server = http.createServer((req, res) => {
const uri = url.parse(req.url);
let pathParts = uri.pathname.split("/");
switch (pathParts[1]) {
case '':
case 'index.html':
sendIndex(res);
break;
case 'css':
switch (pathParts[2]) {
case 'playfield.css':
case 'editor.css':
case 'common.css':
sendFile(res, path.join('css', pathParts[2]), 'text/css');
break;
default:
send404(res, uri);
}
break;
case 'js':
switch (pathParts[2]) {
case 'playfield.js':
case 'editor.js':
case 'interact.js':
sendFile(res, path.join('js', pathParts[2]), 'application/javascript');
break;
default:
send404(res, uri);
}
break;
case 'template':
pathParts.splice(0, 2); // remove first two elements
let item = pathParts.join("/");
console.log("template/" + item);
switch (item) {
case "card.json":
case "deck.json":
case "environment/input.json":
case "hero/input.json":
case "villain/input.json":
sendFile(res, path.join("template", item), 'application/json');
break;
case "environment/deck.svg":
case "hero/deck.svg":
case "hero/character-back.svg":
case "hero/character.svg":
case "villain/deck.svg":
case "villain/character.svg":
case "villain/instructions.svg":
sendFile(res, path.join("template", item), 'image/svg+xml');
break;
default:
send404(res, uri);
}
break;
case 'deck':
if (pathParts.length < 3 || pathParts[2] === '') {
sendIndex(res);
break;
}
let deckName = decodeURI(pathParts[2]);
switch (pathParts[3] || '') {
case '':
sendDeckIndex(res, deckName);
break;
case 'play':
sendFile(res, 'html/playfield.html');
break;
case 'editor':
sendFile(res, 'html/editor.html');
break;
case 'deck.png':
sendFile(res, `decks/${deckName}.png`, 'image/png');
break;
case 'deck.json':
sendFileJSON(res, deckName);
break;
case 'deck.tts.json':
sendFile(res, `decks/${deckName}.json`, 'application/json');
break;
case 'deck.input.json':
sendFile(res, `decks/${deckName}.input.json`, 'application/json');
break;
case 'upload':
handleUpload(res, req);
break;
default:
send404(res, uri);
}
break;
default:
send404(res, uri);
}
});
server.listen(process.env.PORT || port);
console.log('listening on 8080');
function sendIndex(res) {
const html = `
<html>
<head>
<link rel="stylesheet" type="text/css" href="/css/common.css">
<title>Index</title>
</head>
<body>
<label>Create New Deck: <input type="text" onchange="window.location='/deck/' + event.target.value + '/editor'"></label>
<ul>
${(decks.map(d => `<li><a href="/deck/${d}">${d}</a></li>`).join(' '))}
</ul>
</body>
</html>`;
res.writeHead(200, {'Content-type': 'text/html; charset=utf-8'});
res.end(html, 'utf-8');
}
function sendDeckIndex(res, deckName) {
const html = `
<html>
<head>
<link rel="stylesheet" type="text/css" href="/css/common.css">
<title>${deckName}</title>
</head>
<body>
<ul>
<li><a href="${deckName}/play">Play!</a></li>
<li><a href="${deckName}/editor">Editor</a></li>
</ul>
</body>
</html>`;
res.writeHead(200, {'Content-type': 'text/html; charset=utf-8'});
res.end(html, 'utf-8');
}
function sendFileJSON(res, deckName) {
fs.readFile(`decks/${deckName}.json`, (error, content) => {
console.log(JSON.parse(content));
res.writeHead(200, {'Content-type': 'application/json; charset=utf-8'});
res.end(JSON.stringify(JSON.parse(content).ObjectStates[0]), 'utf-8');
if (error) {
console.error(error);
}
});
}
function handleUpload(res, req) {
let body = '';
req.on('data', data => {
body += data;
// check for file > 100MB
if (body.length > 1e8) {
req.connection.destroy();
console.log('upload too big');
}
});
req.on('end', () => {
const json = JSON.parse(body);
const deckJSON = json.json;
const cardTemplate = fs.readFileSync('template/card.json');
const template = JSON.parse(fs.readFileSync(`template/${deckJSON.type}/input.json`));
const cardCount = Object.entries(template.cardTypes)
.map(ct => deckJSON[ct[0]].length * (ct[1].back ? 2 : 1))
.reduce((sum, current) => sum + current, 0);
let deckOut = JSON.parse(fs.readFileSync('template/deck.json'));
deckOut.ObjectStates[0].Nickname = deckJSON.name;
Object.assign(deckOut.ObjectStates[0].CustomDeck['1'],
{NumWidth: Math.ceil(Math.sqrt(cardCount)),
NumHeight: Math.ceil(Math.sqrt(cardCount)),
FaceURL: `http://${req.headers.host}/deck/${deckJSON.name}/deck.png`,
BackURL: "http://cloud-3.steamusercontent.com/ugc/156906385556221451/CE2C3AFE1759790CB0B532FFD636D05A99EC91F4/"});
let index = 100;
deckOut.ObjectStates[0].ContainedObjects =
Object.entries(template.cardTypes).map(
cardType => deckJSON[cardType[0]].map(cardIn => {
let cardOut = JSON.parse(cardTemplate);
Object.assign(cardOut, {Nickname: cardIn.name,
Description: cardIn.keywords,
CardID: index});
for (let ii=0; ii<(cardIn.count || 1); ii++) {
deckOut.ObjectStates[0].DeckIDs.push(index);
}
index++;
if(cardType[1].back) {
let cardBack = JSON.parse(cardTemplate);
Object.assign(cardBack, {Nickname: cardIn.back.name,
Description: cardIn.back.keywords,
CardID: index});
cardOut.States = {"2": cardBack};
index++;
}
return cardOut;
}))
.reduce((sum, cur) => sum.concat(cur), []);
fs.writeFileSync(`decks/${deckJSON.name}.json`, JSON.stringify(deckOut));
fs.writeFileSync(`decks/${deckJSON.name}.input.json`, JSON.stringify(deckJSON));
console.log("making page");
phantom.create().then(
ph => ph.createPage().then(
page => {
page.on('onLoadFinished', status => {
if (status !== 'success') {
console.log('Failed to load page');
ph.exit(1);
}
else {
page.render(`decks/${deckJSON.name}.png`);
page.close().then(() => ph.exit());
}
});
page.property('zoomFactor', 2); // pretty arbitrary
page.property('content', '<body style="margin:0;">' + json.body + '</body>');
}));
decks.push(deckJSON.name);
});
}
function send404(res, uri) {
res.writeHead(404, {'Content-type': "text/html; charset=utf-8"});
const html = `
<head>
<title>404 Not Found</title>
<link rel="stylesheet" href="/css/common.css">
</head>
<body>
<h1>Error 404: Path ${uri.pathname} not found</h1>
You seem to have gone to the wrong place, would you like to go
back to the <a href="/">main page</a>?
</body>`;
res.end(html, 'utf-8');
}
function sendFile(res, filename, contentType='text/html; charset=utf-8') {
fs.readFile(filename, (error, content) => {
res.writeHead(200, {'Content-type': contentType});
res.end(content, 'utf-8');
if (error) {
console.error(error);
}
});
}

9
src/App.vue Normal file
View File

@ -0,0 +1,9 @@
<template>
<router-view></router-view>
</template>
<script>
export default {
name: 'App',
};
</script>

32
src/DeckIndex.vue Normal file
View File

@ -0,0 +1,32 @@
<template>
<body>
<label>Create New Deck:
<input type="text"
@change="window.location='/deck/' + event.target.value + '/editor'">
</label>
<ul>
<li v-for="deck in decks">
{{ deck }}:
<router-link :to="'/play/' + deck"> Play </router-link>
<router-link :to="'/edit/' + deck"> Edit </router-link>
</li>
</ul>
</body>
</template>
<script>
export default {
name: 'DeckIndex',
data() {
return {
decks: null,
}
},
created() {
fetch('/decks.json')
.then(r => r.json())
.then(d => this.decks = d);
},
}
</script>

187
src/Editor.vue Normal file
View File

@ -0,0 +1,187 @@
<template>
<div>
<div id="controls">
<div>
<button type="button" @click="upload"> Save Deck </button>
<button type="button" @click="jsonInputDownload">
Download Input JSON
</button>
<button type="button"
@click="downloadFile('deck.tts.json', deckName + '.json')">
Download Tabletop Output JSON
</button>
</div>
<div>
<label> Upload JSON: WARNING: WILL CLEAR DECK
<input @change="jsonUpload" type="file">
</label>
</div>
<form>
<div>
<label> Deck Name: <input type="text" v-model="deckName"> </label>
</div>
<div>
<label> Deck Type:
<select v-model="deckInfo.type">
<option value="hero">hero</option>
<option value="villain">villain</option>
<option value="environment">environment</option>
</select>
</label>
</div>
</form>
</div>
<div id="cardEditor" v-if="selected">
<button class="close-editor" @click="selected = null">X</button>
<div v-for="(type, prop) in selectedCardProps">
<label> {{ prop }}
<input v-if="type === Number" v-model="selected.card[prop]"/>
<input v-if="type === 'file'" v-model="selected.card[prop]"/>
<textarea v-else rows="1" v-model="selected.card[prop]"> </textarea>
</label>
</div>
</div>
<div ref="deck" :style="deckStyle">
<div v-for="card in cards" @click="selected = card">
<Hero v-bind="card.card"> </Hero>
</div>
</div>
</div>
</template>
<script>
import Hero from './template/hero/Hero.vue'
export default {
name: 'Editor',
components: {Hero},
props: ['deckName'],
data() {
return {
selected: null,
deckInfo: {},
};
},
watch: {
deckName() {
document.title = "Editor|" + deckName;
},
},
created() {
fetch('/decks/' + this.deckName + '.input.json')
.then(r => r.json())
.then(j => this.deckInfo = j);
/* window.addEventListener(
* 'beforeunload', e => e.returnValue = "Unsaved changes blah blah"); */
},
computed: {
cards() {
return Object
.keys(this.deckInfo)
.filter(cardType => typeof this.deckInfo[cardType] !== 'string')
.flatMap(cardType => this.deckInfo[cardType].flatMap((card, index) => {
let cardWrapper = {
type: cardType,
card: this.deckInfo[cardType][index],
};
return Array(card.count || 1).fill(cardWrapper);
}));
},
deckStyle() {
// find minimum box to fit cards
let rows = Math.ceil(Math.sqrt(this.cards.length));
let columns = this.cards.length > rows * (rows - 1) ? rows : rows - 1;
return {
display: 'grid',
'grid-template-columns': `repeat(${rows}, 1fr)`,
'grid-template-rows': `repeat(${columns}, 1fr)`,
};
},
selectedCardProps() {
// todo: make better
return Hero.props;
},
},
methods: {
// deck JSON uploader
jsonUpload(event) {
let files = event.target.files;
let reader = new FileReader();
reader.onload = event => {
this.deckInfo = JSON.parse(event.target.result);
};
reader.readAsText(files[0]);
},
// download input JSON
jsonInputDownload() {
console.log(JSON.stringify(this.deckInfo));
this.downloadFile('data:application/json;charset=utf-8,' +
encodeURIComponent(JSON.stringify(this.deckInfo)),
this.deckName + '.input.json')
},
// chrome doesn't seem to send input event on file select
fileUploaded(event) {
let prop = event.target.id.substring(5);
if (prop === "image") {
let files = event.target.files;
let reader = new FileReader();
reader.onload = e => {
selected.svg.querySelector('#' + prop)
.setAttributeNS("http://www.w3.org/1999/xlink", "href", e.target.result);
selected.json[prop] = e.target.result;
};
reader.readAsDataURL(files[0]);
}
},
downloadFile(file, name) {
let dl = document.createElement('a');
dl.setAttribute('href', file);
dl.setAttribute('download', name);
document.body.appendChild(dl);
dl.click();
document.body.removeChild(dl);
},
upload() {
// POST the generated SVGs to the server
let data = (new XMLSerializer()).serializeToString(this.$refs.deck);
fetch('upload', {
method: 'post',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({body: data, json: this.deckInfo})
});
},
},
}
</script>
<style>
#cardEditor {
position: fixed;
top: 0;
right: 0;
background-color: gray;
padding: 10px;
border-radius: 3px;
}
.close-editor {
float: right;
}
</style>

4
src/index.html Normal file
View File

@ -0,0 +1,4 @@
<body>
<div id="charSheet"> </div>
<script type="text/javascript" src="index.js"></script>
</body>

21
src/index.js Normal file
View File

@ -0,0 +1,21 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
import App from './App.vue';
import DeckIndex from './DeckIndex.vue';
import Editor from './Editor.vue';
Vue.use(VueRouter);
const router = new VueRouter({
routes: [
{path: '/', component: DeckIndex},
{path: '/edit/:deckName', component: Editor, props: true},
],
});
new Vue({
el: '#charSheet',
render: h => h(App),
router,
});

109
src/server.js Normal file
View File

@ -0,0 +1,109 @@
// jshint node:true
// jshint esversion:6
"use strict";
const express = require('express'),
Bundler = require('parcel-bundler'),
fs = require('fs'),
path = require('path'),
phantom = require('phantom'),
port = process.env.PORT || 1234;
const decks = ["the_Unholy_Priest_update_2",
"NZoths_Invasion_1.2",
"Puffer_Fish_input_1.3"];
const app = express();
app.use(express.json());
app.use('/template', express.static('template'));
app.use('/decks', express.static('decks'));
app.get('/decks.json', (req, res) => res.json(decks));
app.post('/upload', handleUpload);
let bundler = new Bundler(path.join(__dirname, 'index.html'));
app.use(bundler.middleware());
app.listen(port, () => console.log(`App listening on port ${port}!`));
function handleUpload(req, res) {
const json = request.body;
const deckJSON = json.json;
const cardTemplate = fs.readFileSync('template/card.json');
const template = JSON.parse(fs.readFileSync(`template/${deckJSON.type}/input.json`));
const cardCount = Object.entries(template.cardTypes)
.map(ct => deckJSON[ct[0]].length * (ct[1].back ? 2 : 1))
.reduce((sum, current) => sum + current, 0);
let deckOut = JSON.parse(fs.readFileSync('template/deck.json'));
deckOut.ObjectStates[0].Nickname = deckJSON.name;
Object.assign(deckOut.ObjectStates[0].CustomDeck['1'],
{NumWidth: Math.ceil(Math.sqrt(cardCount)),
NumHeight: Math.ceil(Math.sqrt(cardCount)),
FaceURL: `http://${req.headers.host}/deck/${deckJSON.name}/deck.png`,
BackURL: "http://cloud-3.steamusercontent.com/ugc/156906385556221451/CE2C3AFE1759790CB0B532FFD636D05A99EC91F4/"});
let index = 100;
deckOut.ObjectStates[0].ContainedObjects =
Object.entries(template.cardTypes).map(
cardType => deckJSON[cardType[0]].map(cardIn => {
let cardOut = JSON.parse(cardTemplate);
Object.assign(cardOut, {Nickname: cardIn.name,
Description: cardIn.keywords,
CardID: index});
for (let ii=0; ii<(cardIn.count || 1); ii++) {
deckOut.ObjectStates[0].DeckIDs.push(index);
}
index++;
if(cardType[1].back) {
let cardBack = JSON.parse(cardTemplate);
Object.assign(cardBack, {Nickname: cardIn.back.name,
Description: cardIn.back.keywords,
CardID: index});
cardOut.States = {"2": cardBack};
index++;
}
return cardOut;
}))
.reduce((sum, cur) => sum.concat(cur), []);
fs.writeFileSync(`decks/${deckJSON.name}.json`, JSON.stringify(deckOut));
fs.writeFileSync(`decks/${deckJSON.name}.input.json`, JSON.stringify(deckJSON));
console.log("making page");
phantom.create().then(
ph => ph.createPage().then(
page => {
page.on('onLoadFinished', status => {
if (status !== 'success') {
console.log('Failed to load page');
ph.exit(1);
}
else {
page.render(`decks/${deckJSON.name}.png`);
page.close().then(() => ph.exit());
}
});
page.property('zoomFactor', 2); // pretty arbitrary
page.property('content', '<body style="margin:0;">' + json.body + '</body>');
}));
decks.push(deckJSON.name);
}
function send404(res, uri) {
res.writeHead(404, {'Content-type': "text/html; charset=utf-8"});
const html = `
<head>
<title>404 Not Found</title>
<link rel="stylesheet" href="/css/common.css">
</head>
<body>
<h1>Error 404: Path ${uri.pathname} not found</h1>
You seem to have gone to the wrong place, would you like to go
back to the <a href="/">main page</a>?
</body>`;
res.end(html, 'utf-8');
}

View File

Before

Width:  |  Height:  |  Size: 278 KiB

After

Width:  |  Height:  |  Size: 278 KiB

View File

@ -0,0 +1,26 @@
<template>
<v-runtime-template :template="template"> </v-runtime-template>
</template>
<script>
import fs from 'fs';
import VRuntimeTemplate from "v-runtime-template";
const templates = {
'character': fs.readFileSync(__dirname + '/character.svg', 'utf8'),
'character-back': fs.readFileSync(__dirname + '/character-back.svg', 'utf8'),
'deck': fs.readFileSync(__dirname + '/deck.svg', 'utf8'),
}
export default {
name: 'HeroCharacter',
props: ['name', 'hp', 'keywords', 'text', 'quote', 'quoteCitation', 'artist'],
components: {VRuntimeTemplate},
data() {
return {
template: templates['deck'],
}
},
}
</script>

View File

Before

Width:  |  Height:  |  Size: 845 B

After

Width:  |  Height:  |  Size: 845 B

View File

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

View File

@ -310,21 +310,23 @@
<path d="m133.05 234.55c-7.6094 4.4883-21.801 7.2969-31.816 8.4258h-56.82c-17.391-2.0781-28.762-5.7461-34.582-10.348l4e-3 -9.8086c7.7344-6.6289 31.883-12.094 62.988-12.094 36.301 0 65.734 7.6094 65.734 17 0 0.36719-0.13672 1.0977-0.13672 1.0977 10.293 1.4922 9.9648 10.367 9.9648 10.367-7.5196-7.125-15.336-4.6406-15.336-4.6406z" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="1.25"/> <path d="m133.05 234.55c-7.6094 4.4883-21.801 7.2969-31.816 8.4258h-56.82c-17.391-2.0781-28.762-5.7461-34.582-10.348l4e-3 -9.8086c7.7344-6.6289 31.883-12.094 62.988-12.094 36.301 0 65.734 7.6094 65.734 17 0 0.36719-0.13672 1.0977-0.13672 1.0977 10.293 1.4922 9.9648 10.367 9.9648 10.367-7.5196-7.125-15.336-4.6406-15.336-4.6406z" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="1.25"/>
<path d="m171.5 27.502h-162v121.59h162z" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2"/> <path d="m171.5 27.502h-162v121.59h162z" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2"/>
<g font-family="'RedStateBlueState BB'"> <g font-family="'RedStateBlueState BB'">
<g id="keywordBox"> <text x="14.173298" y="24.167526" font-family="'CrashLanding BB'" font-size="23px"> {{ name }} </text>
<g v-if="keywords">
<path d="m126.21 156.92h-106.91v-11.305h106.91z" fill="#f8c711"/> <path d="m126.21 156.92h-106.91v-11.305h106.91z" fill="#f8c711"/>
<use transform="translate(18.45,145.7) matrix(.68531 0 0 .70654 .85059 .10938)" width="100%" height="100%" mask="url(#f)" xlink:href="#l"/> <use transform="translate(18.45,145.7) matrix(.68531 0 0 .70654 .85059 .10938)" width="100%" height="100%" mask="url(#f)" xlink:href="#l"/>
<path d="m126.96 144.87h-108.41v12.805h108.41zm-0.75 12.055h-106.91v-11.305h106.91z"/> <path d="m126.96 144.87h-108.41v12.805h108.41zm-0.75 12.055h-106.91v-11.305h106.91z"/>
<text id="keywords" x="21.05547" y="154.41197" font-family="'RedStateBlueState BB'" font-size="11.5px" font-weight="bold">Keywords</text> <text x="21.05547" y="154.41197" font-size="11.5px" font-weight="bold"> {{ keywords }} </text>
</g> </g>
<text id="name" x="14.173298" y="24.167526" font-family="'CrashLanding BB'" font-size="23px">Header</text> <g v-if="hp">
<g id="hpMark">
<path d="m171.5 9.5h-29.047l-17.832 3.8359 16.25 5.9336-14.375 8.5625 13.312-0.25-9.5 13.379 15.312-8.2539-8.25 15.941 13.875-13.004 5.0625 13.754 7.125-19.379 6.9375 4.3125-3.0898-9.9141 4.2188-0.25z" fill="url(#k)" stroke="#000" stroke-width=".8"/> <path d="m171.5 9.5h-29.047l-17.832 3.8359 16.25 5.9336-14.375 8.5625 13.312-0.25-9.5 13.379 15.312-8.2539-8.25 15.941 13.875-13.004 5.0625 13.754 7.125-19.379 6.9375 4.3125-3.0898-9.9141 4.2188-0.25z" fill="url(#k)" stroke="#000" stroke-width=".8"/>
<text id="hp" x="154.03" y="30.44" fill="#000000" font-family="'CrashLanding BB'" font-size="35px" text-align="center" text-anchor="middle">3</text> <text x="154.03" y="30.44" fill="#000000" font-family="'CrashLanding BB'" font-size="35px" text-align="center" text-anchor="middle"> {{ hp }} </text>
</g> </g>
<image id="image" x="21" y="34" width="162px" height="121px" preserveAspectRatio="xMidYMid"/> <image id="image" x="21" y="34" width="162px" height="121px" preserveAspectRatio="xMidYMid"/>
<text id="text" x="14.48318" y="165.95828" width="152" height="48.5" font-family="'RedStateBlueState BB'" font-size="8.7px">Text Here</text> <foreignObject x="14.5" y="160" width="152" height="48.5" font-family="'RedStateBlueState BB'" font-size="8.7px">
<text id="quote" x="72.514046" y="221.9552" font-size="6.8px" font-style="italic" text-align="center" text-anchor="middle">"Type Quote Here!"</text> <div xmlns="http://www.w3.org/1999/xhtml" v-html="text"> {{ text }} </div>
<text id="quoteCitation" x="101.80201" y="229.85782" font-size="5.8px" text-align="end" text-anchor="end">- Name, Comic #</text> </foreignObject>
<text id="artist" x="168.4621" y="247.85452" fill="#ffffff" font-family="'RedStateBlueState BB'" font-size="5.3px" text-align="end" text-anchor="end">Art By</text> <text x="72.514046" y="222" font-size="6.8px" font-style="italic" text-align="center" text-anchor="middle"> {{ quote }} </text>
<text x="101.80201" y="230" font-size="5.8px" text-align="end" text-anchor="end">- {{ quoteCitation }} </text>
<text x="168.4621" y="247.85452" fill="#ffffff" font-family="'RedStateBlueState BB'" font-size="5.3px" text-align="end" text-anchor="end"> Art By {{ artist }} </text>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 314 KiB

After

Width:  |  Height:  |  Size: 314 KiB

View File

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

View File

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 316 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB