all repos — felt @ f1c56f63f18f9064f5c7ebd67cc96542b703647c

virtual tabletop for dungeons and dragons (and similar) using Go, MongoDB, and websockets

fix table reconnect on lag; keep scroll on token select and master list when refreshing; add token copy; v0.2.0
Iris Lightshard nilix@nilfm.cc
PGP Signature
-----BEGIN PGP SIGNATURE-----

iQIzBAABCAAdFiEEkFh6dA+k/6CXFXU4O3+8IhROY5gFAmS0tbEACgkQO3+8IhRO
Y5ixfw/+LPwwy+19KrFmgY2HY0mnNMm/LZG9lsLMA5x7FYrrSNzuE3TQu1F+LMRe
mu8FYcYh1oXnx5ulBTggzZuXz7ICVlkYn34SWvwLrH1m7rXPaOM94L4M7PRTXXuQ
AznyGuy7wS09YniY7L3CBC+jrqg7xv86I4rXQMzPrDd5d77Bn/26J1phZlBejqTT
7iDbYoOWXpO9R4GcZeaDom/DEe0F9k4kRrMQZCGwKpBFxhvB1BamkRAAWZZXXkeI
3P+ZgwS0zzVYvNlfFtFSs7X3n3K/9Cm+l2qkZ+uI0LJ/pv5PD17NBHZ1uKjo49eA
KRcXFbdFwp0wmshlhmlHGu0VybPQ5whzgERXT0Sw5SLeIh+yyk3uVA+lBaBE/n5L
uwx8j5hVl5iezaVE6EE6J2PDv7RwBMfvTwrzp5BvY2PGv9OPdFmpMJcy24KiN1ZP
oAsE9SrHlIi/mhrl3nqra1k5WsaH5Dihh3w7WLPq4fAf0qIt2ZPxaJI14lQPsE8m
3LjiL4Vv3YvCuigbLuPi9taOZeMzxED1ebyVxus4XVsn9zcVhuuEiXgwGTQbgnTh
H+48zskXjKVI/+3UJU3JYhvc1JlrB6y5gNwwXV32DrZid6bC7ZwL7JEfAHWrELaU
6hPn5pph/1Vi1r9g0vs11bqhgqN4dGVnOuqR30r5B1wDrhhQce8=
=S5Sn
-----END PGP SIGNATURE-----
commit

f1c56f63f18f9064f5c7ebd67cc96542b703647c

parent

196294b5b8183e107fd28f03bef454961fb38ff8

M README.mdREADME.md

@@ -38,9 +38,11 @@ Joining a table also grants the admin access to the `tokens` and `sprites` panels. The `sprites` panel allows the admin to upload, view, and delete images for use in tokens. The `tokens` panel gives an interface to create tokens as well as a list of existing tokens.

After hitting the `New Token` button, the list of tokens is replaced with a form. The sprite can be selected from a dropdown (which sets the other fields to resonable defaults), and the name, width, height, and coordinate origin of the token can be adjusted. There is a toggle for holding the aspect ratio of the token constant, and as changes are made a live preview similar to the one in the `token select` panel is updated (with the addition of a small pink cross indicating the coordinate origin). -In the `tokens` list, a token name can be clicked to live preview or deleted with the button to the right of the name. +*Scene-building note* - The coordinate origin determintes the layering of the tokens - tokens are ordered on the z-axis based on their vertical coordinate - eg scenic tokens which should be underneath characters can have their coordinate origin at the top of the sprite, and tokens which should be rendered above others in the scene can have their coordinate origin at or below the bottom edge. -Creating or destroying tokens as well as changing the map causes updates to be sent to all clients at the same table in the same manner as (de)activating and moving tokens. +In the `tokens` list, a token name can be clicked to live preview, or you can use the buttons at right to copy (opens the form with the token's data - you can, eg, change the name and create the copy quickly) or destroy the token. + +Creating or destroying tokens as well as changing the map causes updates to be sent to all clients at the same table in the same manner as (de)activating and moving tokens, updating status, or rolling dice. ## build, run, deploy
M static/admin.jsstatic/admin.js

@@ -63,7 +63,6 @@ adminZone.innerHTML = infoHtml;

let tokenListHTML = "<input id='token_img_upload' type='file'/><button onclick='uploadTokenImg()'>Upload Sprite</button><br/>"; if (tokenImgs.ok) { - tokenListHTML += "<label>Available Sprites</label>"; const tokens = (await tokenImgs.json()).sort(); tokenListHTML += "<ul class='single_btn_list'>"; for (const t of tokens) {

@@ -129,6 +128,11 @@ if (mapImg && mapImg._image) {

const scaleFactor = mapImg._image.clientWidth / mapImg._image.naturalWidth; const keepAspect = tokenAspect.checked; const img = previewZone.children[0]; + + tokenHeight.value = Math.floor(Number(tokenHeight.value)) + tokenWidth.value = Math.floor(Number(tokenWidth.value)) + + if (img) { if (!keepAspect || !source) { img.width = Number(tokenWidth.value) * scaleFactor;

@@ -140,12 +144,12 @@ switch (source.id) {

case "token_width": img.width = Number(tokenWidth.value) * scaleFactor; img.height = (img.clientWidth / img.naturalWidth) * img.naturalHeight; - tokenHeight.value = Number(tokenWidth.value)/currentAspect; + tokenHeight.value = Math.floor(Number(tokenWidth.value)/currentAspect); break; case "token_height": img.height = Number(tokenHeight.value) * scaleFactor; img.width = (img.clientHeight / img.naturalHeight) * img.naturalWidth; - tokenWidth.value = currentAspect * Number(tokenHeight.value); + tokenWidth.value = Math.floor(currentAspect * Number(tokenHeight.value)); break; } }

@@ -159,6 +163,10 @@

function drawTokenOrigin() { if (tokenSpriteDropdown.selectedIndex >= 0) { + + tokenCX.value = Math.floor(Number(tokenCX.value)) + tokenCY.value = Math.floor(Number(tokenCY.value)) + const img = previewZone.children[0]; const x = Number(tokenWidth.value) / Number(tokenCX.value); const y = Number(tokenHeight.value) / Number(tokenCY.value);

@@ -257,14 +265,20 @@ reinitializeSpritePreview();

} } +function copyToken(id) { + setTokenCreateFormVisible(true); + previewExistingToken(id); +} + function renderTokenMasterList() { if (tokenZone) { - let tokenMasterListHTML = "<label>Available Tokens</label><br/><ul class='single_btn_list'>"; + let tokenMasterListHTML = ""; + const scroll = tokenZone.scrollTop; for (const t of tokens) { - tokenMasterListHTML += `<li><a href="#" onclick="previewExistingToken('${t.t.id}');return false">${t.t.name}</a><button onclick="destroyToken('${t.t.id}')">Destroy</button></li>\n`; + tokenMasterListHTML += `<li><a href="#" onclick="previewExistingToken('${t.t.id}');return false">${t.t.name}</a><button onclick="copyToken('${t.t.id}')">Copy</button><button onclick="destroyToken('${t.t.id}')">Destroy</button></li>\n`; } - tokenMasterListHTML += "</ul>"; tokenZone.innerHTML = tokenMasterListHTML; + tokenZone.scrollTop = scroll; } }

@@ -409,9 +423,10 @@ tableListHTML += "</ul>"

adminZone.innerHTML = tableListHTML; tokenWrapper.style.display = "none"; } else { - // fail silently here + // fail silently } } catch { + // fail silently } }

@@ -484,7 +499,7 @@ tokenName.value = "";

tokenSpriteDropdown.selectedIndex = 0; } createTokenForm.style.display = v ? "block" : "none"; - tokenZone.style.display = v ? "none" : "inline"; + tokenZone.style.display = v ? "none" : "block"; reinitializeSpritePreview(); } }
M static/index.htmlstatic/index.html

@@ -6,7 +6,7 @@ <title>Felt</title>

<meta name="viewport" content="width=device-width" /> <link rel="shortcut icon" href="./favicon.png"/> <link href="./leaflet.css?v=1.9.4" rel="stylesheet" /> - <link href="./style.css?v=0.1.0" rel="stylesheet" /> + <link href="./style.css?v=0.2.0" rel="stylesheet" /> </head> <body> <noscript><div id="noscript_container">

@@ -77,7 +77,7 @@ <details class="ui_win"><summary>token select</summary>

<button id="tokenPreview_alt_clear" onclick="dismissPreview()" style="display: none;">Dismiss Preview</button> <div id="tokenPreview_alt"></div> <input hidden id="tokenPreview_alt_id"/> - <div id="token_select"></div> + <ul id="token_select" class="single_btn_list"></ul> </details><br/> </div>

@@ -103,6 +103,7 @@ <button onclick="setTableCreateFormVisible(false)">Cancel</button>

</form> <div id="adminZone"></div> </details><br/> + <div id="adminWrapper_tokens"> <details id="admin_token_win" class="ui_win admin_win"><summary>tokens</summary> <button onclick="setTokenCreateFormVisible(true)">New Token</button>

@@ -110,20 +111,22 @@ <form onsubmit="return false" id="createTokenForm" style="display:none;">

<label>Sprite<select id="token_combobox" onchange="previewSprite(this)"></select></label><br/> <label>Name<input id="token_name"/></label><br/> - <label>Width<input type="number" id="token_width" min="1" max="9999" onchange="previewSprite(this)"/></label><label id="aspectLockLabel" for="tokenKeepAspect">&#128274;</label><input type="checkbox" checked id="tokenKeepAspect" onchange="toggleAspectLock()"/><br/> - <label>Height<input type="number" id="token_height" min="1" max="9999" onchange="previewSprite(this)"/></label><br/> - <label>cX<input type="number" id="token_cx" min="0" max="9999" onchange="previewSprite(this)"/></label><br/> - <label>cY<input type="number" id="token_cy" min="0" max="9999" onchange="previewSprite(this)"/></label><br/> + <label>Width<input type="number" id="token_width" min="1" max="9999" step="1" onchange="previewSprite(this)"/></label><label id="aspectLockLabel" for="tokenKeepAspect">&#128274;</label><input type="checkbox" checked id="tokenKeepAspect" onchange="toggleAspectLock()"/><br/> + <label>Height<input type="number" id="token_height" min="1" step="1" max="9999" onchange="previewSprite(this)"/></label><br/> + <label>cX<input type="number" id="token_cx" min="0" max="9999" step="1" onchange="previewSprite(this)"/></label><br/> + <label>cY<input type="number" id="token_cy" min="0" max="9999" step="1" onchange="previewSprite(this)"/></label><br/> <button type="submit" onclick="createToken()">Create</button> <button onclick="setTokenCreateFormVisible(false)">Cancel</button> </form> <div id="tokenPreview_zone"></div> - <div id="tokenZone"></div> + <ul id="tokenZone" class="two_btn_list"></ul> </details><br/> + <details id="admin_sprite_win" class="ui_win admin_win"><summary>sprites</summary> <div id="spriteZone"></div> </details> </div> + </div> </section>

@@ -143,13 +146,13 @@ <button onclick="setTheme()">Apply</button><button onclick="resetTheme(defaultTheme)">Reset</button>

</form> </details> <div id="lag" style="display:none;">lag...</div> - <div class="ui_win" id="felt_info"><a href="https://hacklab.nilfm.cc/felt">felt v0.1.0</a> (<a href="https://hacklab.nilfm.cc/felt/raw/main/LICENSE">license</a>) | built with <a href="https://leafletjs.com">leaflet</a> (<a href="https://hacklab.nilfm.cc/felt/raw/main/LEAFLET_LICENSE">license</a>) </div> + <div class="ui_win" id="felt_info"><a href="https://hacklab.nilfm.cc/felt">felt v0.2.0</a> (<a href="https://hacklab.nilfm.cc/felt/raw/main/LICENSE">license</a>) | built with <a href="https://leafletjs.com">leaflet</a> (<a href="https://hacklab.nilfm.cc/felt/raw/main/LEAFLET_LICENSE">license</a>) </div> </nav> </body> <script src="./leaflet.js?v=1.9.4" type="text/javascript"></script> - <script src="./util.js?v=0.1.0" type="text/javascript"></script> - <script src="./map.js?v=0.1.0" type="text/javascript"></script> - <script src="./socket.js?v=0.1.0" type="text/javascript"></script> - <script src="./dice.js?v=0.1.0" type="text/javascript"></script> - <script src="./admin.js?v=0.1.0" type="text/javascript"></script> + <script src="./util.js?v=0.2.0" type="text/javascript"></script> + <script src="./map.js?v=0.2.0" type="text/javascript"></script> + <script src="./socket.js?v=0.2.0" type="text/javascript"></script> + <script src="./dice.js?v=0.2.0" type="text/javascript"></script> + <script src="./admin.js?v=0.2.0" type="text/javascript"></script> </html>
M static/map.jsstatic/map.js

@@ -1,6 +1,6 @@

let map = null; let mapImg = null; -let tokens = []; +const tokens = []; const worldBounds = [[180, -180], [-180, 180]]; const cameraBounds = [[270, -270], [-270, 270]];

@@ -26,10 +26,11 @@ // this works but assumes the map is square (reasonable limitation I think)

function resizeMarkers() { // for newly created tokens, the icon may not be loaded and thus the resize will not properly complete - // we need a way to queue the resize for when the icon is finished loading + // TODO: we need a way to queue the resize for when the icon is finished loading tokens.forEach(t=>{ const icon = t.m.options.icon; + const scaleFactor = mapImg._image.clientWidth / mapImg._image.naturalWidth; icon.options.iconSize = [scaleFactor * t.t.w, scaleFactor * t.t.h];
M static/socket.jsstatic/socket.js

@@ -2,7 +2,6 @@ let tableKey = {

name: "", passcode: "" } -let table = null; let conn = null; let offline = false;

@@ -77,12 +76,13 @@ }

function renderTokenSelect() { const tokenSelect = document.getElementById("token_select"); - let tokenSelectHTML = "<ul class='single_btn_list'>"; + let tokenSelectHTML = "" + const scroll = tokenSelect.scrollTop; for (const t of tokens) { tokenSelectHTML += `<li><a href="#" onclick="initSpritePreviewById('${t.t.id}')">${t.t.name}</a><button onclick="toggleActive('${t.t.id}');return false">${(t.t.active ? "Deactivate" : "Activate")}</button></li>\n`; } - tokenSelectHTML += "</ul>"; tokenSelect.innerHTML = tokenSelectHTML; + tokenSelect.scrollTop = scroll; } // the following few functions aren't socket related but they directly relate to the previous function

@@ -123,26 +123,29 @@ }

} } -function makeUpToDate(table) { - if (table) { +function makeUpToDate(msg) { + if (msg) { + // map image has to be set before tokens can be handled! - if (table.mapImg) { - setMapImg(table.mapImg); + if (msg.mapImg) { + setMapImg(msg.mapImg); } - if (table.auxMsg) { - setAuxMsg(table.auxMsg); + + if (msg.auxMsg) { + setAuxMsg(msg.auxMsg); } - if (table.diceRolls) { - logDice(table.diceRolls); - } else if (table.diceRoll) { - logDice(table.diceRoll); + + if (msg.diceRolls) { + logDice(msg.diceRolls); + } else if (msg.diceRoll) { + logDice(msg.diceRoll); } - if (table.tokens) { - updateTokens(table.tokens); - } else if (table.token) { - updateTokens([table.token]); + + if (msg.tokens) { + updateTokens(msg.tokens); + } else if (msg.token) { + updateTokens([msg.token]); } - } }

@@ -176,33 +179,40 @@ conn.close(1000);

} const wsProto = location.protocol == "https:" ? "wss" : "ws"; conn = new WebSocket(`${wsProto}://${location.host}/subscribe`, `${tableKey.name}.${tableKey.passcode}`); + conn.addEventListener("close", e => { offline = true; + if (e.code == 1006 && e.wasClean) { setErr("Table not found - check the name and passcode are correct"); + } else if (e.code > 1001) { lagDiv.style.display = "block"; setTimeout(dial, 1000) } else { + tblNameInput.readOnly = false; tblPassInput.readOnly = false; joinTblBtn.style.display = adminToken ? "none" : "inline"; leaveTblBtn.style.display = "none"; + tabletop = document.getElementById("tabletop"); if (tabletop) { tabletop.style.display = "none"; } - table = null; + while (tokens.some(t=>t)) { tokens[0].m.removeFrom(map); tokens.shift(); } + if (mapImg) { mapImg.removeFrom(map); mapImg = null; } } }); + conn.addEventListener("open", e => { offline = false; tblNameInput.readOnly = true;

@@ -227,15 +237,13 @@ })

conn.addEventListener("message", e => { const data = JSON.parse(e.data); - if (table == null) { - table = data; - } + if (data.diceRolls) { // dicerolls are treated as a byte array when marshalling to json, so we have to decode them data.diceRolls.forEach(r=>{ r.roll = Uint8Array.from(atob(r.roll), c => c.charCodeAt(0)) }) - makeUpToDate(table); + makeUpToDate(data); } else { if (data.diceRoll) { data.diceRoll.roll = Uint8Array.from(atob(data.diceRoll.roll), c => c.charCodeAt(0));

@@ -245,6 +253,7 @@ }

console.log(data); }); + } else { setErr("Table name and passcode can only be alphanumeric and underscores"); }
M templates/error.htmltemplates/error.html

@@ -6,7 +6,7 @@ <meta charset="UTF-8" />

<title>Felt &mdash; Error</title> <meta name="viewport" content="width=device-width" /> <link rel="shortcut icon" href="/table/favicon.png"/> - <link href="/table/style.css?v=0.1.0" rel="stylesheet" /> + <link href="/table/style.css?v=0.2.0" rel="stylesheet" /> </head> <body> <main id="registration">

@@ -15,6 +15,6 @@ <p class="error">{{ $params.ErrorMessage }}</p>

<p><a href="/table">Get back to gaming...</a></p> </main> </body> -<script src="./util.js?v=0.1.0" type="text/javascript"></script> +<script src="./util.js?v=0.2.0" type="text/javascript"></script> </html> <html>
M templates/register.htmltemplates/register.html

@@ -7,7 +7,7 @@ <meta charset="UTF-8" />

<title>Felt &mdash; Admin Registration</title> <meta name="viewport" content="width=device-width" /> <link rel="shortcut icon" href="/table/favicon.png"/> - <link href="/table/style.css?v=0.1.0" rel="stylesheet" /> + <link href="/table/style.css?v=0.2.0" rel="stylesheet" /> </head> <body> <main id="registration">

@@ -23,5 +23,5 @@ <span class="error">The registration token you provided is invalid;<br/> obtain a new one.</span>

{{end}} </main> </body> -<script src="/table/util.js?v=0.1.0" type="text/javascript"></script> +<script src="/table/util.js?v=0.2.0" type="text/javascript"></script> </html>
M templates/registered.htmltemplates/registered.html

@@ -6,7 +6,7 @@ <meta charset="UTF-8" />

<title>Felt &mdash; Registration Complete</title> <meta name="viewport" content="width=device-width" /> <link rel="shortcut icon" href="/table/favicon.png"/> - <link href="/table/style.css?v=0.1.0" rel="stylesheet" /> + <link href="/table/style.css?v=0.2.0" rel="stylesheet" /> </head> <body> <main id="registration">

@@ -19,5 +19,5 @@ <span class="error">Something went wrong; please try a different username or obtain a new registration code.</span>

{{end}} </main> </body> -<script src="/table/util.js?v=0.1.0" type="text/javascript"></script> +<script src="/table/util.js?v=0.2.0" type="text/javascript"></script> </html>