From 08e2745b3aebaff45a5c6b9f6599bf9377987ee4 Mon Sep 17 00:00:00 2001 From: CPTN Cosmo Date: Sun, 21 Dec 2025 20:25:29 +0100 Subject: [PATCH] feat: add new improved, draggable countdown tracker module for Daggerheart. --- README.md | 27 +++++ module.json | 31 ++++++ scripts/countdown-app.js | 183 ++++++++++++++++++++++++++++++++ scripts/module.js | 52 +++++++++ styles/countdown.css | 183 ++++++++++++++++++++++++++++++++ templates/countdown-tracker.hbs | 51 +++++++++ 6 files changed, 527 insertions(+) create mode 100644 README.md create mode 100644 module.json create mode 100644 scripts/countdown-app.js create mode 100644 scripts/module.js create mode 100644 styles/countdown.css create mode 100644 templates/countdown-tracker.hbs diff --git a/README.md b/README.md new file mode 100644 index 0000000..d0b9d3b --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Improved Countdowns + +A modern, draggable countdown tracker for the Daggerheart system in Foundry VTT (v13+). + +## Features + +- **Modern UI**: A sleek, draggable window replacing the default system tracker. +- **Minimized View**: Toggle between a full view with names and icons, or a compact minimized view to save space. +- **Interactive Controls**: + - **Increase/Decrease**: Hover over countdowns to see `+` and `-` buttons for quick updates. + - **Add New**: GMs can add new countdowns directly from the tracker. + - **Lock/Unlock**: Lock the tracker's position to prevent accidental drags. +- **System Integration**: Fully compatible with the Daggerheart system's built-in countdown logic. + +## Usage + +1. **Draggable Handle**: Use the handle at the top of the tracker to reposition it on your canvas. +2. **Locking**: Click the lock icon to freeze the tracker in place. +3. **Minimizing**: Click the collapse icon to switch to the compact view. +4. **Modifying Countdowns**: Hover over any countdown icon to reveal the increment/decrement controls. + +## Installation + +1. Copy the Manifest URL: `https://github.com/your-username/dh-improved-countdowns/releases/latest/download/module.json` (Update with actual URL). +2. In Foundry VTT, go to the **Add-on Modules** tab. +3. Click **Install Module** and paste the URL. + diff --git a/module.json b/module.json new file mode 100644 index 0000000..12a27d8 --- /dev/null +++ b/module.json @@ -0,0 +1,31 @@ +{ + "name": "dh-improved-countdowns", + "title": "Improved Countdowns", + "description": "A modern, draggable countdown tracker for the Daggerheart system.", + "version": "1.0.0", + "library": false, + "url": "https://github.com/cptn-cosmo/dh-improved-countdowns", + "manifest": "https://github.com/cptn-cosmo/dh-improved-countdowns/releases/latest/download/module.json", + "download": "https://github.com/cptn-cosmo/dh-improved-countdowns/releases/download/1.0.0/dh-improved-countdowns.zip", + "authors": [ + { + "name": "CPTN Cosmo", + "email": "cptncosmo@gmail.com", + "url": "https://github.com/cptn-cosmo", + "discord": "cptn_cosmo" + } + ], + "systems": [ + "daggerheart" + ], + "esmodules": [ + "scripts/module.js" + ], + "styles": [ + "styles/countdown.css" + ], + "compatibility": { + "minimum": "13", + "verified": "13" + } +} \ No newline at end of file diff --git a/scripts/countdown-app.js b/scripts/countdown-app.js new file mode 100644 index 0000000..aadfc1f --- /dev/null +++ b/scripts/countdown-app.js @@ -0,0 +1,183 @@ +const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; + +export class CountdownTrackerApp extends HandlebarsApplicationMixin(ApplicationV2) { + static instance; + + constructor(options = {}) { + super(options); + this._dragData = { + isDragging: false, + startX: 0, + startY: 0, + startLeft: 0, + startTop: 0 + }; + } + + static DEFAULT_OPTIONS = { + id: "dh-improved-countdowns-app", + tag: "aside", + classes: ["dh-improved-countdowns"], + window: { + frame: false, + positioned: true, + }, + position: { + width: "auto", + height: "auto", + }, + actions: { + increaseCountdown: CountdownTrackerApp.#onIncrease, + decreaseCountdown: CountdownTrackerApp.#onDecrease, + addCountdown: CountdownTrackerApp.#onAdd, + toggleViewMode: CountdownTrackerApp.#onToggleView, + toggleLock: CountdownTrackerApp.#onToggleLock + } + }; + + static PARTS = { + content: { + template: "modules/dh-improved-countdowns/templates/countdown-tracker.hbs", + }, + }; + + static initialize() { + this.instance = new CountdownTrackerApp(); + const pos = game.settings.get("dh-improved-countdowns", "position"); + this.instance.render(true, { position: pos }); + } + + async _prepareContext(options) { + const isGM = game.user.isGM; + const isMinimized = game.settings.get("dh-improved-countdowns", "minimized"); + const isLocked = game.settings.get("dh-improved-countdowns", "locked"); + + // Fetch countdowns from system settings + const systemCountdownSetting = game.settings.get("daggerheart", "countdowns"); + const countdowns = {}; + + if (systemCountdownSetting && systemCountdownSetting.countdowns) { + for (const [id, countdown] of Object.entries(systemCountdownSetting.countdowns)) { + const ownership = this.#getPlayerOwnership(game.user, systemCountdownSetting, countdown); + if (ownership !== CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE) { + countdowns[id] = { + ...countdown, + editable: isGM || ownership === CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER + }; + } + } + } + + return { + countdowns, + isGM, + isMinimized, + isLocked + }; + } + + #getPlayerOwnership(user, setting, countdown) { + const playerOwnership = countdown.ownership[user.id]; + return playerOwnership === undefined || playerOwnership === CONST.DOCUMENT_OWNERSHIP_LEVELS.INHERIT + ? setting.defaultOwnership + : playerOwnership; + } + + static async #onIncrease(event, target) { + const id = target.dataset.id; + Hooks.call("editCountdown", true, { id }); + // The Daggerheart system has a static method for this, let's try to use it if available + if (typeof game.system.api.applications.ui.DhCountdowns?.editCountdown === "function") { + await game.system.api.applications.ui.DhCountdowns.editCountdown(true, { id }); + } + } + + static async #onDecrease(event, target) { + const id = target.dataset.id; + if (typeof game.system.api.applications.ui.DhCountdowns?.editCountdown === "function") { + await game.system.api.applications.ui.DhCountdowns.editCountdown(false, { id }); + } + } + + static async #onAdd(event, target) { + if (!game.user.isGM) return; + if (game.system.api.applications.ui.CountdownEdit) { + new game.system.api.applications.ui.CountdownEdit().render(true); + } + } + + static async #onToggleView(event, target) { + const current = game.settings.get("dh-improved-countdowns", "minimized"); + await game.settings.set("dh-improved-countdowns", "minimized", !current); + this.instance.render(); + } + + static async #onToggleLock(event, target) { + const current = game.settings.get("dh-improved-countdowns", "locked"); + await game.settings.set("dh-improved-countdowns", "locked", !current); + this.instance.render(); + } + + _onRender(context, options) { + this.#setupDragging(); + } + + #setupDragging() { + if (game.settings.get("dh-improved-countdowns", "locked")) return; + + const dragHandle = this.element.querySelector('.drag-handle'); + if (!dragHandle) return; + + dragHandle.addEventListener('mousedown', this.#onDragStart.bind(this)); + } + + #onDragStart(e) { + if (e.button !== 0) return; + + this._dragData.isDragging = true; + this._dragData.startX = e.clientX; + this._dragData.startY = e.clientY; + + const rect = this.element.getBoundingClientRect(); + this._dragData.startLeft = rect.left; + this._dragData.startTop = rect.top; + + this.element.style.cursor = 'grabbing'; + + window.addEventListener('mousemove', this.#onDragging.bind(this)); + window.addEventListener('mouseup', this.#onDragEnd.bind(this)); + } + + #onDragging(e) { + if (!this._dragData.isDragging) return; + + const dx = e.clientX - this._dragData.startX; + const dy = e.clientY - this._dragData.startY; + + const newLeft = this._dragData.startLeft + dx; + const newTop = this._dragData.startTop + dy; + + this.element.style.left = `${newLeft}px`; + this.element.style.top = `${newTop}px`; + } + + #onDragEnd() { + if (!this._dragData.isDragging) return; + this._dragData.isDragging = false; + this.element.style.cursor = ''; + + window.removeEventListener('mousemove', this.#onDragging.bind(this)); + window.removeEventListener('mouseup', this.#onDragEnd.bind(this)); + + const rect = this.element.getBoundingClientRect(); + const pos = { + top: rect.top, + left: rect.left + }; + + this.position.top = pos.top; + this.position.left = pos.left; + + game.settings.set("dh-improved-countdowns", "position", pos); + } +} diff --git a/scripts/module.js b/scripts/module.js new file mode 100644 index 0000000..6fda29f --- /dev/null +++ b/scripts/module.js @@ -0,0 +1,52 @@ +import { CountdownTrackerApp } from './countdown-app.js'; + +Hooks.once('init', () => { + // Register settings for position, locked state, and minimized state + game.settings.register("dh-improved-countdowns", "position", { + name: "Tracker Position", + scope: "client", + config: false, + type: Object, + default: { top: 100, left: 100 } + }); + + game.settings.register("dh-improved-countdowns", "locked", { + name: "Lock Tracker Position", + hint: "Prevents the countdown tracker from being dragged.", + scope: "client", + config: true, + type: Boolean, + default: false, + onChange: () => CountdownTrackerApp.instance?.render() + }); + + game.settings.register("dh-improved-countdowns", "minimized", { + name: "Minimized View", + scope: "client", + config: false, + type: Boolean, + default: false, + onChange: () => CountdownTrackerApp.instance?.render() + }); +}); + +Hooks.once('ready', () => { + // Hide default countdown tracker via CSS (handled in countdown.css) + + // Initialize our improved tracker + CountdownTrackerApp.initialize(); +}); + +// Re-render when countdowns change in the system +Hooks.on('daggerheart.refresh', (data) => { + if (data.refreshType === "countdown" || data.refreshType === 4) { // 4 is RefreshType.Countdown in Daggerheart + CountdownTrackerApp.instance?.render(); + } +}); + +// Generic socket refresh if system refresh doesn't catch everything +Hooks.on('refresh', (data) => { + if (data.refreshType === "countdown") { + CountdownTrackerApp.instance?.render(); + } +}); diff --git a/styles/countdown.css b/styles/countdown.css new file mode 100644 index 0000000..6dcbe09 --- /dev/null +++ b/styles/countdown.css @@ -0,0 +1,183 @@ +/* Hide default Daggerheart countdown tracker */ +.daggerheart.countdowns { + display: none !important; +} + +/* Modern Countdown Tracker Application */ +.dh-improved-countdowns { + pointer-events: none; + /* Let clicks pass through to child elements */ + z-index: 100; +} + +.countdown-tracker-window { + pointer-events: all; + background: rgba(20, 20, 25, 0.85); + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 8px; + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + transition: all 0.3s ease; + min-width: 120px; + color: #eee; + font-family: 'Inter', 'Roboto', sans-serif; +} + +.countdown-tracker-window.minimized { + min-width: 60px; +} + +/* Header and Drag Handle */ +.tracker-header { + display: flex; + justify-content: space-between; + align-items: center; + opacity: 0; + height: 0; + overflow: hidden; + transition: all 0.2s ease; +} + +.countdown-tracker-window:hover .tracker-header { + opacity: 1; + height: 24px; + margin-bottom: 4px; +} + +.drag-handle { + cursor: grab; + color: rgba(255, 255, 255, 0.5); + padding: 2px 8px; +} + +.countdown-tracker-window.locked .drag-handle { + display: none; +} + +.header-controls { + display: flex; + gap: 8px; + padding-right: 4px; +} + +.control-btn { + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + font-size: 14px; +} + +.control-btn:hover { + color: #fff; + text-shadow: 0 0 8px rgba(255, 255, 255, 0.5); +} + +/* Countdowns List */ +.countdowns-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.countdown-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + position: relative; +} + +.countdown-name { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(255, 255, 255, 0.8); + text-align: center; + max-width: 150px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.countdown-visual { + display: flex; + align-items: center; + gap: 10px; +} + +.icon-container { + position: relative; + width: 48px; + height: 48px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.3); + border: 2px solid rgba(255, 255, 255, 0.1); + overflow: hidden; +} + +.countdown-icon { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.value-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 22px; + font-weight: 800; + color: #fff; + text-shadow: 0 0 4px rgba(0, 0, 0, 0.9), 0 0 8px rgba(0, 0, 0, 0.9); + pointer-events: none; +} + +/* +/- Controls */ +.value-control { + opacity: 0; + transition: opacity 0.2s ease; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + cursor: pointer; + color: #ccc; + font-size: 12px; +} + +.countdown-item:hover .value-control { + opacity: 1; +} + +.value-control:hover { + background: rgba(255, 255, 255, 0.2); + color: #fff; +} + +.value-control.plus:hover { + background: rgba(74, 222, 128, 0.2); + color: #4ade80; +} + +.value-control.minus:hover { + background: rgba(248, 113, 113, 0.2); + color: #f87171; +} + +/* No Countdowns State */ +.no-countdowns { + font-size: 11px; + font-style: italic; + color: rgba(255, 255, 255, 0.4); + padding: 8px; + text-align: center; +} \ No newline at end of file diff --git a/templates/countdown-tracker.hbs b/templates/countdown-tracker.hbs new file mode 100644 index 0000000..786d433 --- /dev/null +++ b/templates/countdown-tracker.hbs @@ -0,0 +1,51 @@ +
+
+
+ +
+
+ {{#if isGM}} + + + + {{/if}} + + + + + + +
+
+ +
+ {{#each countdowns as | countdown id |}} +
+ {{#unless ../isMinimized}} +
{{countdown.name}}
+ {{/unless}} + +
+ {{#if countdown.editable}} + + + + {{/if}} + +
+ +
{{countdown.progress.current}}
+
+ + {{#if countdown.editable}} + + + + {{/if}} +
+
+ {{else}} +
No Active Countdowns
+ {{/each}} +
+