Skip to content

Instantly share code, notes, and snippets.

@tomasreichmann
Last active January 4, 2017 16:15
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tomasreichmann/3b4d5a15f0d6b87979518cb424cd6063 to your computer and use it in GitHub Desktop.
Save tomasreichmann/3b4d5a15f0d6b87979518cb424cd6063 to your computer and use it in GitHub Desktop.
Fate - Character sheet
<div id="wrapper"></div>
var ref;
var data = {
//view: "editCharacter", //list
view: "list", //list
viewData: {
//id: "-KQPRrTpqWgyuPwBesFq"
},
lastCharacterId: 1,
characters: []
};
var config = {
skillLevels: 5,
skillsPerLevel: 5,
skillList: [ "Atletika", "Zlodějství", "Konexe", "Řemeslo", "Klamání", "Pilotování", "Vcítění", "Provokace", "Boj", "Vyšetřování", "Věda", "Medicína", "Technologie", "Všímavost", "Fyzická zdatnost", "Diplomacie", "Zdroje", "Střelba", "Kradmost", "Vůle", "Artilérie" ],
stress: [
{ label: "PHYSICAL STRESS", key: "physical", count: 4, def: 2, skill: "Fyzická zdatnost" },
{ label: "MENTAL STRESS", key: "mental", count: 4, def: 2, skill: "Vůle" }
],
consequences: {
defaultCount: 2,
bonusSkillLevel: 5,
skills: ["Fyzická zdatnost", "Vůle"],
list: [{ val: 2, label: "Minor", key: "consequences.minor" }, { val: 4, label: "Moderate", key: "consequences.moderate" }, { val: 6, label: "Severe", key: "consequences.severe" }]
},
challengeLevels: [ "Average", "Fair", "Good", "Great", "Superb" ],
texts: {
clear: "-vymazat-",
confirm: "OK",
cancel: "zrušit",
save: "uložit",
edit: "upravit",
new: "nový"
}
}
function seq(count){
return Array(count).fill(null).map( (item, index)=>( index ) );
}
var Input = React.createClass({
getInitialState: function() {
return {text: this.props.val || data.text || ''};
},
getInitialProps: function() {
return { handleChange: ()=>(null) }
},
handleChange: function(event) {
this.props.handleChange(event.target.value)
this.setState({text: event.target.value});
},
componentWillReceiveProps: function(nextProps) {
nextProps.hasOwnProperty("val") && this.setState({text: nextProps.val});
},
render: function() {
var text = this.state.text;
return <input value={text} onChange={this.handleChange} { ...this.props } />;
}
});
function editCharacter(e){
const id = this;
console.log("editCharacter", id);
update({
...data,
view: "editCharacter",
viewData: { id }
})
};
function deleteCharacter(e){
const id = this;
console.log("deleteCharacter", id);
ref.child(id).remove();
};
function addCharacter(data, character){
return {
...data,
characters: [
...data.characters,
{
...character
}
]
}
};
function removeCharacter(data, id){
let index = data.characters.findIndex( (item)=>(
item.id === id
) );
return {
...data,
characters: [
...data.characters.slice(0, index),
...data.characters.slice(index+1)
]
}
};
function replaceCharacter(data, character){
let index = data.characters.findIndex( (item)=>(
item.id === character.id
) );
return {
...data,
characters: [
...data.characters.slice(0, index),
character,
...data.characters.slice(index+1)
]
}
};
function updateCharacter(data, character, id){
let index = data.characters.findIndex( (item)=>(
item.id === id
) );
return {
...data,
characters: [
...data.characters.slice(0, index-1),
{
...character
},
...data.characters.slice(index+1),
]
}
};
function saveCharacter(){
console.log("saveCharacter", data.viewData.id);
const character = { ...collectData( document.querySelector(".editView .cs") ), id: data.viewData.id || -1 };
console.log("saveCharacter", character);
// handle update
let doUpdate = (data.viewData.id !== undefined);
update({
...data,
view: "list",
viewData: {}
});
console.log("doUpdate", doUpdate, character.id, character);
if( doUpdate ){
ref.child(character.id).set(character);
} else {
ref.push(character);
}
};
function cancelCharacterEdit(){
console.log("cancelCharacterEdit");
update({
...data,
view: "list"
})
};
function fillSkill(){
let { level, column, skill, character } = this;
console.log("fillSkill", level, column, skill, !!character );
let unsaved = Immutable.fromJS(data.viewData.unsaved || character).setIn(['skills', level, column], skill).toJS();
update({
...data,
viewData: {
id: character.id,
unsaved: unsaved
}
});
}
const inputTemplates = {
text: (path, val, handleChange)=>( <Input type="text" key={path} data-model={path} val={val} handleChange={handleChange} /> ),
textarea: (path, val, handleChange)=>( <textarea data-model={path} key={path} defaultValue={val} handleChange={handleChange} ></textarea> ),
checkbox: (path, val, handleChange)=>( <input type="checkbox" key={path} data-model={path} defaultChecked={ !!val } handleChange={handleChange} /> )
}
function getIn(obj, path){
return path.split(".").reduce( (ref, key)=>(
typeof ref === "object" && key in ref ? ref[key] : undefined
), obj);
}
function getInput(type, path, data, def, handleChange){
def = def || type === "checkbox" ? false : "";
let val = getIn(data, path) || def;
return inputTemplates[type](path, val, handleChange);
}
function addToObject(data, path, val){
let ref = data;
path.split(".").forEach( (key, index, arr) => {
if(index + 1 == arr.length){
ref[key] = val;
} else {
ref[key] = ref[key] || {};
ref = ref[key];
}
});
return data;
}
function getSkillLevels(character){
let skills = Immutable.Map();
character.skills && Immutable.fromJS(character.skills).toArray().forEach( (level, levelIndex)=>(
level.toArray().forEach( (skill)=>(
!!skill && (skills = skills.set(skill, levelIndex+1))
) )
) );
return skills.toJS();
}
function collectData(el){
var model = {};
el.querySelectorAll("[data-model]").forEach( (el)=>{
let val = el.getAttribute("type") === "checkbox" ? el.checked : el.value;
let path = el.getAttribute("data-model");
model = addToObject(model, path, val);
} );
console.log("collectData", model);
return model;
}
function update(newData){
console.log("update", newData);
data = newData;
ReactDOM.render(views[data.view](data), document.querySelector("#wrapper"));
};
function connectDB(){
var config = {
apiKey: "AIzaSyDIKfY8EvvjFBeF_uEW0ajV6jhVtsw9qs0",
authDomain: "fir-demom.firebaseapp.com",
databaseURL: "https://fir-demom.firebaseio.com",
storageBucket: "",
};
var db = firebase.initializeApp(config).database();
ref = db.ref("fate");
ref.on("child_added", (snapshot)=>{
let character = snapshot.val();
character.id = snapshot.key;
console.log("child_added", character );
update( addCharacter(data, character ) )
});
ref.on("child_removed", (snapshot)=>{
console.log("child_removed", snapshot.key );
update( removeCharacter(data, snapshot.key ) )
});
ref.on("child_changed", (snapshot)=>{
console.log("child_changed", snapshot.key, snapshot.val() );
update( replaceCharacter(data, snapshot.val() ) )
});
};
function getUnsavedCharacterInput(type, path, dataItem, def){
return getInput(type, path, dataItem, def, (newVal)=>{
let character = data.viewData.id !== undefined && data.characters.find( (item) => ( item.id === data.viewData.id ) ) || {};
return update({
...data,
viewData: {
...data.viewData,
unsaved: Immutable.fromJS(data.viewData.unsaved || character).setIn(path.split("."), newVal).toJS()
}
})
});
};
function closeModal(){
update({
...data,
viewData: {
...data.viewData,
modal: undefined
}
});
}
function displayDeleteCharacterConfirmation(id, data){
let character = data.characters.find( (item) => ( item.id === id ) ) || {};
update({
...data,
viewData: {
...data.viewData,
modal: {
cls: "modal-danger",
type: "confirm",
text: "Opravdu si přejete smazat charakter " + character.name + "?",
confirm: ()=>{
closeModal();
deleteCharacter.bind(character.id)();
},
cancel: closeModal
}
}
});
}
const views = {
modal: (modalData) => {
return <div className={ "modal " + (modalData.type ? "modal-" + modalData.type : "" ) + " " + (modalData.cls || "") } >
<div className="modal-content" >
<div className="modal-body" >{modalData.text}</div>
<div className="modal-controls" >
{ modalData.cancel && <button onClick={modalData.cancel} className="btn modal-cancel">{config.texts.cancel}</button> || null }
{ modalData.confirm && <button onClick={modalData.confirm} className="btn btn-primary modal-confirm" >{config.texts.confirm}</button> || null }
</div>
</div>
</div>
},
list: (data) => {
let modal = !data.viewData.modal ? undefined : views["modal"](data.viewData.modal);
return <div className="list" >
{ modal }
<h2>Characters</h2>
<div className="character-list" >
{ data.characters.map( (item)=>( <div className="list-item" ><div className="item-title" >{item.name}</div><div className="actions" ><button className="edit btn" onClick={ editCharacter.bind(item.id) } >{config.texts.edit}</button> <button className="delete btn btn-danger" onClick={ displayDeleteCharacterConfirmation.bind(this, item.id, data) } >x</button></div></div> ) ) }
</div>
<p><button onClick={editCharacter} className="btn btn-primary">+ {config.texts.new}</button></p>
</div>
}, editCharacter: (data) => {
let character = data.viewData.id !== undefined && data.characters.find( (item) => ( item.id === data.viewData.id ) ) || {};
character = Immutable.fromJS(character).mergeDeep( data.viewData.unsaved ).toJS();
console.log("character", character);
let skills = getSkillLevels(character);
let maxConsequences = config.consequences.defaultCount;
if( config.consequences.skills.reduce( (maxLevel, skill)=>( Math.max( maxLevel, skills[skill] || 0 ) ), 0 ) >= config.consequences.bonusSkillLevel ){
maxConsequences += 1;
}
return <div className="editView" >
<div className="cs">
<div className="row mb-sm">
<div className="col-xs-12 col-sm-8">
<div className="row mb-sm">
<div className="col-xs-12 col-sm-12"><h2>ID</h2></div>
<div className="col-xs-12 col-sm-9 name-column">
<label className="input-wrap name mb-sm"><span>Name</span>{getUnsavedCharacterInput("text", "name", character)}</label>
<div className="description mb-sm" ><label className="textarea-wrap"><span>Description</span>{getUnsavedCharacterInput("textarea", "description", character)}</label></div>
</div>
<div className="col-xs-12 col-sm-3 refresh mb-sm">
<label className="textarea-wrap"><span>Refresh</span>{getUnsavedCharacterInput("textarea", "refresh", character)}</label>
</div>
</div>
</div>
<div className="col-xs-12 col-sm-4 logo mb-sm">
<img src="http://3.bp.blogspot.com/-bJjDZ-FtpHQ/ULRW6uKF3JI/AAAAAAAAAh8/PRrdYkH2fi8/s1600/Fate%2BCore%2BCover.png" alt="FATE core system" />
</div>
</div>
<div className="row mb">
<div className="col-xs-12 col-sm-5 aspects">
<h2>Aspects</h2>
<label className="input-wrap"><span>Main&nbsp;aspect</span>{getUnsavedCharacterInput("text", "aspects.main", character)}</label>
<label className="input-wrap"><span>Trouble</span>{getUnsavedCharacterInput("text", "aspects.trouble", character)}</label>
<label className="input-wrap">{getUnsavedCharacterInput("text", "aspects.3", character)}</label>
<label className="input-wrap">{getUnsavedCharacterInput("text", "aspects.4", character)}</label>
<label className="input-wrap">{getUnsavedCharacterInput("text", "aspects.5", character)}</label>
</div>
<div className="col-xs-12 col-sm-7 skills">
<h2>Skills</h2>
{ seq(config.skillLevels).reverse().map( (level) =>{
return <div className="row">
<div className="col-xs-12 col-sm-2">{config.challengeLevels[level]}&nbsp;(+{level+1})</div>
{ seq(config.skillsPerLevel).map( (column)=>{
let currentPath = "skills.level"+(level+1)+".column"+(column+1);
let val = getIn(character, currentPath);
let inputState = "skill-valid";
let title;
if( level !== 0 ){
let lowerSkillCount = Immutable.fromJS( getIn(character, "skills.level"+(level) ) || {} ).toArray().filter( (val)=>( !!val ) ).length;
if( column >= lowerSkillCount) {
if(!!val){
inputState = "skill-invalid";
title = "This exceeds maximum number of skills on this level";
} else {
inputState = "skill-locked";
title = "Maximum number of skills on this level reached";
}
}
}
return <div className="col-xs-12 col-sm-2"><label className={"input-wrap " + inputState} title={title} >{getUnsavedCharacterInput("text", currentPath, character)}<div className="skill-list" > {[config.texts.clearText].concat(config.skillList).map( (skill)=>( <button onClick={ fillSkill.bind({ level: "level"+(level+1), column: "column"+(column+1), skill: (skill === config.texts.clearText ? "" : skill), character}) } >{skill}</button> ) )}</div></label></div>
} ) }
</div> } ) }
</div>
</div>
<div className="row mb">
<div className="col-xs-12 col-sm-6">
<h2>Extras</h2>
<div className="extras" >{getUnsavedCharacterInput("textarea", "extras", character)}</div>
</div>
<div className="col-xs-12 col-sm-6">
<h2>Stunts</h2>
<div className="stunts" >{getUnsavedCharacterInput("textarea", "stunts", character)}</div>
</div>
</div>
<div className="row">
<div className="col-xs-12 col-sm-4">
{ config.stress.map( (stress)=>{
let maxStress = stress.def;
if(stress.skill in skills){
maxStress = maxStress + (skills[stress.skill] >= 3 ? 2 : 1);
}
return <div className="stress-lane" ><h2>{stress.label} <span className="note">({stress.skill})</span></h2>
<div className="stress">
{ seq(stress.count).map( (stressBox, index)=>(
<label className={ "checkbox" + ((index >= maxStress) ? " disabled" : "") } ><span><i className="superscript">{index+1}</i></span>{getUnsavedCharacterInput("checkbox", "stress."+stress.key+"."+(index+1), character)}<s></s></label>
) ) }
</div>
</div>
} ) }
</div>
<div className="col-xs-12 col-sm-8 consequences">
<h2>Consequences</h2>
<div>{config.consequences.list.map( (consequence, index)=>(
<label key={consequence.key} className={"input-wrap" + (index >= maxConsequences ? " disabled" : "")}><i className="superscript" >{consequence.val}</i><span>{consequence.label}</span>{getUnsavedCharacterInput("text",consequence.key, character)}</label>
) )}</div>
</div>
</div>
</div>
<div className="character-edit-controls" ><hr /><button onClick={cancelCharacterEdit} className="btn" >{config.texts.cancel}</button><button onClick={saveCharacter} className="btn btn-success" >{config.texts.save}</button></div>
</div>;
}
}
connectDB();
update(data);
<script src="https://npmcdn.com/react@15.3.0/dist/react.min.js"></script>
<script src="https://npmcdn.com/react-dom@15.3.0/dist/react-dom.min.js"></script>
<script src="http://www.gstatic.com/firebasejs/live/3.0/firebase.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/immutable/3.8.1/immutable.js"></script>
*, *:after, *:before {
box-sizing: border-box;
}
#wrapper {
padding: 20px;
min-width: 300px;
overflow: auto;
}
h2 {
position: relative;
background-color: #000;
color: #fff;
text-transform: uppercase;
margin: 0 0 10px 6px;
font-size: 14px;
line-height: 18px;
.note {
display: inline-block;
font-size: 10px;
line-height: inherit;
}
&:after {
content: "";
position: absolute;
left: -6px;
top: 0;
width: 0;
height: 100%;
border-top: 6px solid transparent;
border-right: 6px solid #000;
}
}
.btn {
position: relative;
border: 2px solid #000;
background-color: transparent;
font-size: 12px;
text-transform: uppercase;
background-color: #000;
color: #fff;
font-weight: bold;
padding: 0 5px 0 0;
margin-left: 6px;
&:before {
content: "";
position: absolute;
left: -8px;
top: -2px;
width: 0;
bottom: -2px;
border-top: 6px solid transparent;
border-right: 6px solid #000;
}
&:hover {
color: #000;
background-color: #fff;
&:after {
content: "";
position: absolute;
left: -6px;
top: 0px;
width: 0;
bottom: 0px;
border-top: 6px solid transparent;
border-right: 6px solid #fff;
}
}
&-success {
background-color: #0a0;
border-color: #0a0;
&:hover {
color: #0a0;
}
&:before {
border-color: transparent #0a0;
}
}
&-danger {
background-color: #a00;
border-color: #a00;
&:hover {
color: #a00;
}
&:before {
border-color: transparent #a00;
}
}
&-primary {
background-color: #00a;
border-color: #00a;
&:hover {
color: #00a;
}
&:before {
border-color: transparent #00a;
}
}
}
.modal {
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0,0,0,0.9);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
overflow: auto;
&-content {
position: relative;
background-color: #fff;
padding: 20px;
max-width: 400px;
min-width: 200px;
clip-path: polygon(20px 0, 100% 0, 100% 100%, 0 100%, 0 20px);
}
&-controls {
margin-top: 10px;
padding-top: 10px;
border-top: 2px solid #000;
text-align: right;
button + button {
margin-left: 10px;
}
}
&-danger &-content {
border: 10px solid #a00;
&:after {
content: "";
position: absolute;
left: 0;
top: 0;
border-left: 15px solid #a00;
border-bottom: 15px solid transparent
}
}
}
hr {
background: #000;
height: 2px;
}
.character-edit-controls {
text-align: right;
margin-top: 20px;
button + button {
margin-left: 10px;
}
}
.character-list {
.list-item {
padding: 4px 0;
border-bottom: 2px solid #000;
display: flex;
&:hover {
background-color: #f2f2f2;
}
}
.item-title {
flex: 1 0 auto;
font-weight: bold;
}
}
.cs {
position: relative;
background-color: #fff;
font-size: 10px;
&:after {
position: absolute;
content: "";
left: 0;
top: 0;
width: 100%;
height: 100%;
opacity: 0.5;
z-index: -1;
//background: transparent url('http://rpgknights.com/wp-content/uploads/2013/10/Fate-Core-Character-Sheet-Draft1.jpg') 0 0 no-repeat;
background-size: contain;
}
$inputBorder: 2px solid #000;
.label {
cursor: text;
border: $inputBorder;
background-color: #fff;
line-height: 1.5;
padding: 0 4px;
color: #888;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
text-transform: uppercase;
}
.logo img {
display: block;
width: 100%;
}
.input-wrap {
position: relative;
display: flex;
min-width: 64px;
&.disabled, &.skill-locked {
input, span, i {
border-color: #888;
color: #888;
pointer-events: none;
}
}
&.skill-invalid {
input, span i {
border-color: #a00;
color: #a00;
pointer-events: none;
}
}
span {
@extend .label;
flex: 0 1 auto;
border-right: 0;
min-width: 32px;
&+input {
border-left: 0;
}
}
input {
line-height: 1.5;
flex: 1 0 0;
width: 0;
border: $inputBorder;
padding: 0 5px;
min-width: 32px;
&:focus + .skill-list {
visibility: visible;
opacity: 1;
}
}
.skill-list {
position: absolute;
opacity: 0;
visibility: hidden;
top: 100%;
right: 0;
padding: 10px;
background-color: rgba(255,255,255,0.9);
z-index: 1;
min-width: 300px;
border: 2px solid #888;
transition: all 0.5s;
button {
padding: 0;
border: 0;
color: #44a;
background: transparent none;
cursor: pointer;
display: inline;
margin: 0 5px 5px 5px;
}
}
}
.textarea-wrap {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
&.disabled span, &.disabled textarea, &.disabled .superscript {
border-color: #888;
color: #888;
pointer-events: none;
}
span {
@extend .label;
border-bottom: 0;
display: block;
flex: 0;
}
textarea {
flex: 1 1 40px;
line-height: 1.5;
width: 100%;
border: $inputBorder;
border-top: 0;
resize: vertical;
}
}
.superscript {
position: absolute;
font-size: 1.8em;
font-weight: bold;
left: 0;
top: 0;
font-style: normal;
z-index: 1;
text-shadow: 2px 2px 0 #fff, -2px -2px 0 #fff, -2px 2px 0 #fff, 2px -2px 0 #fff;
transform: translate(-50%, -50%);
}
.checkbox {
position: relative;
display: inline-block;
cursor: pointer;
input {
position: absolute;
clip: rect(0,0,0,0);
&:checked + s:after {
content: "";
position: absolute;
left: 2px;
top: 2px;
right: 2px;
bottom: 2px;
background-color: #000;
}
}
s {
user-select: none;
position: relative;
display: inline-block;
width: 20px;
height: 20px;
border: $inputBorder;
}
&.disabled, &.disabled input, &.disabled s, &.disabled span {
pointer-events: none;
cursor: auto;
border-color: #888;
color: #888
}
}
.mb {
margin-bottom: 10px;
}
.mb-sm {
margin-bottom: 5px;
}
.name-column {
display: flex;
flex-direction: column;
.description {
flex-grow: 1;
display: flex;
flex-direction: column;
position: relative;
.textarea-wrap {
display: flex;
flex-direction: column;
flex: 1;
}
}
}
.aspects label {
margin-bottom: 5px;
}
.skills .row > div {
margin-bottom: 5px;
}
.extras textarea, .stunts textarea {
width: 100%;
height: 80px;
border: $inputBorder;
resize: vertical;
}
.consequences label + label {
margin-top: 5px;
}
.stress {
margin: 0 -5px 10px -5px;
display: flex;
flex-wrap: wrap;
label {
flex: 1;
margin: 0 5px 10px 5px;
}
}
.refresh textarea {
text-align: center;
font-size: 60px;
line-height: 60px;
}
}
<link href="//cdnjs.cloudflare.com/ajax/libs/flexboxgrid/6.3.1/flexboxgrid.min.css" rel="stylesheet" />
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment