From 061637c48127ecf1faccc67f0ac3711241efffd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zlatko=20=C4=90uri=C4=87?= Date: Sun, 13 Apr 2025 20:17:28 +0200 Subject: [PATCH] works nicely --- README.md | 17 +++++ lang/de.json | 11 +++- lang/en.json | 11 +++- lang/hr.json | 11 +++- module.json | 3 + src/error.js | 5 ++ src/logger.js | 59 ++++++++++++++++++ src/render.js | 77 +++++++++++++++++++++++ src/timer.js | 75 +++++++++++++++++++--- src/timer.loop.js | 79 +++++++++++++++++++++++ src/timer.repository.js | 134 ++++++++++++++++++++++++++++++++++++++++ styles/style.css | 57 +++++++++++++++++ templates/timer.hbs | 21 +++++++ 13 files changed, 550 insertions(+), 10 deletions(-) create mode 100644 README.md create mode 100644 src/error.js create mode 100644 src/logger.js create mode 100644 src/render.js create mode 100644 src/timer.loop.js create mode 100644 src/timer.repository.js create mode 100644 styles/style.css create mode 100644 templates/timer.hbs diff --git a/README.md b/README.md new file mode 100644 index 0000000..07d891c --- /dev/null +++ b/README.md @@ -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 diff --git a/lang/de.json b/lang/de.json index 0967ef4..522707f 100644 --- a/lang/de.json +++ b/lang/de.json @@ -1 +1,10 @@ -{} +{ + "YAT": { + "title": "Noch Ein Timer", + "duration": "Dauer", + "time-remaining": "Verbliebend", + "resume": "Fortsetzen", + "pause": "Anhalten", + "delete": "Löschen" + } +} diff --git a/lang/en.json b/lang/en.json index 0967ef4..efe28bd 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1 +1,10 @@ -{} +{ + "YAT": { + "title": "Yet Another Timer", + "duration": "Duration", + "time-remaining": "Time remaining", + "resume": "Resume", + "pause": "Pause", + "delete": "Delete" + } +} diff --git a/lang/hr.json b/lang/hr.json index 0967ef4..f4e50e7 100644 --- a/lang/hr.json +++ b/lang/hr.json @@ -1 +1,10 @@ -{} +{ + "YAT": { + "title": "Još Jedan Timer", + "duration": "Trajanje", + "time-remaining": "Preostalo vrijeme", + "resume": "Nastavi", + "pause": "Zaustavi", + "delete": "Obriši" + } +} diff --git a/module.json b/module.json index a70945f..e05c2ef 100644 --- a/module.json +++ b/module.json @@ -17,6 +17,9 @@ "esmodules": [ "src/timer.js" ], + "styles": [ + "styles/style.css" + ], "languages": [ { "lang": "en", diff --git a/src/error.js b/src/error.js new file mode 100644 index 0000000..89f119a --- /dev/null +++ b/src/error.js @@ -0,0 +1,5 @@ +export class TimerError extends Error { + constructor(message) { + super(`[Timer Module] ${message}`); + } +} diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..d2bff35 --- /dev/null +++ b/src/logger.js @@ -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; +})(); diff --git a/src/render.js b/src/render.js new file mode 100644 index 0000000..0a761be --- /dev/null +++ b/src/render.js @@ -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); diff --git a/src/timer.js b/src/timer.js index b4901d8..f5d6b73 100644 --- a/src/timer.js +++ b/src/timer.js @@ -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); + */ +} + diff --git a/src/timer.loop.js b/src/timer.loop.js new file mode 100644 index 0000000..35fc695 --- /dev/null +++ b/src/timer.loop.js @@ -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; +} diff --git a/src/timer.repository.js b/src/timer.repository.js new file mode 100644 index 0000000..33458b2 --- /dev/null +++ b/src/timer.repository.js @@ -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} + */ +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=''] - 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 "", "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; + } +} diff --git a/styles/style.css b/styles/style.css new file mode 100644 index 0000000..2ae4860 --- /dev/null +++ b/styles/style.css @@ -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; +} diff --git a/templates/timer.hbs b/templates/timer.hbs new file mode 100644 index 0000000..32cff09 --- /dev/null +++ b/templates/timer.hbs @@ -0,0 +1,21 @@ +

{{localize "YAT.title" }}

+ +
+ {{#each timers }} +
+

{{name}}

+
+

{{ localize "YAT.duration" }}: {{duration}}

+

{{ localize "YAT.time-remaining" }}: {{formatRemainingTime durationSeconds elapsedTimeSeconds}}

+
+
+ {{#if isPaused}} + + {{else}} + + {{/if}} + +
+
+ {{/each}} +