works nicely

This commit is contained in:
Zlatko Đurić 2025-04-13 20:17:28 +02:00
parent 249cfdf95f
commit 061637c481
13 changed files with 550 additions and 10 deletions

17
README.md Normal file
View 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

View File

@ -1 +1,10 @@
{}
{
"YAT": {
"title": "Noch Ein Timer",
"duration": "Dauer",
"time-remaining": "Verbliebend",
"resume": "Fortsetzen",
"pause": "Anhalten",
"delete": "Löschen"
}
}

View File

@ -1 +1,10 @@
{}
{
"YAT": {
"title": "Yet Another Timer",
"duration": "Duration",
"time-remaining": "Time remaining",
"resume": "Resume",
"pause": "Pause",
"delete": "Delete"
}
}

View File

@ -1 +1,10 @@
{}
{
"YAT": {
"title": "Još Jedan Timer",
"duration": "Trajanje",
"time-remaining": "Preostalo vrijeme",
"resume": "Nastavi",
"pause": "Zaustavi",
"delete": "Obriši"
}
}

View File

@ -17,6 +17,9 @@
"esmodules": [
"src/timer.js"
],
"styles": [
"styles/style.css"
],
"languages": [
{
"lang": "en",

5
src/error.js Normal file
View File

@ -0,0 +1,5 @@
export class TimerError extends Error {
constructor(message) {
super(`[Timer Module] ${message}`);
}
}

59
src/logger.js Normal file
View 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;
})();

77
src/render.js Normal file
View File

@ -0,0 +1,77 @@
import { logger } from "./logger.js";
import { getTimers } from "./timer.repository.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",
id: "yet-another-timer",
template: templatePath,
title: game.i18n?.localize("YAT.title") || "Yet another timer",
};
return Object.assign({}, defaults, overrides);
}
getData() {
return {
timers: getTimers(),
};
}
activateListeners(html) {
logger.debug('Activating listeners...');
html.on('click', "[data-action]", this.#handleClick);
logger.debug('Activated.');
}
#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);
}
}
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);

View File

@ -1,21 +1,82 @@
console.log('We just booted!');
import { logger } from './logger.js';
import { startTimers, stopTimers, resumeTimer } from './timer.loop.js';
import { createTimer } from './timer.repository.js';
import { TimerScreen } from "./render.js";
let isPaused = true;
console.debug('Timer module booted!');
Hooks.on('init', function() {
console.log('Timer module init');
logger.log('Timer module init');
});
Hooks.on('ready', function() {
console.log('Timer module ready');
Hooks.on('ready', function(details) {
logger.log('Timer module ready:', details);
// startTimers();
isPaused = false;
addSomeTimers();
const screen = new TimerScreen();
logger.log(screen);
const result = screen.render(true);
logger.log(result);
});
Hooks.on('error', function(location, error, payload) {
console.error(`Timer module error at ${location}`, error, payload ?? 'no payload');
logger.error(`Timer module error at ${location}`, error, payload ?? 'no payload');
});
Hooks.on('pauseGame', function(paused) {
if (paused) {
console.log('Game paused.');
logger.log('Game paused.');
if (!isPaused) {
stopTimers();
isPaused = true;
}
} else {
console.log('Game unpaused');
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);
*/
}

79
src/timer.loop.js Normal file
View File

@ -0,0 +1,79 @@
import { logger } from "./logger.js";
import { TimerError } from "./error.js";
import { getActiveTimers, getTimer } from "./timer.repository.js";
import { logTimer } from "./render.js";
const INTERVAL_MS = 1000; // main loop interval
const state = {
timerInterval: null,
};
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);
}
export function stopTimers() {
if (state.timerInterval === null) {
throw new TimerError('Loop not running.');
}
clearInterval(state.timerInterval);
state.timerInterval = null;
logger.debug('Stopping main timer loop.');
}
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;
}
}
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;
}
}
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);
}
}
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;
}

134
src/timer.repository.js Normal file
View File

@ -0,0 +1,134 @@
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);
}
/**
* 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`);
}
}
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;
}
}

57
styles/style.css Normal file
View File

@ -0,0 +1,57 @@
.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;
}
.timer-card.paused {
background-color: #eee;
opacity: 0.7;
}
.timer-card .title {
font-weight: bold;
font-size: 1.2em;
margin-bottom: 10px;
border-bottom: 1px solid #eee;
padding-bottom: 5px;
}
.timer-card .content p {
margin: 5px 0;
font-size: 0.9em;
}
.timer-card .actions {
margin-top: 15px;
text-align: right;
}
.timer-card .button {
padding: 5px 10px;
margin-left: 5px;
cursor: pointer;
border: 1px solid #aaa;
border-radius: 4px;
background-color: #e0e0e0;
}
.timer-card .button:hover {
background-color: #d0d0d0;
}
.timer-card .delete-button {
background-color: #f8d7da;
border-color: #f5c6cb;
color: #721c24;
}
.timer-card .delete-button:hover {
background-color: #f5c6cb;
}

21
templates/timer.hbs Normal file
View File

@ -0,0 +1,21 @@
<h2>{{localize "YAT.title" }}</h2> <!-- TODO: how to localize -->
<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">
{{#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.resume" }}</button>
{{/if}}
<button class="button delete-button" data-action="delete">{{ localize "YAT.delete" }}</button>
</div>
</section>
{{/each}}
</section>