Tabletop RPG Dice Roller App

A dice rolling app meant to be used for when playing tabletop role playing games to make calculations. Wrote a custom backend api used for calculating the dice rolls in the Express framework. The front end was made using React, and used the new Context API.

Tools/Libraries: React.js, Node.js, Express, SASS

Language: Javascript(ES6), HTML5/CSS3


Live App

Git Repo

frontend.js

import React, { Component, createContext, useContext, useReducer, Fragment, useState } from 'react'; import style from './frontend.css'; import logo from './imgs/dice-logo-alt.svg'; //get dice images const d4 = require('./imgs/d4-dice.svg'); const d6 = require('./imgs/d6-dice.svg'); const d8 = require('./imgs/d8-dice.svg'); const d10 = require('./imgs/d10-dice.svg'); const d100 = require('./imgs/d100-dice.svg'); const d12 = require('./imgs/d12-dice.svg'); const d20 = require('./imgs/d20-dice.svg'); //start context const GV = createContext(); class Global extends Component { constructor(props) { super(props); this.state = { Amount: 1, Mod: 0, Sel: [], History: "" } this.Change = this.Change.bind(this); } Change = (type, val) => { switch(type) { case "Amount": this.setState({Amount: val}); break; case "Mod": this.setState({Mod: val}); break; case "Sel": this.setState({Sel: val}); break; case "History": this.setState({History: val}); break; } } Clear = () => { this.setState({Amount: 1}); this.setState({Mod: 0}); this.setState({Sel: []}); } render() { return( <GV.Provider value={{state: this.state, Change: this.Change, Clear: this.Clear}}> {this.props.children} </GV.Provider> ); } } const GameHeader = () => { return( <div class={style.banner}> <img src={logo} class={style.logo}></img> </div> ); } const SelDie = () => { //const [SelDiceval, SetDice] = useState(''); function changeDie(die, data) { data.push(die); return (data); } return( <div class={style.DiceContainer}> <p class={style.LrgTxt}>Select Dice</p> <div class={style.diceborder}> <GV.Consumer> {(data) => ( <Fragment> <div class={style.dicebtn} onClick={() => data.Change("Sel", changeDie("d4", data.state.Sel))}> <img src={d4} class={style.dice}></img><br /> <div class={style.DiceTxt}>d4</div> </div> <div class={style.dicebtn} onClick={() => data.Change("Sel", changeDie("d6", data.state.Sel))}> <img src={d6} class={style.dice}></img><br /> <div class={style.DiceTxt}>d6</div> </div> <div class={style.dicebtn} onClick={() => data.Change("Sel", changeDie("d8", data.state.Sel))}> <img src={d8} class={style.dice}></img><br /> <div class={style.DiceTxt}>d8</div> </div> <div class={style.dicebtn} onClick={() => data.Change("Sel", changeDie("d10", data.state.Sel))}> <img src={d10} class={style.dice}></img><br /> <div class={style.DiceTxt}>d10</div> </div> <div class={style.dicebtn} onClick={() => data.Change("Sel", changeDie("d100", data.state.Sel))}> <img src={d100} class={style.dice}></img><br /> <div class={style.DiceTxt}>d100</div> </div> <div class={style.dicebtn} onClick={() => data.Change("Sel", changeDie("d12", data.state.Sel))}> <img src={d12} class={style.dice}></img><br /> <div class={style.DiceTxt}>d12</div> </div> <div class={style.dicebtn} onClick={() => data.Change("Sel", changeDie("d20", data.state.Sel))}> <img src={d20} class={style.dice}></img><br /> <div class={style.DiceTxt}>d20</div> </div> </Fragment> )} </GV.Consumer> </div> <br /> </div> ); } const SelectedDie = () => { //const [SelDiceval, SetValue] = useState(''); const RenderDice = (list) => { let Dice = []; for (let i = 0; i < list.length; i++) Dice.push(<Fragment><img src={require('./imgs/' + list[i] + '-dice.svg')} class={style.tinydice}></img></Fragment>); return( <div class={style.seldicecol}> {Dice} </div> ); } return( <div class={style.SelContainer}> <GV.Consumer> {(data) => ( <Fragment> <p class={style.MdTxt}>Selected</p> <div class={style.seldiceborder}> {RenderDice(data.state.Sel)}; </div> <br /> </Fragment> )} </GV.Consumer> </div> ); } const ChangeNum = (type, mod) => { if (type === "pos") mod++; if (type === "neg") mod--; return (mod); } const DiceAmount = () => { return( <div class={style.AmtContainer}> <p class={style.MdTxt}>Amount</p> <div class={style.FlexRow}> <GV.Consumer> {(data) => ( <Fragment> <button class={style.bigbtn} onClick={() => data.Change("Amount", ChangeNum("pos", data.state.Amount))}>+</button> <input class={style.leInput} type="text" value={data.state.Amount}></input> <button class={style.bigbtn} onClick={() => data.Change("Amount", ChangeNum("neg", data.state.Amount))}>-</button><br /> <br /> </Fragment> )} </GV.Consumer> </div> </div> ); } const DiceMod = () => { return( <div class={style.AmtContainer}> <p class={style.MdTxt}>Modifier</p> <div class={style.FlexRow}> <GV.Consumer> {(data) => ( <Fragment> <button class={style.bigbtn} onClick={() => data.Change("Mod", ChangeNum("pos", data.state.Mod))}>+</button> <input class={style.leInput} type="text" value={data.state.Mod}></input> <button class={style.bigbtn} onClick={() => data.Change("Mod", ChangeNum("neg", data.state.Mod))}>-</button> </Fragment> )} </GV.Consumer> </div> </div> ); } const TextFields = () => { /*const RenderHistory = (text) => { let Alt = []; for (let i = 0; i !== text.length; i++) Alt.push(<Fragment><li>{text[i]}</li></Fragment>); return( <ul> {Alt} </ul> ); }*/ //{RenderHistory(data.state.History)} return ( <div class={style.FlexCol}> <GV.Consumer> {(data) => ( <Fragment> <div class={style.termHeader}>History</div> <textarea id="DiceOutput" class={style.Terminal} value={data.state.History}> </textarea> </Fragment> )} </GV.Consumer> </div> ); } class HistoryBtns extends Component { ResetTextbox = (text) => { text = ""; return(text); } render () { return( <GV.Consumer> {(data) => ( <Fragment> <div class={style.HistoryBtnContainer}> <button class={style.impbtn} onClick={ () => data.Change("History", this.ResetTextbox(data.state.History))}> Reset</button> </div> </Fragment> )} </GV.Consumer> ); } } class DiceBtns extends Component { //puts the info put in into the api call, then takes the returned info and puts it into an output string CallDiceApi = async(dice, amount, mod, save, prevState) => { try { let url = '/dungeondiceroller/api/' + dice.toString() + '-' + amount + '-' + mod; let output = await fetch(url); let converted = await output.json(); if (converted.length > 1) { for(let i = 0; i < converted.length; i++) converted[i] = converted[i].slice(0, converted[i].length - 2) + '\n'; converted = converted.join(""); } else { converted = converted.toString(); converted = converted.slice(0, converted.length - 2) + '\n'; console.log(converted); } save("History", prevState + converted); } catch(error) { console.log("error calling api: " + error); } }; render () { return( <div class={style.DiceBtnContainer}> <GV.Consumer> {(data) => ( <Fragment> <button class={style.impbtn} onClick={ () => this.CallDiceApi(data.state.Sel, data.state.Amount, data.state.Mod, data.Change, data.state.History)}> Roll!</button> <button class={style.impbtn} onClick={ () => data.Clear()}>Clear</button> </Fragment> )} </GV.Consumer> </div> ); } } class FrontEnd extends React.Component { render() { return ( <div> <Global> <GameHeader /> <div class={style.displayIt}> <div class={style.FlexCol}> <div class={style.DataContainer}> <div class={style.FlexRow}> <SelDie /> </div> <SelectedDie /> <div class={style.InfoContainer}> <DiceAmount /> <DiceMod /> </div> <DiceBtns /> </div> </div> <div class={style.FlexCol}> <TextFields /> <HistoryBtns /> </div> </div> <div class={style.footer}></div> </Global> </div> ); } } export default FrontEnd;

backend.js

const express = require('express'); const ready = require('./roll.js'); const app = express(); const portnum = 5000; const port = process.env.PORT || portnum; app.set('port ', port); app.get('/dungeondiceroller/api/', (req, res) => { res.send('Dice Api is running'); }); app.get('/dungeondiceroller/api/:dicetype-:amount-:mod', (req, res) => { let Rolled = ready.RollDice(req.params.dicetype, req.params.amount, req.params.mod); res.send(Rolled); }) app.listen(port, () => console.log('Listing to port ' + portnum));

roll.js

//returns a random int number inbetween the min and max. function RandomInt(minNum, maxNum) { minNum = Math.ceil(minNum); maxNum = Math.floor(maxNum); return Math.floor(Math.random() * (maxNum - minNum + 1)) + minNum; } function roll (type, amount, mod) { let result = 0; let collection = []; for (let i = 0; i < amount; i++) { let current = 0; switch (type) { case "d4": current = RandomInt(1, 4); break; case "d6": current = RandomInt(1, 6); break; case "d8": current = RandomInt(1, 8); break; case "d10": current = RandomInt(1, 10); break; case "d100": current = RandomInt(1, 100); break; case "d12": current = RandomInt(1, 12); break; case "d20": current = RandomInt(1, 20); break; default: return ("error - no matching dice type"); } collection.push(current); result += current; } result = result + Number(mod); return ("Rolled: (dice amount:" + amount + " type: " + type + ") " + collection + " mod: " + mod + " \nTotal: " + result + "\n"); } module.exports = { //used by the backend for rolling the dice, and returns the calculations in a string RollDice: function (type, amount, mod) { let ntype = []; if (type.length > 3) ntype = type.split(","); else ntype.push(type); let total = []; let possible = ['d4', 'd6', 'd8', 'd10', 'd100', 'd12', 'd20']; for (let i = 0, len = ntype.length; i !== len; i++) if (possible.includes(ntype[i]) === false) return ("error - no matching dice type"); if (ntype.length > 15) return ("error - too many dice"); if (amount < 1) return("error - one dice minimum"); for (let i = 0; i !== ntype.length; i++) { total.push(roll(ntype[i], amount, mod)); } return (total); } }

style.scss

@import url('https://fonts.googleapis.com/css?family=Open+Sans&display=swap'); $textcolor: #666; $lgtred: rgb(250, 72, 72); $shadow: rbga(192,192,192,0.3); body{ padding-top: 100px; margin: 0; background-image: url('./imgs/repeated-square.png'); background-repeat: repeat; } .banner { width: 100%; height: 35px; background-color: $lgtred; margin-bottom: 25px; -webkit-box-shadow: 2px 2px 2px 1px rgba(0, 0, 0, 0.2); -moz-box-shadow: 2px 2px 2px 1px rgba(0, 0, 0, 0.2); box-shadow: 2px 2px 2px 1px rgba(0, 0, 0, 0.2); display: flex; justify-content: center; //margin-bottom: 95px; position: fixed; top: 0; z-index: 0; } .footer { width: 100%; height: 15px; background-color: #000; margin-top: 100px; position: fixed; bottom: 0; } .logo { height: 142px; width: 142px; padding-top: 10px; animation: float 6s ease-in-out infinite; } .displayIt { display:flex; justify-content: center; flex-wrap: wrap !important; min-width: 80%; flex-direction: row; } @keyframes float { 0% { transform: translatey(0px); } 50% { transform: translatey(-20px); } 100% { transform: translatey(0px); } } .dice { width: 85px; height: 85px; @media (max-width: 800px) { width: 60px; height: 60px; } } .tinydice { width: 60px; height: 60px; @media (max-width: 1400px) { width: 42px; height: 42px; } @media (max-width: 800px) { width: 24px; height: 24px; } } .seldicecol { flex-wrap: wrap; display:flex; justify-content: center; } .FlexRow { display:flex; justify-content: center; flex-wrap: nowrap; flex-direction: row; } .FlexCol { display:flex; justify-content: center; flex-wrap: nowrap; flex-direction: column; //flex-grow: 1; margin: auto; } .Container { height: auto; max-width: 500px; } .DataContainer { @extend .Container; flex-direction: row; display: flex; flex-wrap: wrap; justify-content: center; max-width: 900px; @media (max-width: 800px) { justify-content: center; } } .DiceBtnContainer { @extend .Container; width:100%; margin-top: -42px; margin-bottom: 10px; display: flex; justify-content: center; @media (max-width: 1200px) { margin-top: 10px; } } .DiceContainer { @extend .Container; max-width: 700px; } .SelContainer { @extend .Container; max-width: 160px; margin:50px; @media (max-width: 650px) { margin: auto; } } .AmtContainer { @extend .Container; max-width: 400px; } .InfoContainer { @extend .Container; display: inline-flex; flex-wrap: wrap; flex-direction: column; max-width: 400px; flex-grow: 1; @media (max-width: 650px) { margin: auto; } } .HistoryBtnContainer { @extend .Container; @extend .FlexCol; margin-top: 50px; margin-bottom: 10px; display: flex; justify-content: center; @media (max-width: 1200px) { margin-top: 10px; padding-bottom:30px; } } .TxtView Code { font-size: 12pt; font-family: sans-serif; color: $textcolor; text-align: center; } .DiceTxt { @extend .Txt; font-size: 18pt; color: white; } .LrgTxt { @extend .Txt; font-size: 36pt; margin: 3px; } .MdTxt { @extend .Txt;View Code font-size: 28pt; margin: 3px; } .border { display: flex; height: auto; width: auto; padding: 40px; align-items: center; justify-content: center; padding: 5px; background: black; border: 3px black solid; border-radius: 30px; flex-wrap: wrap; } .diceborder { @extend .border; } .seldiceborder { @extend .border; min-height: 150px; min-width: 150px; display:flex; justify-content: center; margin:auto; border-radius: 20px; } .dicebtn { cursor: pointer; width: 80px; height: 120px; padding: 5px; color: white; text-align: center; &:hover { background: grey; } &:active{ background: white; } @media (max-width: 800px) { width: auto; height: auto; } } .bigbtn { background: $lgtred; width: 66px; height: 66px; border-radius: 15px; color: white; font-size: 25pt; margin-left: 12px; margin-right: 12px; border: 1px solid $lgtred; cursor: pointer; &:hover { background: rgb(253, 37, 37); } @media (max-width: 800px) { width: 40px; height: auto; } } .impbtn { @extend .bigbtn; height: 66px; width: auto; max-width: 110px; } .leInput { border: 1px solid black; height: 50px; width: 200px; background: white; color: black; font-size: 12pt; text-align: center; border-radius: 15px; } .Terminal { width: 600px; height: 400px; border-radius: 6px; border: 2px solid $textcolor; background-color: rgb(233, 232, 232); color: black; font-size: 10.5pt; } .termHeader { @extend .Txt; width: 600px; height: 50px; padding-top: 5px; margin-bottom: 0px; background: $lgtred; text-align: center; color: white; font-size: 28pt; border-radius: 6px; justify-content: center; }