Files
cypher-player-assistant/index.html

786 lines
28 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, height=device-height, initial-scale: 1">
<title>Cypher Player Assistant</title>
<link rel="stylesheet" type="text/css" href="static/reset.css">
<style>
/* Themes and theme switcher */
:root {
/* Dark Color Scheme */
--dark-bg: #111111;
--dark-text: #eeeeee;
--dark-box-border: #333333;
--dark-hl-box-border: #cccccc;
/* Light Color Scheme */
--light-bg: #eeeeee;
--light-text: #111111;
--light-box-border: #cccccc;
--light-hl-box-border: #333333;
/* Defaults */
--bg: var(--light-bg);
--text: var(--light-text);
--box-border: var(--light-box-border);
--hl-box-border: var(--light-hl-box-border);
}
#color-scheme {
display: none;
}
#color-scheme:checked ~ .color-scheme-wrapper {
--bg: var(--dark-bg);
--text: var(--dark-text);
--box-border: var(--dark-box-border);
--hl-box-border: var(--dark-hl-box-border);
}
#color-scheme:checked ~ .color-scheme-wrapper .dark-mode-hide {
display: none;
}
#color-scheme:checked ~ .color-scheme-wrapper .light-mode-hide {
display: initial;
}
.dark-mode-hide {
display: initial;
}
.light-mode-hide {
display: none;
}
@media(prefers-color-scheme: dark) {
:root {
--bg: var(--dark-bg);
--text: var(--dark-text);
--box-border: var(--dark-box-border);
--hl-box-border: var(--dark-hl-box-border);
}
#color-scheme:checked ~ .color-scheme-wrapper {
--bg: var(--light-bg);
--text: var(--light-text);
--box-border: var(--light-box-border);
--hl-box-border: var(--light-hl-box-border);
}
#color-scheme:checked ~ .color-scheme-wrapper .dark-mode-hide {
display: initial;
}
#color-scheme:checked ~ .color-scheme-wrapper .light-mode-hide {
display: none;
}
.dark-mode-hide {
display: none;
}
.light-mode-hide {
display: initial;
}
}
.color-scheme-wrapper {
min-height: 100vh;
background: var(--bg);
color: var(--text);
}
#color-scheme-changer {
position: fixed;
text-align: center;
top: 0;
right: 0;
font-size: 150%;
width: 1.5em;
cursor: pointer;
}
.color-scheme-toggle {
cursor: pointer;
}
.color-scheme-toggle span {
cursor: pointer;
}
.dark-mode-hide, .light-mode-hide {
cursor: pointer;
}
.dark-mode-hide {
background-color: var(--dark-bg);
color: var(--dark-text);
}
.light-mode-hide {
background-color: var(--light-bg);
color: var(--light-text);
}
/* Outline and generic styles */
body {
background-color: var(--bg);
color: var(--text);
font-family: sans-serif;
}
h1 {
width: 100%;
font-family: serif;
text-align: center;
}
#warn-no-local-storage {
display: none;
color: red;
}
.box {
width: 80%;
border-width: 2px;
border-style: solid;
border-color: var(--box-border);
border-radius: 10px;
margin: 0 auto 1em auto;
padding: 1em;
}
.box h2 {
display: initial;
position: relative;
top: -1.8em;
padding: 0 .5em;
color: var(--hl-box-border);
background-color: var(--bg);
font-size: 100%;
font-weight: normal;
}
.box h2::after {
display: block;
content: "";
margin-bottom: -1.5em;
}
input {
border: 1px solid var(--hl-box-border);
background-color: var(--bg);
color: var(--text);
}
input[type=number] {
width: 4em;
}
input[disabled] {
border-color: var(--box-border);
}
.warn {
color: red;
font-weight: bold;
}
/* Character roster box */
#cont-character-roster {
text-align: center;
}
#btn-create-character {
display: none;
}
/* No character loaded box (AKA the welcome screen) */
#cont-no-loaded {
text-align: center;
}
div.or {
font-weight: bold;
text-transform: uppercase;
margin: .5em 0;
}
/* Character sheet */
#cont-character-sheet {
display: none;
}
#cont-character-id {
margin: -2em 0 .5em 0;
color: var(--box-border);
font-size: 75%;
}
#cont-character-id::before {
content: "ID: ";
}
.pool-container {
display: inline-block;
border-width: 2px;
border-style: solid;
border-color: var(--box-border);
border-radius: 10px;
width: 32%;
padding: .6em;
}
.pool-container:has(> h3 > input[name=pool-selector]:checked) {
border-color: var(--hl-box-border);
}
input[name=pool-selector] {
display: none;
}
.pool-container h4 {
color: var(--hl-box-border);
font-weight: normal;
font-size: 80%;
}
/* Input unlockers */
.input-unlocker {
display: none;
}
.input-unlocker + label > .input-locked {
display: initial;
cursor: pointer;
}
.input-unlocker + label > .input-unlocked {
display: none;
cursor: pointer;
}
.input-unlocker:checked + label > .input-locked {
display: none;
}
.input-unlocker:checked + label .input-unlocked {
display: initial;
}
/* Ability use box */
</style>
</head>
<body>
<input id="color-scheme" type="checkbox">
<div class="color-scheme-wrapper">
<div id="color-scheme-changer">
<label for="color-scheme" id="color-scheme-toggle">
<span class="dark-mode-hide"></span>
<span class="light-mode-hide"></span>
</label>
</div>
<h1>
Cypher Player Assistant
<span id="warn-no-local-storage" title="Local Storage is not available. You can import/export data, but the app wont save it between sessions!">!</span>
</h1>
<div id="cont-character-roster" class="box">
<select id="inp-character-roster"><option value="">Choose a character to load</option></select>
<button id="btn-load-character">Load Character</button>
<button id="btn-create-character">Create Character</button>
</div>
<div id="cont-no-loaded" class="box">
No character is loaded. Load one from the menu above
<div class="or">— or —</div>
<button id="btn-no-char-create-character">Create one</button>
</div>
<div id="cont-character-sheet">
<div class="box">
<h2>Character data</h2>
<div id="cont-character-id"></div>
<strong>Campaign</strong> <input type="text" id="inp-campaign-name">
<strong>Name</strong> <input type="text" id="inp-character-name">
<strong>Max Effort</strong> <input type="number" id="inp-max-effort" min="1" value="1" disabled>
<input type="checkbox" class="input-unlocker" id="max-effort-unlocker">
<label for="max-effort-unlocker">
<span class="input-locked">🔒</span>
<span class="input-unlocked">🔓</span>
</label>
<strong>Armor</strong> <input type="number" id="inp-armor" min="0" value="0" disabled>
<input type="checkbox" class="input-unlocker" id="armor-unlocker">
<label for="armor-unlocker">
<span class="input-locked">🔒</span>
<span class="input-unlocked">🔓</span>
</label>
<button id="btn-save-character">Save</button>
</div>
<div class="box">
<h2>Pools</h2>
<div class="pool-container">
<h3>
<input id="pool-selector-might" type="radio" name="pool-selector" value="might">
<label for="pool-selector-might">Might</label>
</h3>
<h4>Pool</h4>
<input type="number" id="inp-pool-value-might" min="0" value="0">
/
<input type="number" id="inp-pool-max-might" min="0" value="0" disabled>
<input type="checkbox" class="input-unlocker" id="pool-max-unlocker-might">
<label for="pool-max-unlocker-might">
<span class="input-locked">🔒</span>
<span class="input-unlocked">🔓</span>
</label>
<h4>Edge</h4>
<input type="number" id="inp-pool-edge-might" min="0" value="0" disabled>
<input type="checkbox" class="input-unlocker" id="pool-edge-unlocker-might">
<label id="pool-edge-unlocker-label-might" for="pool-edge-unlocker-might">
<span class="input-locked">🔒</span>
<span class="input-unlocked">🔓</span>
</label>
</div>
<div class="pool-container">
<h3>
<input id="pool-selector-speed" type="radio" name="pool-selector" value="speed">
<label for="pool-selector-speed">Speed</label>
</h3>
<h4>Pool</h4>
<input type="number" id="inp-pool-value-speed" min="0" value="0">
/
<input type="number" id="inp-pool-max-speed" min="0" value="0" disabled>
<input type="checkbox" class="input-unlocker" id="pool-max-unlocker-speed">
<label for="pool-max-unlocker-speed">
<span class="input-locked">🔒</span>
<span class="input-unlocked">🔓</span>
</label>
<h4>Edge</h4>
<input type="number" id="inp-pool-edge-speed" min="0" value="0" disabled>
<input type="checkbox" class="input-unlocker" id="pool-edge-unlocker-speed">
<label id="pool-edge-unlocker-label-speed" for="pool-edge-unlocker-speed">
<span class="input-locked">🔒</span>
<span class="input-unlocked">🔓</span>
</label>
</div>
<div class="pool-container">
<h3>
<input id="pool-selector-intellect" type="radio" name="pool-selector" value="intellect">
<label for="pool-selector-intellect">Intellect</label>
</h3>
<h4>Pool</h4>
<input type="number" id="inp-pool-value-intellect" min="0" value="0">
/
<input type="number" id="inp-pool-max-intellect" min="0" value="0" disabled>
<input type="checkbox" class="input-unlocker" id="pool-max-unlocker-intellect">
<label for="pool-max-unlocker-intellect">
<span class="input-locked">🔒</span>
<span class="input-unlocked">🔓</span>
</label>
<h4>Edge</h4>
<input type="number" id="inp-pool-edge-intellect" min="0" value="0" disabled>
<input type="checkbox" class="input-unlocker" id="pool-edge-unlocker-intellect">
<label for="pool-edge-unlocker-intellect">
<span class="input-locked">🔒</span>
<span class="input-unlocked">🔓</span>
</label>
</div>
</div>
<div class="box">
<h2>Use Ability</h2>
<span id="dsp-ability-pool" class="warn">No pool selected!</span>
<strong>Initial cost</strong> <input type="number" id="inp-ability-cost" min-value="0" value="0">
<strong>Effort used</strong> <input type="number" id="inp-ability-effort" min-value="0" value="0">
<span id="dsp-ability-cost"></span>
<button id="btn-ability-do">Do it!</button>
</div>
</div>
</div>
<script>
const poolTypes = ["might", "speed", "intellect"];
const characterIDChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const containerNoCharacter = document.getElementById("cont-no-loaded");
const containerCharacter = document.getElementById("cont-character-sheet");
const containerCharacterID = document.getElementById("cont-character-id");
const btnLoadCharacter = document.getElementById("btn-load-character");
const btnCreateCharacter = document.getElementById("btn-create-character");
const btnNoCharCreateCharacter = document.getElementById("btn-no-char-create-character");
const btnSaveCharacter = document.getElementById("btn-save-character");
const btnAbilityDo = document.getElementById("btn-ability-do");
const inpCharacterRoster = document.getElementById("inp-character-roster");
const inpCampaignName = document.getElementById("inp-campaign-name");
const inpCharacterName = document.getElementById("inp-character-name");
const inpMaxEffort = document.getElementById("inp-max-effort");
const inpArmor = document.getElementById("inp-armor");
const inpPoolValue = {
might: document.getElementById("inp-pool-value-might"),
speed: document.getElementById("inp-pool-value-speed"),
intellect: document.getElementById("inp-pool-value-intellect"),
};
const inpPoolMax = {
might: document.getElementById("inp-pool-max-might"),
speed: document.getElementById("inp-pool-max-speed"),
intellect: document.getElementById("inp-pool-max-intellect"),
};
const inpPoolEdge = {
might: document.getElementById("inp-pool-edge-might"),
speed: document.getElementById("inp-pool-edge-speed"),
intellect: document.getElementById("inp-pool-edge-intellect"),
};
const inpAbilityCost = document.getElementById("inp-ability-cost");
const inpAbilityEffort = document.getElementById("inp-ability-effort");
const dspAbilityPool = document.getElementById("dsp-ability-pool");
const dspAbilityCost = document.getElementById("dsp-ability-cost");
var characterRoster = {};
var currentCharacter = null;
const checkLocalStorage = () => {
let storage;
try {
storage = window.localStorage;
const x = "__storage_test__";
storage.setItem(x, x);
storage.removeItem(x);
return true;
} catch (e) {
return (
e instanceof DOMException &&
e.name === "QuotaExceededError" &&
// acknowledge QuotaExceededError only if there's something already stored
storage &&
storage.length !== 0
);
}
};
const toggleInputLockedHandler = (evt) => {
let cb = evt.target;
let pool = cb.id.slice(cb.id.lastIndexOf("-") + 1);
let input = cb.previousElementSibling;
let toUnlock = cb.checked;
input.disabled = !toUnlock;
};
const loadCharacter = (characterID) => {
containerNoCharacter.style.display = "none";
containerCharacter.style.display = "initial";
btnCreateCharacter.style.display = "initial";
clearSheet();
characterData = characterRoster[characterID] || {};
containerCharacterID.textContent = characterID;
inpCharacterName.value = characterData.name || "";
inpCampaignName.value = characterData.campaign || "";
inpMaxEffort.value = characterData.max_effort || 1;
inpArmor.value = characterData.armor || 0;
for (var poolType of poolTypes) {
var poolData = characterData[poolType] || {};
inpPoolValue[poolType].value = poolData.pool || 8;
inpPoolMax[poolType].value = poolData.max || 8;
inpPoolEdge[poolType].value = poolData.edge || 0;
}
currentCharacter = characterID;
updateAbilityCostDisplay();
};
const createCharacter = () => {
let newID = generateID();
characterRoster[newID] = {};
currentCharacter = newID;
loadCharacter(newID);
};
const clearSheet = () => {
inpCampaignName.value = "";
inpCharacterName.value = "";
inpMaxEffort.value = 1;
inpArmor.value = 0;
for (var poolType of poolTypes) {
inpPoolValue[poolType].value = 0;
inpPoolMax[poolType].value = 0;
inpPoolEdge[poolType].value = 0;
}
};
const generateID = () => {
var result = "";
for (var i = 0; i < 16; i++) {
result += characterIDChars.charAt(Math.floor(Math.random() * characterIDChars.length));
}
return result;
};
const sheetToObject = () => {
return {
name: inpCharacterName.value,
campaign: inpCampaignName.value,
max_effort: Number(inpMaxEffort.value),
armor: Number(inpArmor.value),
might: {
pool: Number(inpPoolValue.might.value),
max: Number(inpPoolMax.might.value),
edge: Number(inpPoolEdge.might.value),
},
speed: {
pool: Number(inpPoolValue.speed.value),
max: Number(inpPoolMax.speed.value),
edge: Number(inpPoolEdge.speed.value),
},
intellect: {
pool: Number(inpPoolValue.intellect.value),
max: Number(inpPoolMax.intellect.value),
edge: Number(inpPoolEdge.intellect.value),
},
};
};
const saveCharacterRoster = () => {
if (!checkLocalStorage()) {
alert("Local Storage is not available, cannot save roster.");
return;
}
localStorage.setItem("character-roster", JSON.stringify(characterRoster));
};
const saveCurrentCharacter = () => {
characterRoster[currentCharacter] = sheetToObject();
saveCharacterRoster();
fillCharacterRoster();
};
const clearContainer = (cont) => {
while (cont.firstChild) {
cont.removeChild(cont.lastChild);
}
};
const fillCharacterRoster = () => {
var characterIDs = Object.keys(characterRoster).toSorted((idA, idB) => {
// Sort by campaign name first
if (characterRoster[idA].campaign < characterRoster[idB].campaign) return -1;
if (characterRoster[idA].campaign > characterRoster[idB].campaign) return 1;
// Sort by character name within the same campaign
if (characterRoster[idA].name < characterRoster[idB].name) return -1;
if (characterRoster[idA].name > characterRoster[idB].name) return 1;
// If all else fails, sort by the character ID
return (idA < idB) ? -1 : (idA > idB) ? 1: 0;
});
var placeHolder = inpCharacterRoster.options[0];
clearContainer(inpCharacterRoster);
inpCharacterRoster.appendChild(placeHolder);
var currentGroup = null;
var previousCampaign = null;
for (var characterID of characterIDs) {
if ((previousCampaign !== null) && (previousCampaign != characterRoster[characterID].campaign)) {
inpCharacterRoster.appendChild(currentGroup);
currentGroup = null;
}
if (currentGroup === null) {
currentGroup = document.createElement("optgroup");
currentGroup.label = characterRoster[characterID].campaign;
}
var newOption = document.createElement("option");
newOption.value = characterID;
newOption.appendChild(document.createTextNode(characterRoster[characterID].name));
currentGroup.appendChild(newOption);
previousCampaign = characterRoster[characterID].campaign;
}
if (currentGroup !== null) {
inpCharacterRoster.appendChild(currentGroup);
}
};
const loadCharacterRoster = (doLocalStorageCheck) => {
if (doLocalStorageCheck && !checkLocalStorage()) {
alert("Local Storage is not available, cannot load roster.");
return;
}
var newRoster = localStorage.getItem("character-roster");
characterRoster = (newRoster === null) ? {} : JSON.parse(newRoster);
fillCharacterRoster();
};
const capitalize = (str) => {
return String(str).charAt(0).toUpperCase() + String(str).slice(1);
};
const getSelectedPool = () => {
var currentPoolSelector = document.querySelector("input[name=pool-selector]:checked");
return (currentPoolSelector === null) ? null : currentPoolSelector.value;
};
const poolSelectionChanged = () => {
var currentPool = getSelectedPool();
clearContainer(dspAbilityPool);
if (currentPool === null) {
dspAbilityPool.appendChild(document.createTextNode("No pool selected!"));
dspAbilityPool.classList.add("warn");
} else {
dspAbilityPool.appendChild(document.createTextNode(capitalize(currentPool)));
dspAbilityPool.classList.remove("warn");
}
};
const abilityEffortChanged = () => {
var maxEffort = Number(inpMaxEffort.value);
var abilityEffort = Number(inpAbilityEffort.value);
if (abilityEffort > maxEffort) {
inpAbilityEffort.value = maxEffort;
}
updateAbilityCostDisplay();
};
const calculateAbilityCost = () => {
var currentPool = getSelectedPool();
if (currentPool === null) return null;
var cost = Number(inpAbilityCost.value);
var effort = Number(inpAbilityEffort.value);
var effortCost = (effort) ? 3 + (effort - 1) * 2 : 0;
var poolEdge = Number(inpPoolEdge[currentPool].value);
return Math.max(0, cost + effortCost - poolEdge);
};
const updateAbilityCostDisplay = () => {
var currentPool = getSelectedPool();
var cost = calculateAbilityCost();
clearContainer(dspAbilityCost);
if ((currentPool === null) || (cost === null)) return;
var currentPoolValue = Number(inpPoolValue[currentPool].value);
if (cost > currentPoolValue) {
dspAbilityCost.classList.add("warn");
} else {
dspAbilityCost.classList.remove("warn");
}
dspAbilityCost.appendChild(document.createTextNode(cost));
};
const poolValueChanged = () => {
updateAbilityCostDisplay();
};
const poolEdgeChanged = () => {
updateAbilityCostDisplay();
};
const abilityCostChanged = () => {
updateAbilityCostDisplay();
};
const subtractPoolPoints = () => {
var currentPool = getSelectedPool();
var abilityCost = calculateAbilityCost();
var poolValue = Number(inpPoolValue[currentPool].value);
if (abilityCost === null) return;
updateAbilityCostDisplay();
if (poolValue < abilityCost) {
alert("You cannot affort to use this ability now.");
return;
}
inpPoolValue[currentPool].value = poolValue - abilityCost;
updateAbilityCostDisplay();
};
document.addEventListener(
"DOMContentLoaded",
() => {
if (!checkLocalStorage()) {
document.getElementById("warn-no-local-storage").style.display = "initial";
alert("Local Storage is not available, saving and loading data will be unavailable.");
}
document
.querySelectorAll(".input-unlocker")
.forEach((elem) => {elem.addEventListener("change", toggleInputLockedHandler)});
document
.querySelectorAll("input[name=pool-selector]")
.forEach((elem) => {elem.addEventListener("change", poolSelectionChanged)});
for (var poolValueInput of Object.values(inpPoolValue)) {
poolValueInput.addEventListener("input", poolValueChanged);
}
for (var poolEdgeInput of Object.values(inpPoolEdge)) {
poolEdgeInput.addEventListener("input", poolEdgeChanged);
}
inpAbilityCost.addEventListener("input", abilityCostChanged);
inpAbilityEffort.addEventListener("input", abilityEffortChanged);
btnNoCharCreateCharacter.addEventListener("click", createCharacter);
btnCreateCharacter.addEventListener("click", createCharacter);
btnSaveCharacter.addEventListener("click", saveCurrentCharacter);
btnLoadCharacter.addEventListener("click", () => {
var selectedID = inpCharacterRoster.selectedOptions[0].value;
if (!selectedID) return;
loadCharacter(selectedID);
});
btnAbilityDo.addEventListener("click", subtractPoolPoints);
clearSheet();
loadCharacterRoster(true);
poolSelectionChanged();
updateAbilityCostDisplay();
},
);
</script>
</body>
</html>