/*
* Version 0.1.11
* Made By Robin Kuiper
* Skype: RobinKuiper.eu
* Discord: Atheos#1095
* Roll20: https://app.roll20.net/users/1226016/robin
* Roll20 Thread: https://app.roll20.net/forum/post/6349145/script-combattracker
* Github: https://github.com/RobinKuiper/Roll20APIScripts
* Reddit: https://www.reddit.com/user/robinkuiper/
* Patreon: https://patreon.com/robinkuiper
* Paypal.me: https://www.paypal.me/robinkuiper
*/
/* TODO
*
* Styling
* More chat message options
* Show menu with B shows always
* Add icon if not StatusInfo (?) (IF YES, remove conditions on statusmarker remove)
* Edit Conditions
*/
var CombatTracker = CombatTracker || (function() {
'use strict';
let round = 1,
timerObj,
intervalHandle,
paused = false,
observers = {
tokenChange: []
};
// Styling for the chat responses.
const styles = {
reset: 'padding: 0; margin: 0;',
menu: 'background-color: #fff; border: 1px solid #000; padding: 5px; border-radius: 5px;',
button: 'background-color: #000; border: 1px solid #292929; border-radius: 3px; padding: 5px; color: #fff; text-align: center;',
textButton: 'background-color: transparent; border: none; padding: 0; color: #000; text-decoration: underline',
list: 'list-style: none;',
float: {
right: 'float: right;',
left: 'float: left;'
},
overflow: 'overflow: hidden;',
fullWidth: 'width: 100%;',
underline: 'text-decoration: underline;',
strikethrough: 'text-decoration: strikethrough'
},
script_name = 'CombatTracker',
state_name = 'COMBATTRACKER',
handleInput = (msg) => {
if (msg.type != 'api') return;
let args = msg.content.split(' ');
let command = args.shift().substring(1);
let extracommand = args.shift();
if(command !== state[state_name].config.command) return;
if(extracommand === 'next'){
if(playerIsGM(msg.playerid) || msg.playerid === 'api'){
NextTurn();
return;
}
let turn = getCurrentTurn(),
token;
if(token = getObj('graphic', turn.id)){
let character = getObj('character', token.get('represents'));
if((token.get('controlledby').split(',').includes(msg.playerid) || token.get('controlledby').split(',').includes('all')) ||
(character && (character.get('controlledby').split(',').includes(msg.playerid) || character.get('controlledby').split(',').includes(msg.playerid)))){
NextTurn();
// SHOW MENU
}
}
}
// Below commands are only for GM's
if(!playerIsGM(msg.playerid)) return;
let name, duration, direction, message, condition;
switch(extracommand){
case 'help':
sendHelpMenu();
break;
case 'reset':
switch(args.shift()){
case 'conditions':
state[state_name].conditions = {}
break;
default:
state[state_name] = {};
setDefaults(true);
sendConfigMenu();
break;
}
break;
case 'config':
if(args[0] === 'timer'){
if(args[1]){
let setting = args[1].split('|');
let key = setting.shift();
let value = (setting[0] === 'true') ? true : (setting[0] === 'false') ? false : setting[0];
state[state_name].config.timer[key] = value;
}
sendConfigTimerMenu();
}else if (args[0] === 'announcements'){
if(args[1]){
let setting = args[1].split('|');
let key = setting.shift();
let value = (setting[0] === 'true') ? true : (setting[0] === 'false') ? false : setting[0];
state[state_name].config.announcements[key] = value;
}
sendConfigAnnounceMenu();
}else{
if(args[0]){
let setting = args.shift().split('|');
let key = setting.shift();
let value = (setting[0] === 'true') ? true : (setting[0] === 'false') ? false : setting[0];
state[state_name].config[key] = value;
}
sendConfigMenu();
}
break;
case 'prev':
PrevTurn();
break;
case 'start':
startCombat(msg.selected);
if(args.shift() === 'b') sendMenu();
break;
case 'stop':
stopCombat();
if(args.shift() === 'b') sendMenu();
break;
case 'st':
stopTimer();
if(args.shift() === 'b') sendMenu();
break;
case 'pt':
pauseTimer();
if(args.shift() === 'b') sendMenu();
break;
case 'add':
name = args.shift();
duration = args.shift();
duration = (!duration || duration === 0) ? 'none' : duration;
direction = args.shift() || -1;
message = args.join(' ');
condition = { name, duration, direction, message };
if(!msg.selected || !msg.selected.length || !name){
makeAndSendMenu('No tokens were selected.', '', 'gm');
return;
}
msg.selected.forEach(s => {
let token = getObj(s._type, s._id);
if(!token) return;
addCondition(token, condition, true);
});
break;
case 'addfav':
name= args.shift();
duration = args.shift();
direction = args.shift() || -1;
message = args.join(' ');
condition = { name, duration, direction, message };
addOrEditFavoriteCondition(condition);
sendFavoritesMenu();
break;
case 'editfav':
name = args.shift();
if(!name){
makeAndSendMenu('No condition name was given.', '', 'gm');
return;
}
name = strip(name).toLowerCase();
condition = state[state_name].favorites[name]
if(!condition){
makeAndSendMenu('Condition does not exists.', '', 'gm');
return;
}
if(args[0]){
let setting = args.shift().split('|');
let key = setting.shift();
let value = (setting[0] === 'true') ? true : (setting[0] === 'false') ? false : setting[0];
state[state_name].favorites[name][key] = value;
if(key === 'name'){
state[state_name].favorites[strip(value).toLowerCase()] = state[state_name].favorites[name];
delete state[state_name].favorites[name];
}
}
sendEditFavoriteConditionMenu(condition);
break;
case 'removefav':
removeFavoriteCondition(args.shift())
sendFavoritesMenu();
break;
case 'favorites':
sendFavoritesMenu();
break;
case 'remove':
let cname = args.shift();
if(!msg.selected || !msg.selected.length || !cname) return;
msg.selected.forEach(s => {
let token = getObj(s._type, s._id);
if(!token) return;
removeCondition(token, cname);
});
break;
default:
sendMenu();
break;
}
},
addOrEditFavoriteCondition = (condition) => {
if(condition.duration === 0 || condition.duration === '') condition.duration = undefined;
let strippedName = strip(condition.name).toLowerCase();
state[state_name].favorites[strippedName] = condition;
},
removeFavoriteCondition = (name) => {
name = strip(name).toLowerCase();
delete state[state_name].favorites[name];
},
addCondition = (token, condition, announce=false) => {
if('undefined' !== typeof StatusInfo && StatusInfo.getConditionByName){
const duration = condition.duration;
const direction = condition.direction;
const message = condition.message;
condition = StatusInfo.getConditionByName(condition.name) || condition;
condition.duration = duration;
condition.direction = direction;
condition.message = message;
}
if(!condition.duration || condition.duration === 0 || condition.duration === '0' || condition.duration === '' || condition.duration === 'none') condition.duration = undefined;
if(state[state_name].conditions[strip(token.get('name'))]){
let hasCondition = false;
state[state_name].conditions[strip(token.get('name'))].forEach(c => {
if(c.name.toLowerCase() === condition.name.toLowerCase()) hasCondition = true;
})
if(hasCondition) return;
state[state_name].conditions[strip(token.get('name'))].push(condition);
}else{
state[state_name].conditions[strip(token.get('name'))] = [condition];
}
if(condition.icon){
let prevSM = token.get('statusmarkers');
token.set('status_'+condition.icon, true);
if(announce && 'undefined' !== typeof StatusInfo && StatusInfo.sendConditionToChat){
StatusInfo.sendConditionToChat(condition);
}
}else makeAndSendMenu('Condition ' + condition.name + ' added to ' + token.get('name'));
},
removeCondition = (token, condition_name, auto=false) => {
if(!state[state_name].conditions[strip(token.get('name'))]) return;
let si_condition = false;
if('undefined' !== typeof StatusInfo && StatusInfo.getConditionByName){
si_condition = StatusInfo.getConditionByName(condition_name) || false;
}
state[state_name].conditions[strip(token.get('name'))].forEach((condition, i) => {
if(condition.name.toLowerCase() !== condition_name.toLowerCase()) return;
state[state_name].conditions[strip(token.get('name'))].splice(i, 1);
if(si_condition){
//StatusInfo.Conditions([condition_name], [token], 'remove', false);
token.set('status_'+condition.icon, false);
}else if(!auto){
makeAndSendMenu('Condition ' + condition.name + ' removed from ' + token.get('name'));
}
});
},
strip = (str) => {
return str.replace(/[^a-zA-Z0-9]+/g, '_');
},
handleTurnorderChange = (obj, prev) => {
if(obj.get('turnorder') === prev.turnorder) return;
let turnorder = (obj.get('turnorder') === "") ? [] : JSON.parse(obj.get('turnorder'));
let prevTurnorder = (prev.turnorder === "") ? [] : JSON.parse(prev.turnorder);
if(obj.get('turnorder') === "[]"){
resetMarker();
stopTimer();
return;
}
if(turnorder.length && prevTurnorder.length && turnorder[0].id !== prevTurnorder[0].id){
doTurnorderChange();
}
},
handleGraphicChange = (obj, prev) => {
if(!inFight()) return;
if(getCurrentTurn().id === obj.get('id')){
changeMarker(obj);
}
if('undefined' !== typeof StatusInfo && StatusInfo.getConditions){
prev.statusmarkers = (typeof prev.get === 'function') ? prev.get('statusmarkers') : prev.statusmarkers;
if(obj.get('statusmarkers') !== prev.statusmarkers){
let nS = obj.get('statusmarkers').split(','),
oS = prev.statusmarkers.split(',');
// Marker added?
array_diff(oS, nS).forEach(icon => {
getObjects(StatusInfo.getConditions(), 'icon', icon).forEach(condition => {
addCondition(obj, { name: condition.name });
});
})
// Marker Removed?
array_diff(nS, oS).forEach(icon => {
getObjects(StatusInfo.getConditions(), 'icon', icon).forEach(condition => {
removeCondition(obj, condition.name);
});
})
}
}
},
array_diff = (a, b) => {
return b.filter(function(i) {return a.indexOf(i) < 0;});
},
//return an array of objects according to key, value, or key and value matching
getObjects = (obj, key, val) => {
var objects = [];
for (var i in obj) {
if (!obj.hasOwnProperty(i)) continue;
if (typeof obj[i] == 'object') {
objects = objects.concat(getObjects(obj[i], key, val));
} else
//if key matches and value matches or if key matches and value is not passed (eliminating the case where key matches but passed value does not)
if (i == key && obj[i] == val || i == key && val == '') { //
objects.push(obj);
} else if (obj[i] == val && key == ''){
//only add if the object is not already in the array
if (objects.lastIndexOf(obj) == -1){
objects.push(obj);
}
}
}
return objects;
},
startCombat = (selected) => {
paused = false;
resetMarker();
Campaign().set('initiativepage', Campaign().get('playerpageid'));
if(selected && state[state_name].config.throw_initiative){
rollInitiative(selected, true);
}
},
inFight = () => {
return (Campaign().get('initiativepage') !== false);
},
rollInitiative = (selected, sort) => {
let character;
selected.forEach(s => {
if(s._type !== 'graphic') return;
let token = getObj('graphic', s._id),
//whisper = (token.get('layer') === 'gmlayer') ? '/w gm ' : '',
bonus = parseFloat(getAttrByName(token.get('represents'), state[state_name].config.initiative_attribute_name, 'current')) || 0;
let pr = randomBetween(1,20)+bonus
pr = (Math.round(pr) !== pr) ? pr.toFixed(2) : pr;
addToTurnorder({ id: token.get('id'), pr, custom: '', pageid: token.get('pageid') });
});
if(sort){
sortTurnorder();
}
},
stopCombat = () => {
if(timerObj) timerObj.remove();
clearInterval(intervalHandle);
removeMarker();
paused = false;
Campaign().set({
initiativepage: false,
turnorder: ''
});
state[state_name].turnorder = {};
round = 1;
},
removeMarker = () => {
getOrCreateMarker().remove();
},
resetMarker = () => {
let marker = getOrCreateMarker();
marker.set({
name: 'Round ' + round,
imgsrc: state[state_name].config.marker_img,
pageid: Campaign().get('playerpageid'),
layer: 'gmlayer',
left: 35, top: 35,
width: 70, height: 70
});
},
doTurnorderChange = (prev=false) => {
if(!Campaign().get('initiativepage')) return;
let turn = getCurrentTurn();
if(turn.id === '-1') return;
if(turn.id === getOrCreateMarker().get('id')){
if(prev) PrevRound();
else NextRound();
return;
}
let token = getObj('graphic', turn.id);
toFront(token);
if(state[state_name].config.timer.use_timer){
startTimer(token);
}
changeMarker(token || false);
if(state[state_name].config.announcements.announce_turn){
announceTurn(token || turn.custom, (token.get('layer') === 'objects') ? '' : 'gm')
}else if(state[state_name].config.announcements.announce_conditions){
let name = token.get('name') || turn.custom;
let conditions = getConditionString(token);
if(conditions && conditions !== '') makeAndSendMenu(conditions, 'Conditions - ' + name, (token.get('layer') === 'objects') ? '' : 'gm');
}
},
startTimer = (token) => {
paused = false;
clearInterval(intervalHandle);
if(timerObj) timerObj.remove();
let config_time = parseInt(state[state_name].config.timer.time);
let time = config_time;
if(token && state[state_name].config.timer.token_timer){
timerObj = createObj('text', {
text: 'Timer: ' + time,
font_size: state[state_name].config.timer.token_font_size,
font_family: state[state_name].config.timer.token_font,
color: state[state_name].config.timer.token_font_color,
pageid: token.get('pageid'),
layer: 'gmlayer'
});
}
intervalHandle = setInterval(() => {
if(paused) return;
if(timerObj) timerObj.set({
top: token.get('top')+token.get('width')/2+40,
left: token.get('left'),
text: 'Timer: ' + time,
layer: token.get('layer')
});
if(state[state_name].config.timer.chat_timer && (time === config_time || config_time/2 === time || config_time/4 === time || time === 10 || time === 5)){
makeAndSendMenu('', 'Time Remaining: ' + time);
}
if(time <= 0){
if(timerObj) timerObj.remove();
clearInterval(intervalHandle);
NextTurn();
}
time--;
}, 1000);
},
stopTimer = () => {
clearInterval(intervalHandle);
if(timerObj) timerObj.remove();
},
pauseTimer = () => {
paused = !paused;
},
announceTurn = (token, target) => {
let name, imgurl;
if(typeof token === 'object'){
name = token.get('name');
imgurl = token.get('imgsrc');
}else{
name = token;
}
let conditions = getConditionString(token);
let image = (imgurl) ? '' : '';
name = (state[state_name].config.announcements.handleLongName) ? handleLongString(name) : name;
let contents = '\
| '+image+' | \'+name+'\'s Turn | \
' + message + '
' + messageButton + ''+message+'
' : ''; let contents = message+makeList(listItems, styles.reset + styles.list + styles.overflow, styles.overflow)+configTimerButton+configAnnouncementsButton+'You can always come back to this config by typing `!'+state[state_name].config.command+' config`.