Compare commits
6 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
331e8a7da5 | ||
|
cf3815446a | ||
|
5510af02a1 | ||
|
30e4a4a62a | ||
|
061637c481 | ||
|
249cfdf95f |
17
README.md
Normal file
17
README.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
Yet Another Timer
|
||||||
|
===
|
||||||
|
|
||||||
|
A simple Foundry VTT timer module, allowing to start timers, pause and save them, show them to the players or hide them away.
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
1. Create and start a new timer for a particular time
|
||||||
|
|
||||||
|
- from the chat command /yat 3:00
|
||||||
|
- from a macro
|
||||||
|
|
||||||
|
2. Display a timer time on the screen.
|
||||||
|
|
||||||
|
3. Alert when the timer is up.
|
||||||
|
- make a sound
|
||||||
|
- make a visual effect
|
BIN
assets/time-out.jpg
Normal file
BIN
assets/time-out.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.6 KiB |
29
lang/de.json
Normal file
29
lang/de.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"YAT": {
|
||||||
|
"title": "Noch Ein Timer",
|
||||||
|
"duration": "Dauer",
|
||||||
|
"time-remaining": "Verbliebend",
|
||||||
|
"resume": "Fortsetzen",
|
||||||
|
"pause": "Anhalten",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"resume-runner": "Timer fortsetzen",
|
||||||
|
"pause-runner": "Timer anhalten",
|
||||||
|
"status": {
|
||||||
|
"timers-are": "Timers sind",
|
||||||
|
"timers-paused": "pausiert",
|
||||||
|
"timers-active": "aktiv",
|
||||||
|
"add-timer": "Timer hinzufügen"
|
||||||
|
},
|
||||||
|
"timer": {
|
||||||
|
"complete": "Timer fertig",
|
||||||
|
"your-timer-is-complete": "Dein Timer \"{name}\" ist ausgelaufen"
|
||||||
|
},
|
||||||
|
"add-timer": {
|
||||||
|
"timer name": "Timer Name",
|
||||||
|
"duration": "Dauer",
|
||||||
|
"format-hint": "Nutze den Format n, mm:ss oder hh:mm:ss für Stunden/Minuten/Sekunden.",
|
||||||
|
"start-now": "Gleich anfangen"
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
28
lang/en.json
Normal file
28
lang/en.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"YAT": {
|
||||||
|
"title": "Yet Another Timer",
|
||||||
|
"duration": "Duration",
|
||||||
|
"time-remaining": "Time remaining",
|
||||||
|
"resume": "Resume",
|
||||||
|
"pause": "Pause",
|
||||||
|
"delete": "Delete",
|
||||||
|
"resume-runner": "Resume timer",
|
||||||
|
"pause-runner": "Pause timer",
|
||||||
|
"status": {
|
||||||
|
"timers-are": "Timers are",
|
||||||
|
"timers-paused": "paused",
|
||||||
|
"timers-active": "active",
|
||||||
|
"add-timer": "Add timer"
|
||||||
|
},
|
||||||
|
"timer": {
|
||||||
|
"complete": "Timer complete",
|
||||||
|
"your-timer-is-complete": "Your timer \"{name}\" is complete"
|
||||||
|
},
|
||||||
|
"add-timer": {
|
||||||
|
"timer name": "Timer name",
|
||||||
|
"duration": "Duration",
|
||||||
|
"format-hint": "Use format s, mm:ss or hh:mm:ss for hours/minutes/seconds.",
|
||||||
|
"start-now": "Start now"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
29
lang/hr.json
Normal file
29
lang/hr.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"YAT": {
|
||||||
|
"title": "Još Jedan Timer",
|
||||||
|
"duration": "Trajanje",
|
||||||
|
"time-remaining": "Preostalo vrijeme",
|
||||||
|
"resume": "Nastavi",
|
||||||
|
"pause": "Zaustavi",
|
||||||
|
"delete": "Obriši",
|
||||||
|
"resume-runner": "Nastavi timer",
|
||||||
|
"pause-runner": "Zaustavi timer",
|
||||||
|
"status": {
|
||||||
|
"timers-are": "Brojači su",
|
||||||
|
"timers-paused": "zaustavljeni",
|
||||||
|
"timers-active": "aktivni",
|
||||||
|
"add-timer": "Dodaj timer"
|
||||||
|
},
|
||||||
|
"timer": {
|
||||||
|
"complete": "Timer gotov",
|
||||||
|
"your-timer-is-complete": "Tvoj timer \"{name}\" je završen"
|
||||||
|
},
|
||||||
|
"add-timer": {
|
||||||
|
"timer name": "Ime timera",
|
||||||
|
"duration": "Trajanje",
|
||||||
|
"format-hint": "Koristi šprancu s, mm:ss ili hh:mm:ss za sate/minute/sekunde.",
|
||||||
|
"start-now": "Pokreni odmah"
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
module.json
22
module.json
@ -9,11 +9,17 @@
|
|||||||
"url": "https://zlatko.dev"
|
"url": "https://zlatko.dev"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"minimum": 12,
|
"minimum": 12,
|
||||||
"verified": 12
|
"verified": 12
|
||||||
},
|
},
|
||||||
|
"esmodules": [
|
||||||
|
"src/timer.js"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"styles/yat-style.css"
|
||||||
|
],
|
||||||
"languages": [
|
"languages": [
|
||||||
{
|
{
|
||||||
"lang": "en",
|
"lang": "en",
|
||||||
@ -21,17 +27,17 @@
|
|||||||
"path": "lang/en.json"
|
"path": "lang/en.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"lang": "en",
|
"lang": "de",
|
||||||
"name": "English",
|
"name": "Deutsch",
|
||||||
"path": "lang/en.json"
|
"path": "lang/de.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"lang": "en",
|
"lang": "hr",
|
||||||
"name": "English",
|
"name": "Hrvatski",
|
||||||
"path": "lang/en.json"
|
"path": "lang/hr.json"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"url": "https://zlatko.dev/projects/vtt-timer",
|
"url": "https://zlatko.dev/projects/vtt-timer",
|
||||||
"manifest": "https://gitea.zlatko.dev/vtt/timer",
|
"manifest": "https://gitea.zlatko.dev/vtt/timer",
|
||||||
"download": "missing-url"
|
"download": "https://gitea.zlatko.dev/vtt/timer/archive/v0.1.0.zip"
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
"name": "@zladuric/timer",
|
"name": "@zladuric/timer",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
|
51
src/add-timer-screen.js
Normal file
51
src/add-timer-screen.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { logger } from './logger.js';
|
||||||
|
import { getNextName, createTimer } from './timer.repository.js';
|
||||||
|
|
||||||
|
const templatePath = `modules/yet-another-timer/templates/add-timer.hbs`;
|
||||||
|
|
||||||
|
export class AddTimerScreen extends FormApplication {
|
||||||
|
static get defaultOptions() {
|
||||||
|
const defaults = super.defaultOptions;
|
||||||
|
|
||||||
|
const overrides = {
|
||||||
|
height: "auto",
|
||||||
|
id: "yet-another-timer-add-timer",
|
||||||
|
template: templatePath,
|
||||||
|
title: game.i18n?.localize("YAT.status.add-timer") || "Add Timer",
|
||||||
|
closeOnSubmit: false,
|
||||||
|
submitOnChange: false,
|
||||||
|
};
|
||||||
|
return Object.assign({}, defaults, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
getData() {
|
||||||
|
return {
|
||||||
|
nextName: getNextName()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
activateListeners(html) {
|
||||||
|
logger.debug('Activating listeners...');
|
||||||
|
html.on('click', "[data-action]", this.#handleClick.bind(this));
|
||||||
|
logger.debug('Activated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateObject(event, formData) {
|
||||||
|
logger.debug("Adding a timer", formData, event);
|
||||||
|
createTimer(formData.duration, formData.name, formData.startNow);
|
||||||
|
this.close();
|
||||||
|
// also ping to update?
|
||||||
|
}
|
||||||
|
|
||||||
|
#handleClick(event) {
|
||||||
|
logger.debug('**click**', event);
|
||||||
|
const el = $(event.currentTarget);
|
||||||
|
const action = el.data().action;
|
||||||
|
logger.debug('handle debug on ', action);
|
||||||
|
const data = $(el).parents('form');
|
||||||
|
|
||||||
|
logger.debug('Data:', data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const showAddTimer = () => new AddTimerScreen().render(true);
|
5
src/error.js
Normal file
5
src/error.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export class TimerError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(`[Timer Module] ${message}`);
|
||||||
|
}
|
||||||
|
}
|
59
src/logger.js
Normal file
59
src/logger.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
const getPrefix = () => {
|
||||||
|
const prefixName = game.i18n?.localize("YAT.title") ||
|
||||||
|
"Timer Module";
|
||||||
|
return `[${prefixName}] `;
|
||||||
|
};
|
||||||
|
export const logger = (() => {
|
||||||
|
const originalConsole = console;
|
||||||
|
const wrapper = {};
|
||||||
|
|
||||||
|
const methodsToPrefix = [
|
||||||
|
"log",
|
||||||
|
"info",
|
||||||
|
"warn",
|
||||||
|
"error",
|
||||||
|
"debug",
|
||||||
|
"dir",
|
||||||
|
"dirxml",
|
||||||
|
"assert",
|
||||||
|
"count",
|
||||||
|
"countReset",
|
||||||
|
"group",
|
||||||
|
"groupCollapsed",
|
||||||
|
"groupEnd",
|
||||||
|
"time",
|
||||||
|
"timeLog",
|
||||||
|
"timeEnd",
|
||||||
|
"trace",
|
||||||
|
];
|
||||||
|
|
||||||
|
methodsToPrefix.forEach((methodName) => {
|
||||||
|
wrapper[methodName] = function (...args) {
|
||||||
|
const modifiedArgs = [...args];
|
||||||
|
const prefix = getPrefix();
|
||||||
|
|
||||||
|
// preserve string substitution formatting (e.g., %s, %d)
|
||||||
|
if (typeof modifiedArgs[0] === "string") {
|
||||||
|
modifiedArgs[0] = prefix + modifiedArgs[0];
|
||||||
|
} else {
|
||||||
|
modifiedArgs.unshift(prefix);
|
||||||
|
}
|
||||||
|
originalConsole[methodName].apply(originalConsole, modifiedArgs);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof originalConsole.table === "function") {
|
||||||
|
wrapper.table = function (...args) {
|
||||||
|
const prefix = getPrefix();
|
||||||
|
originalConsole.log(`=== ${prefix} ===`);
|
||||||
|
originalConsole.table.apply(originalConsole, args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof originalConsole.clear === "function") {
|
||||||
|
wrapper.clear = function () {
|
||||||
|
originalConsole.clear();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return wrapper;
|
||||||
|
})();
|
34
src/timer-complete-screen.js
Normal file
34
src/timer-complete-screen.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
const templatePath = `modules/yet-another-timer/templates/timer-complete.hbs`;
|
||||||
|
|
||||||
|
export class TimerCompleteScreen extends FormApplication {
|
||||||
|
static get defaultOptions() {
|
||||||
|
const defaults = super.defaultOptions;
|
||||||
|
|
||||||
|
const overrides = {
|
||||||
|
height: "auto",
|
||||||
|
resizable: true,
|
||||||
|
id: "yet-another-timer-timer-complete",
|
||||||
|
template: templatePath,
|
||||||
|
title: game.i18n?.localize("YAT.timer.complete") || "Timer complete",
|
||||||
|
};
|
||||||
|
return Object.assign({}, defaults, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
names: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
setData(data) {
|
||||||
|
logger.debug("Adding a completed timer", data);
|
||||||
|
if (data.name) {
|
||||||
|
// TODO: dynamically set the id somehow.
|
||||||
|
this.data.names.push(data.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getData() {
|
||||||
|
return this.data;
|
||||||
|
}
|
||||||
|
}
|
110
src/timer-screen.js
Normal file
110
src/timer-screen.js
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { logger } from "./logger.js";
|
||||||
|
import { getTimers } from "./timer.repository.js";
|
||||||
|
import { getStatus, pauseTimer, resumeTimer, deleteTimer, flipTimers, subscribe } from './timer.loop.js';
|
||||||
|
import { TimerError } from "./error.js";
|
||||||
|
import { showAddTimer } from './add-timer-screen.js';
|
||||||
|
|
||||||
|
const templatePath = `modules/yet-another-timer/templates/timer.hbs`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log the timer
|
||||||
|
* @param {Timer} timerInstance The timer to display.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function logTimer(timerInstance) {
|
||||||
|
logger.group(`Timer ${timerInstance.name}`);
|
||||||
|
logger.log(`Logging the timer:`, timerInstance);
|
||||||
|
logger.log(`Timer: ${timerInstance.name}`);
|
||||||
|
logger.log(` Initial: ${timerInstance.duration}`);
|
||||||
|
logger.log(
|
||||||
|
` Remaining: ${timerInstance.durationSeconds - timerInstance.elapsedTimeSeconds
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
logger.log(` Paused: ${timerInstance.isPaused}`);
|
||||||
|
logger.groupEnd(`Timer ${timerInstance.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(templatePath);
|
||||||
|
|
||||||
|
export class TimerScreen extends FormApplication {
|
||||||
|
static get defaultOptions() {
|
||||||
|
const defaults = super.defaultOptions;
|
||||||
|
|
||||||
|
const overrides = {
|
||||||
|
height: "auto",
|
||||||
|
resizable: true,
|
||||||
|
id: "yet-another-timer",
|
||||||
|
template: templatePath,
|
||||||
|
title: game.i18n?.localize("YAT.title") || "Yet another timer",
|
||||||
|
};
|
||||||
|
return Object.assign({}, defaults, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
getData() {
|
||||||
|
return {
|
||||||
|
timers: getTimers(),
|
||||||
|
config: {
|
||||||
|
isPaused: getStatus()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
activateListeners(html) {
|
||||||
|
logger.debug('Activating listeners...');
|
||||||
|
html.on('click', "[data-action]", this.#handleClick.bind(this));
|
||||||
|
logger.debug('Activated.');
|
||||||
|
// also make sure we're always active
|
||||||
|
subscribe(() => {
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#handleClick(event) {
|
||||||
|
logger.debug('**click**', event);
|
||||||
|
const el = $(event.currentTarget);
|
||||||
|
const action = el.data().action;
|
||||||
|
const timerName = el.parents('[data-timer-name]')?.data()?.timerName;
|
||||||
|
console.log(el);
|
||||||
|
logger.debug('timerName and action', timerName, action);
|
||||||
|
|
||||||
|
switch(action) {
|
||||||
|
case 'flip-timers':
|
||||||
|
flipTimers();
|
||||||
|
break;
|
||||||
|
case 'pause':
|
||||||
|
pauseTimer(timerName);
|
||||||
|
break;
|
||||||
|
case 'resume':
|
||||||
|
resumeTimer(timerName);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
deleteTimer(timerName);
|
||||||
|
break;
|
||||||
|
case 'add-timer':
|
||||||
|
showAddTimer()
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logger.error(`Unknown timer action: ${action}`);
|
||||||
|
throw new TimerError(`Unknown timer action: ${action}`);
|
||||||
|
}
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRemainingTime(durationSeconds, elapsedTimeSeconds) {
|
||||||
|
const total = Number(durationSeconds) || 0;
|
||||||
|
const elapsed = Number(elapsedTimeSeconds) || 0;
|
||||||
|
|
||||||
|
const remainingSeconds = Math.max(0, total - elapsed);
|
||||||
|
|
||||||
|
// Calculate minutes and seconds
|
||||||
|
const minutes = Math.floor(remainingSeconds / 60);
|
||||||
|
const seconds = Math.floor(remainingSeconds % 60); // Use Math.floor to handle potential floating point inaccuracies
|
||||||
|
|
||||||
|
// Pad with leading zeros if needed
|
||||||
|
const formattedMinutes = String(minutes).padStart(2, "0");
|
||||||
|
const formattedSeconds = String(seconds).padStart(2, "0");
|
||||||
|
|
||||||
|
return `${formattedMinutes}:${formattedSeconds}`;
|
||||||
|
}
|
||||||
|
Handlebars.registerHelper('formatRemainingTime', formatRemainingTime);
|
85
src/timer.js
Normal file
85
src/timer.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { logger } from './logger.js';
|
||||||
|
import { setScreen, startTimers, stopTimers, resumeTimer, triggerChatCommand, chatCommandName } from './timer.loop.js';
|
||||||
|
import { createTimer } from './timer.repository.js';
|
||||||
|
import { TimerScreen } from "./timer-screen.js";
|
||||||
|
|
||||||
|
let isPaused = true;
|
||||||
|
|
||||||
|
console.debug('Timer module booted!');
|
||||||
|
|
||||||
|
Hooks.once('init', function() {
|
||||||
|
logger.log('Timer module init');
|
||||||
|
const screen = new TimerScreen();
|
||||||
|
setScreen(screen);
|
||||||
|
});
|
||||||
|
|
||||||
|
Hooks.on('chatMessage', (chatLog, messageText, chatData) => {
|
||||||
|
if (messageText.trim().toLowerCase().startsWith(chatCommandName)) {
|
||||||
|
return triggerChatCommand(chatLog, messageText, chatData);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
Hooks.on('ready', function(details) {
|
||||||
|
logger.log('Timer module ready:', details);
|
||||||
|
|
||||||
|
startTimers();
|
||||||
|
isPaused = false;
|
||||||
|
|
||||||
|
addSomeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
Hooks.on('error', function(location, error, payload) {
|
||||||
|
logger.error(`Timer module error at ${location}`, error, payload ?? 'no payload');
|
||||||
|
});
|
||||||
|
|
||||||
|
Hooks.on('pauseGame', function(paused) {
|
||||||
|
if (paused) {
|
||||||
|
logger.log('Game paused.');
|
||||||
|
if (!isPaused) {
|
||||||
|
stopTimers();
|
||||||
|
isPaused = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.log('Game unpaused');
|
||||||
|
if (isPaused) {
|
||||||
|
startTimers()
|
||||||
|
isPaused = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function addSomeTimers() {
|
||||||
|
// TODO: Remove this debugging aid.
|
||||||
|
const pausedTimerName = createTimer('10');
|
||||||
|
createTimer('10');
|
||||||
|
createTimer('01:10', '', true);
|
||||||
|
createTimer('01:10', '', false);
|
||||||
|
createTimer('00:00:00', 'long', false);
|
||||||
|
createTimer('10');
|
||||||
|
createTimer('10');
|
||||||
|
createTimer('10');
|
||||||
|
createTimer('10');
|
||||||
|
createTimer('10');
|
||||||
|
createTimer('10');
|
||||||
|
|
||||||
|
setTimeout(() => resumeTimer(pausedTimerName), 5000);
|
||||||
|
|
||||||
|
createTimer('5', 'my timer', true);
|
||||||
|
/*
|
||||||
|
const flippyNamedTimer = createTimer('00:05', 'flippy', true);
|
||||||
|
|
||||||
|
let isOn = true;
|
||||||
|
setInterval(() => {
|
||||||
|
if (isOn) {
|
||||||
|
pauseTimer(flippyNamedTimer);
|
||||||
|
isOn = false;
|
||||||
|
} else {
|
||||||
|
resumeTimer(flippyNamedTimer);
|
||||||
|
isOn = true;
|
||||||
|
}
|
||||||
|
}, 2200);
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
188
src/timer.loop.js
Normal file
188
src/timer.loop.js
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { logger } from "./logger.js";
|
||||||
|
import { TimerError } from "./error.js";
|
||||||
|
import { getActiveTimers, getTimer, deleteTimer as repoDeleteTimer } from "./timer.repository.js";
|
||||||
|
import { logTimer } from "./timer-screen.js";
|
||||||
|
import { TimerCompleteScreen } from "./timer-complete-screen.js";
|
||||||
|
import { showAddTimer } from './add-timer-screen.js';
|
||||||
|
|
||||||
|
const INTERVAL_MS = 1000; // main loop interval
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
timerInterval: null,
|
||||||
|
subscribers: [],
|
||||||
|
screen: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const chatCommandName = '/timers';
|
||||||
|
|
||||||
|
export function getStatus() {
|
||||||
|
return state.timerInterval === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setScreen(screen) {
|
||||||
|
state.screen = screen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showChatHelpMessage() {
|
||||||
|
ChatMessage.create({
|
||||||
|
user: game.user.id,
|
||||||
|
speaker: ChatMessage.getSpeaker(),
|
||||||
|
content: `<em>${game.user.name} used "/timers"!</em><br>
|
||||||
|
|
||||||
|
Usage: /timers <command><br/>
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
|
||||||
|
<li><strong>show</strong> - shows timers</li>
|
||||||
|
<li><strong>hide</strong> - hide timers</li>
|
||||||
|
<li><strong>pause</strong> - pause the timer runners</li>
|
||||||
|
<li><strong>resume</strong> - resume timer runners</li>
|
||||||
|
<li><strong>add</strong> - add a timer</li>
|
||||||
|
`,
|
||||||
|
type: CONST.CHAT_MESSAGE_STYLES.OOC,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerChatCommand(log, msg, data) {
|
||||||
|
logger.debug('Chat message:', log, msg, data);
|
||||||
|
try {
|
||||||
|
const parts = msg.split(' ')
|
||||||
|
.map(part => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const op = parts[1];
|
||||||
|
if (!op) {
|
||||||
|
logger.error('No chat command given.');
|
||||||
|
showChatHelpMessage();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
logger.debug('Parts:', parts);
|
||||||
|
switch (op) {
|
||||||
|
case 'show':
|
||||||
|
state.screen?.render(true);
|
||||||
|
break;
|
||||||
|
case 'hide':
|
||||||
|
state.screen?.close();
|
||||||
|
break;
|
||||||
|
case 'pause':
|
||||||
|
stopTimers();
|
||||||
|
break;
|
||||||
|
case 'stop':
|
||||||
|
stopTimers();
|
||||||
|
break;
|
||||||
|
case 'resume':
|
||||||
|
startTimers();
|
||||||
|
break;
|
||||||
|
case 'add':
|
||||||
|
showAddTimer();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
showChatHelpMessage();
|
||||||
|
logger.error(`Invalid chat command "${op}".`);
|
||||||
|
}
|
||||||
|
} catch(error) {
|
||||||
|
logger.error(`Error processing errors:`, error);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flipTimers() {
|
||||||
|
if (getStatus()) {
|
||||||
|
startTimers();
|
||||||
|
} else {
|
||||||
|
stopTimers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startTimers() {
|
||||||
|
if (state.timerInterval !== null) {
|
||||||
|
throw new TimerError('Loop already running');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Starting main timer loop`);
|
||||||
|
state.timerInterval = setInterval(() => runLoop(), INTERVAL_MS);
|
||||||
|
ping();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe(cb) {
|
||||||
|
state.subscribers.push(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopTimers() {
|
||||||
|
if (state.timerInterval === null) {
|
||||||
|
throw new TimerError('Loop not running.');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Stopping main timer loop.');
|
||||||
|
clearInterval(state.timerInterval);
|
||||||
|
state.timerInterval = null;
|
||||||
|
ping();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pauseTimer(timerName = '') {
|
||||||
|
const timer = getTimer(timerName);
|
||||||
|
if (timer.isPaused) {
|
||||||
|
logger.error(`Timer ${timerName} already paused.`);
|
||||||
|
} else {
|
||||||
|
logger.info(`Pausing timer ${timerName}.`);
|
||||||
|
timer.isPaused = true;
|
||||||
|
ping();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resumeTimer(timerName = '') {
|
||||||
|
const timer = getTimer(timerName);
|
||||||
|
if (!timer.isPaused) {
|
||||||
|
logger.error(`Timer ${timerName} already running.`);
|
||||||
|
} else {
|
||||||
|
logger.info(`Resuming timer ${timerName}.`);
|
||||||
|
timer.isPaused = false;
|
||||||
|
ping();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTimer(timerName) {
|
||||||
|
repoDeleteTimer(timerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runLoop() {
|
||||||
|
logger.debug(`Running loop...`);
|
||||||
|
const timers = getActiveTimers();
|
||||||
|
logger.debug(`Got ${timers.length} timers.`);
|
||||||
|
|
||||||
|
for (const timer of timers) {
|
||||||
|
advance(timer, INTERVAL_MS);
|
||||||
|
logTimer(timer);
|
||||||
|
}
|
||||||
|
if (timers.length > 0) {
|
||||||
|
ping();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ping() {
|
||||||
|
for (const sub of state.subscribers) {
|
||||||
|
sub();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function advance(timer, interval_ms) {
|
||||||
|
logger.group('int');
|
||||||
|
logger.log(timer);
|
||||||
|
logger.log(`Updating interval, timer before: ${timer.elapsedTimeSeconds}, interval: ${interval_ms}`)
|
||||||
|
timer.elapsedTimeSeconds += (interval_ms / 1000);
|
||||||
|
logger.log(`Interval after: ${timer.elapsedTimeSeconds}`);
|
||||||
|
logger.groupEnd('int');
|
||||||
|
if (Math.round(timer.elapsedTimeSeconds) >= timer.durationSeconds) {
|
||||||
|
completeTimer(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DINGDING
|
||||||
|
function completeTimer(timer) {
|
||||||
|
logger.log(`Timer ${timer.name} completed.`);
|
||||||
|
timer.isPaused = true;
|
||||||
|
timer.isComplete = true;
|
||||||
|
// ping? close something?
|
||||||
|
const timerComplete = new TimerCompleteScreen();
|
||||||
|
timerComplete.setData({ name: timer.name });
|
||||||
|
timerComplete.render(true);
|
||||||
|
}
|
142
src/timer.repository.js
Normal file
142
src/timer.repository.js
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { logger } from "./logger.js";
|
||||||
|
import { TimerError } from "./error.js";
|
||||||
|
|
||||||
|
const MAX_DURATION = 60 * 60 * 24; // one day, let's say.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {string} TimeString
|
||||||
|
* @description A string-wrapped formatted duration:
|
||||||
|
* 1. Seconds only: ("123")
|
||||||
|
* 2. Minutes and seconds as "MM:SS" (leading zeros)
|
||||||
|
* 3. Hours, minutes, seconds as "HH:MM:SS".
|
||||||
|
* @example "125" // 125 seconds
|
||||||
|
* @example "13:22" // 13 minutes 22 seconds
|
||||||
|
* @example "02:04:06" // 2 hours, 4 minutes, 6 seconds
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Timer instance
|
||||||
|
* @typedef {Object} Timer
|
||||||
|
* @property {TimeString} duration - total duration time
|
||||||
|
* @property {number} elapsedTimeSeconds - time we've ran so far
|
||||||
|
* @property {boolean} isPaused - flag indicating if the timer is paused.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
const timerMap = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all timer names and states
|
||||||
|
* @returns {Array<Timer>}
|
||||||
|
*/
|
||||||
|
export function getActiveTimers() {
|
||||||
|
logger.debug(`Getting active timers...`);
|
||||||
|
return Array.from(timerMap)
|
||||||
|
.map(([_, timer]) => timer)
|
||||||
|
.filter(timer => !timer.isPaused);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTimers() {
|
||||||
|
return Array.from(timerMap)
|
||||||
|
.map(([_, timer]) => timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function getTimer(timerName = '') {
|
||||||
|
if (!timerMap.has(timerName)) {
|
||||||
|
throw new TimerError('No such timer');
|
||||||
|
}
|
||||||
|
return timerMap.get(timerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTimer(timerName = '') {
|
||||||
|
if (timerMap.has(timerName)) {
|
||||||
|
timerMap.delete(timerName);
|
||||||
|
} else {
|
||||||
|
throw new TimerError(`Trying to delete non-existent timer ${timerName}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new timer.
|
||||||
|
* @param {TimeString} [durationString='01:00'] - the timer duration
|
||||||
|
* @param {TimeString} [name='<next serial name>'] - name for the timer instance
|
||||||
|
* @param {boolean} [startNow=false] - start immediately
|
||||||
|
* @returns {(string | TimerError)} - returns the timerId or timer.
|
||||||
|
*/
|
||||||
|
export function createTimer(durationString = '01:00', name = '', startNow = false) {
|
||||||
|
let timerName = name;
|
||||||
|
if (timerName === '') {
|
||||||
|
timerName = getNextName();
|
||||||
|
}
|
||||||
|
|
||||||
|
assertValidName(timerName);
|
||||||
|
assertValidDuration(durationString);
|
||||||
|
|
||||||
|
timerMap.set(timerName, {
|
||||||
|
name: timerName,
|
||||||
|
duration: durationString,
|
||||||
|
elapsedTimeSeconds: 0,
|
||||||
|
isPaused: !startNow,
|
||||||
|
durationSeconds: getDurationInSeconds(durationString),
|
||||||
|
});
|
||||||
|
return timerName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidName(name) {
|
||||||
|
if (timerMap.has(name)) {
|
||||||
|
throw new TimerError(`Timer with ${name} already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextName(prefix = 0) {
|
||||||
|
const name = `timer-${prefix}-${timerMap.size}`
|
||||||
|
if (timerMap.has(name)) {
|
||||||
|
return getNextName(prefix + 1);
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondsRe = /^\d+$/;
|
||||||
|
const mmssRe = /^(\d{2}):(\d{2})$/;
|
||||||
|
const hhmmssRe = /^(\d{2}):(\d{2}):(\d{2})$/;
|
||||||
|
const minutesOrHoursRe = /(\d{2}):(\d{2})$/;
|
||||||
|
|
||||||
|
function assertValidDuration(durationString) {
|
||||||
|
if (!durationString) {
|
||||||
|
throw new TimerError('Please provide a duration string');
|
||||||
|
}
|
||||||
|
const parts = minutesOrHoursRe.exec(durationString);
|
||||||
|
if (parts) {
|
||||||
|
if (parts[1] >= 60) {
|
||||||
|
throw new TimerError('Minutes must be between 0 and 59.');
|
||||||
|
}
|
||||||
|
if (parts[2] >= 60) {
|
||||||
|
throw new TimerError('Seconds must be between 0 and 59.');
|
||||||
|
}
|
||||||
|
} else if (!secondsRe.test(durationString)) {
|
||||||
|
throw new TimerError('Must be formatted "<seconds>", "mm:ss" or "hh:mm:ss" format');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDurationInSeconds(durationString) {
|
||||||
|
let duration;
|
||||||
|
if (secondsRe.test(durationString)) {
|
||||||
|
duration = parseInt(durationString, 10);
|
||||||
|
} else if (mmssRe.test(durationString)) {
|
||||||
|
const parts = mmssRe.exec(durationString);
|
||||||
|
duration = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10);
|
||||||
|
} else if (hhmmssRe.test(durationString)) {
|
||||||
|
const parts = hhmmssRe.exec(durationString);
|
||||||
|
duration = parseInt(parts[0], 10) * 24 * 60 + parseInt(parts[1], 10) * 60 + parseInt(parts[2], 10);
|
||||||
|
} else {
|
||||||
|
throw new TimerError(`Duration "${durationString}" not calculable`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duration > MAX_DURATION) {
|
||||||
|
logger.warn(`Duration greater then max duration, capping the value to ${MAX_DURATION}.`);
|
||||||
|
return MAX_DURATION;
|
||||||
|
} else {
|
||||||
|
return duration;
|
||||||
|
}
|
||||||
|
}
|
70
styles/yat-style.css
Normal file
70
styles/yat-style.css
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
|
||||||
|
#yet-another-timer .timer-card {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 10px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.1);
|
||||||
|
width: 250px; /* Adjust as needed */
|
||||||
|
display: inline-block; /* Or use flexbox/grid for layout */
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
#yet-another-timer .timer-card.paused {
|
||||||
|
background-color: #eee;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
#yet-another-timer .timer-card .title {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.2em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#yet-another-timer .timer-card .content p {
|
||||||
|
margin: 5px 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
max-width: 33em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#yet-another-timer .timer-card .actions {
|
||||||
|
margin-top: 15px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
#yet-another-timer .timer-card .button {
|
||||||
|
padding: 5px 10px;
|
||||||
|
margin-left: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #aaa;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#yet-another-timer .timer-card .button:hover {
|
||||||
|
background-color: #d0d0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#yet-another-timer .timer-card .delete-button {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
border-color: #f5c6cb;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
#yet-another-timer .timer-card .delete-button:hover {
|
||||||
|
background-color: #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yat-add-form {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: 40px;
|
||||||
|
width: 40px;
|
||||||
|
}
|
20
templates/add-timer.hbs
Normal file
20
templates/add-timer.hbs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<h3>{{ localize "YAT.status.add-timer" }}</h3>
|
||||||
|
|
||||||
|
<form class="yat-add-form">
|
||||||
|
<label>
|
||||||
|
{{ localize "YAT.add-timer.timer name" }} *:
|
||||||
|
<input type="text" name="name" value="{{ nextName }}" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
{{ localize "YAT.add-timer.duration" }} *:
|
||||||
|
<input type="text" name="duration" pattern="\d+|\d{2}:\d{2}|\d{2}:\d{2}:\d{2}" required>
|
||||||
|
</label>
|
||||||
|
<em class="yat-hint">{{ localize "YAT.add-timer.format-hint" }}</em>
|
||||||
|
<label>
|
||||||
|
{{ localize "YAT.add-timer.start-now" }}:
|
||||||
|
<input type="checkbox" name="startNow">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="button" data-action="add-timer">{{ localize "YAT.status.add-timer" }}</button>
|
||||||
|
</form>
|
5
templates/timer-complete.hbs
Normal file
5
templates/timer-complete.hbs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{{#each names }}
|
||||||
|
<h2>{{ localize "YAT.timer.your-timer-is-complete" name=this }}!</h2>
|
||||||
|
{{/each}}
|
||||||
|
<img src="modules/yet-another-timer/assets/time-out.jpg">
|
||||||
|
|
40
templates/timer.hbs
Normal file
40
templates/timer.hbs
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<h2>{{localize "YAT.title" }}</h2> <!-- TODO: how to localize -->
|
||||||
|
|
||||||
|
<section id="yet-another-timer-config">
|
||||||
|
<p>{{ localize "YAT.status.timers-are" }} {{#if config.isPaused }}{{ localize "YAT.status.timers-paused" }}{{else}}{{ localize "YAT.status.timers-active"}}{{/if}}.</p>
|
||||||
|
<button class="button pause-button" data-action="flip-timers">
|
||||||
|
{{#if config.isPaused}}
|
||||||
|
{{ localize "YAT.resume-runner" }}
|
||||||
|
{{else}}
|
||||||
|
{{ localize "YAT.pause-runner" }}
|
||||||
|
{{/if}}
|
||||||
|
</button>
|
||||||
|
<button class="button resume-button" data-action="add-timer">
|
||||||
|
{{ localize "YAT.status.add-timer" }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<section id="yet-another-timer timers">
|
||||||
|
{{#each timers }}
|
||||||
|
<section class="timer-card {{#if isPaused}}{{ localize "YAT.paused" }}{{/if}}" data-timer-name="{{ name }}">
|
||||||
|
<h3 class="title">{{ name }}</h3>
|
||||||
|
<div class="content">
|
||||||
|
<p>{{ localize "YAT.duration" }}: {{ duration }}</p>
|
||||||
|
<p>{{ localize "YAT.time-remaining" }}: {{ formatRemainingTime durationSeconds elapsedTimeSeconds }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
{{#unless isComplete }}
|
||||||
|
{{#if isPaused }}
|
||||||
|
<button class="button resume-button" data-action="resume">{{ localize "YAT.resume" }}</button>
|
||||||
|
{{ else }}
|
||||||
|
<button class="button pause-button" data-action="pause">{{ localize "YAT.pause" }}</button>
|
||||||
|
{{/if }}
|
||||||
|
{{/unless }}
|
||||||
|
<button class="button delete-button" data-action="delete">{{ localize "YAT.delete" }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{/each }}
|
||||||
|
</section>
|
Loading…
x
Reference in New Issue
Block a user