fix some bugs; add readme, manifest; bump quartzgun dep
This commit is contained in:
parent
28ac847ff7
commit
e29512f8e4
11 changed files with 177 additions and 44 deletions
58
README.md
Normal file
58
README.md
Normal file
|
@ -0,0 +1,58 @@
|
|||
# felt
|
||||
|
||||
-- virtual tabletop for distributed cooperative storytelling --
|
||||
|
||||
## about
|
||||
|
||||
Felt is a lightweight webapp written in Go and vanilla Javascript which provides an agnostic virtual tabletop for battle maps, visual puzzles, or any other situation you may need a shared map in a tabletop RPG over voice/video chat.
|
||||
|
||||
## usage
|
||||
|
||||
### player
|
||||
|
||||
Upon loading the application, the `identity` panel is expanded in the top-left. Enter your nickname here. It's saved to `localStorage`.
|
||||
|
||||
Next, expand the `goto` panel and enter the table name and passcode to join. If they are correct, you will join the table and be presented with more panels.
|
||||
|
||||
The `dice` panel lets you roll dice with notes attached and provides a timestamped log of rolls.
|
||||
|
||||
The `status` panel is updated when the admin changes the table's status, and can be used to display initiative order, other battle status, environmental or contextual notes, etc.
|
||||
|
||||
The `token select` panel provides a list of every existing token at the table. Clicking the name of the token shows a to-scale preview at the top of the list (with a button to dismiss it), which is resized according to the zoom level of the map. The button to the right of each token's name can place the token on or remove it from the map.
|
||||
|
||||
Any user can move any token on the map.
|
||||
|
||||
### admin
|
||||
|
||||
Admin accounts can be created by the sysadmin using the `invite` command on the command line. It generates an encrypted token which can be placed after `/register/` in the application URL to view an admin account creation form. The links are good for 15 minutes after generation.
|
||||
|
||||
After account creation, admins can login by providing their username in the `identity` panel and their password in the `admin` panel and hitting `Login`. The admin is presented with additional panels beyond a normal player (just the `table` panel at first).
|
||||
|
||||
The `table` panel provides the ability to create tables and view tables that belong to you. Clicking a table joins it.
|
||||
|
||||
Upon joining a table in this way, the content of the `table` panel changes. It provides a backlink to view the table list again, a `textarea` to change the table status, a button to delete the table, and a form to upload map images as well as a list of existing map images. The name of the image can be clicked to view it in a new tab, and the buttons to the right of each image name can be used to set the image as the map background or delete it.
|
||||
|
||||
*IMPORTANT NOTE* - Map images must be square; if your map is not square, add transparent padding to the image so it is.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## build, run, deploy
|
||||
|
||||
1. Clone this repository and `cd` into it.
|
||||
2. `go mod tidy`
|
||||
3. `go build`
|
||||
4. Simultaneously (from different terminals or services):
|
||||
- from the `mongodb` folder, copy the `.env.example` file to `.env` and adjust it, then run `./run.sh build`. After initial run, the `build` argument is not needed
|
||||
- from the root folder, run `./felt` - a wizard asks for the mongodb URI (check mongo docs for this) the port, uploads folder, and max upload size
|
||||
- from the root folder, run `./felt invite` to get a registration token and register an admin account as explained above
|
||||
5. When running behind a reverse proxy, you will need to make sure that the server can forward the `Upgrade: websocket` header.
|
||||
|
||||
## license
|
||||
|
||||
The `felt` code is released under the [MIT license](https://hacklab.nilfm.cc/felt/raw/main/LICENSE); [Leaflet](https://leafletjs.org), the slippy map library used in the frontend, is licensed under a [2-clause BSD license](https://hacklab.nilfm.cc/felt/raw/main/LEAFLETLICENSE). Basically do what you will but give credit.
|
|
@ -47,10 +47,23 @@ func New(adapter mongodb.DbAdapter, udb auth.UserStore, uploads string, uploadMa
|
|||
dbAdapter: adapter,
|
||||
udb: udb,
|
||||
}
|
||||
|
||||
// redirect to table if we hit the root
|
||||
srvr.serveMux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
http.Redirect(w, req, "/table/", http.StatusSeeOther)
|
||||
}));
|
||||
|
||||
// frontend is here
|
||||
srvr.serveMux.Handle("/table/", http.StripPrefix("/table/", renderer.Subtree("./static")))
|
||||
|
||||
// uploads filetree
|
||||
srvr.serveMux.Handle("/uploads/", http.StripPrefix("/uploads/", renderer.Subtree(uploads)))
|
||||
|
||||
// admin controller and admin registration controller
|
||||
srvr.serveMux.Handle("/admin/", http.StripPrefix("/admin", admin.CreateAdminInterface(udb, adapter, uploads, uploadMaxMB)))
|
||||
srvr.serveMux.Handle("/register/", http.StripPrefix("/register", register.CreateRegistrationInterface(udb, crypto)))
|
||||
|
||||
// websocket
|
||||
srvr.serveMux.HandleFunc("/subscribe", srvr.subscribeHandler)
|
||||
srvr.serveMux.HandleFunc("/publish", srvr.publishHandler)
|
||||
|
||||
|
|
2
go.mod
2
go.mod
|
@ -5,7 +5,7 @@ go 1.19
|
|||
require (
|
||||
go.mongodb.org/mongo-driver v1.12.0
|
||||
golang.org/x/time v0.1.0
|
||||
hacklab.nilfm.cc/quartzgun v0.3.0
|
||||
hacklab.nilfm.cc/quartzgun v0.3.1
|
||||
nhooyr.io/websocket v1.8.7
|
||||
)
|
||||
|
||||
|
|
|
@ -409,10 +409,7 @@ async function getTables() {
|
|||
adminZone.innerHTML = tableListHTML;
|
||||
tokenWrapper.style.display = "none";
|
||||
} else {
|
||||
if (res.status == 404) {
|
||||
return;
|
||||
}
|
||||
setErr(await res.headers.get("Quartzgun-Error"));
|
||||
// fail silently here
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
|
|
@ -9,6 +9,11 @@
|
|||
<link href="./style.css?v=0.1.0" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript><div id="noscript_container">
|
||||
<img src="./logo.svg" alt="FELT VIRTUAL TABLETOP"/>
|
||||
<p><a href="https://hacklab.nilfm.cc/felt">Felt</a> is a virtual tabletop webapp; therefore, the frontend is written in Javascript</p>
|
||||
<p>Enable Javascript in your browser to get your game on!</p></div>
|
||||
</noscript>
|
||||
<div id="map"></div>
|
||||
|
||||
<div id="errWrapper" style='display:none'><button id="closeErr" onclick="closeErr()">x</button><div id="errDiv"></div></div>
|
||||
|
@ -25,6 +30,7 @@
|
|||
<label>table.name<br><input id="input_table_name"></label><br>
|
||||
<label>table.passcode<br><input id="input_table_pass"></label><br>
|
||||
<button type="submit" id="table_join" onclick="dial();">Join</button>
|
||||
<button id="table_leave" style="display:none;" onclick="leave()">Leave</button>
|
||||
</form>
|
||||
</details><br/>
|
||||
|
||||
|
@ -132,11 +138,11 @@
|
|||
<label>sub color<input type="color" id="sub_col_input"/></label>
|
||||
<label>sub opacity<input type="range" id="sub_col_opacity" min="0" max="255"/></label>
|
||||
<label>error color<input type="color" id="err_col_input"/></label>
|
||||
<label>error opacity<input type="range" id="err_col_opacity"/></label>
|
||||
<label>error opacity<input type="range" id="err_col_opacity" min="0" max="255"/></label>
|
||||
<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.org">leaflet</a> (<a href="https://hacklab.nilfm.cc/felt/raw/main/LEAFLETLICENSE">license</a>) </div>
|
||||
</nav>
|
||||
</body>
|
||||
|
|
14
static/manifest.json
Normal file
14
static/manifest.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"short_name": "Felt",
|
||||
"name": "Felt virtual tabletop for distributed cooperative storytelling",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/table/logo.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg"
|
||||
}
|
||||
],
|
||||
"start_url": "/table/index.html",
|
||||
"display": "standalone",
|
||||
"orientation": "auto"
|
||||
}
|
|
@ -116,27 +116,4 @@ function NewToken(token) {
|
|||
t: token,
|
||||
m: marker,
|
||||
};
|
||||
}
|
||||
|
||||
function addToken(token) {
|
||||
const self = { sz: token.sz, m: L.marker(token.pos, {
|
||||
icon: L.icon({
|
||||
iconUrl: token.img,
|
||||
iconSize: token.sz,
|
||||
}),
|
||||
title: token.name,
|
||||
draggable: true,
|
||||
autoPan: true
|
||||
})};
|
||||
|
||||
tokens.push(self);
|
||||
self.m.addTo(map);
|
||||
}
|
||||
|
||||
// token for testing in browser console
|
||||
const t = {
|
||||
img: "https://nilfm.cc/favicon.png",
|
||||
sz: [32,32],
|
||||
pos: [0,0],
|
||||
name: "test",
|
||||
}
|
|
@ -2,11 +2,13 @@ let tableKey = {
|
|||
name: "",
|
||||
passcode: ""
|
||||
}
|
||||
|
||||
let table = null;
|
||||
|
||||
let conn = null;
|
||||
let offline = false;
|
||||
let msgQ = [];
|
||||
|
||||
const lagDiv = document.getElementById("lag");
|
||||
const secondaryPreviewZone = document.getElementById("tokenPreview_alt");
|
||||
const secondaryPreviewIdInput = document.getElementById("tokenPreview_alt_id");
|
||||
const dismissPreviewBtn = document.getElementById("tokenPreview_alt_clear");
|
||||
|
@ -151,10 +153,17 @@ function isTableKeyValid(name, passcode) {
|
|||
return r.test(name) && r.test(passcode);
|
||||
}
|
||||
|
||||
function leave() {
|
||||
conn.close(1000);
|
||||
}
|
||||
|
||||
function dial() {
|
||||
// get tableKey from UI
|
||||
const tblNameInput = document.getElementById("input_table_name");
|
||||
const tblPassInput = document.getElementById("input_table_pass");
|
||||
const joinTblBtn = document.getElementById("table_join");
|
||||
const leaveTblBtn = document.getElementById("table_leave");
|
||||
|
||||
if (tblNameInput && tblPassInput && tblNameInput.value && tblPassInput.value) {
|
||||
if (isTableKeyValid(tblNameInput.value, tblPassInput.value)) {
|
||||
tableKey.name = tblNameInput.value;
|
||||
|
@ -166,12 +175,17 @@ function dial() {
|
|||
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) {
|
||||
setErr("Websocket error: trying to reconnect");
|
||||
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";
|
||||
|
@ -186,6 +200,18 @@ function dial() {
|
|||
}
|
||||
});
|
||||
conn.addEventListener("open", e => {
|
||||
offline = false;
|
||||
tblNameInput.readOnly = true;
|
||||
tblPassInput.readOnly = true;
|
||||
joinTblBtn.style.display = "none";
|
||||
leaveTblBtn.style.display = adminToken ? "none" : "inline";
|
||||
lagDiv.style.display = "none";
|
||||
let m = null;
|
||||
while (m = msgQ.shift()) {
|
||||
if (m) {
|
||||
publish(m);
|
||||
}
|
||||
}
|
||||
closeErr();
|
||||
tabletop = document.getElementById("tabletop");
|
||||
if (tabletop) {
|
||||
|
@ -224,12 +250,19 @@ function dial() {
|
|||
}
|
||||
|
||||
async function publish(msg) {
|
||||
msg.key = tableKey;
|
||||
const res = await fetch('/publish', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(msg)
|
||||
});
|
||||
if (!res.ok) {
|
||||
setErr("Failed to publish message");
|
||||
if (offline) {
|
||||
msgQ.push(msg);
|
||||
|
||||
} else {
|
||||
msg.key = tableKey;
|
||||
|
||||
const res = await fetch('/publish', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(msg)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setErr("Failed to publish message");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -72,8 +72,12 @@ button:hover {
|
|||
border: solid 2px var(--err_color);
|
||||
padding: 1em;
|
||||
z-index: 3;
|
||||
position: relative;
|
||||
margin: 2em;
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
#closeErr {
|
||||
display: inline;
|
||||
border: none;
|
||||
|
@ -85,6 +89,17 @@ button:hover {
|
|||
display: inline;
|
||||
}
|
||||
|
||||
#lag {
|
||||
background: var(--bg_color);
|
||||
color: var(--err_color);
|
||||
font-size: 125%;
|
||||
position: fixed;
|
||||
bottom:0;
|
||||
margin-left: 50%;
|
||||
transform:translateX(-50%);
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
#dice_log {
|
||||
background: var(--sub_color);
|
||||
color: var(--fg_color);
|
||||
|
@ -147,11 +162,11 @@ summary {
|
|||
margin: 0.25em;
|
||||
}
|
||||
|
||||
.ui_win a {
|
||||
a {
|
||||
color: var(--main_color);
|
||||
}
|
||||
|
||||
.ui_win a:hover, ui_win a:active {
|
||||
a:hover, a:active {
|
||||
color: var(--fg_color);
|
||||
}
|
||||
|
||||
|
@ -328,4 +343,24 @@ input[type=range]::-moz-range-track {
|
|||
|
||||
.error {
|
||||
color: var(--err_color);
|
||||
}
|
||||
|
||||
#noscript_container {
|
||||
z-index:999;
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: var(--bg_color);
|
||||
color: var(--fg_color);
|
||||
padding: 3em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
body {
|
||||
font-size: 75%;
|
||||
}
|
||||
nav {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
|
@ -23,5 +23,5 @@
|
|||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
<script src="./util.js?v=0.1.0" type="text/javascript"></script>
|
||||
<script src="/table/util.js?v=0.1.0" type="text/javascript"></script>
|
||||
</html>
|
|
@ -19,5 +19,5 @@
|
|||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
<script src="./util.js?v=0.1.0" type="text/javascript"></script>
|
||||
<script src="/table/util.js?v=0.1.0" type="text/javascript"></script>
|
||||
</html>
|
Loading…
Reference in a new issue