feat: add new improved, draggable countdown tracker module for Daggerheart.

This commit is contained in:
CPTN Cosmo 2025-12-21 20:25:29 +01:00
commit 08e2745b3a
6 changed files with 527 additions and 0 deletions

27
README.md Normal file
View file

@ -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.

31
module.json Normal file
View file

@ -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"
}
}

183
scripts/countdown-app.js Normal file
View file

@ -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);
}
}

52
scripts/module.js Normal file
View file

@ -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();
}
});

183
styles/countdown.css Normal file
View file

@ -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;
}

View file

@ -0,0 +1,51 @@
<div class="countdown-tracker-window {{#if isMinimized}}minimized{{/if}} {{#if isLocked}}locked{{/if}}">
<div class="tracker-header">
<div class="drag-handle" data-tooltip="Drag to move">
<i class="fa-solid fa-grip-vertical"></i>
</div>
<div class="header-controls">
{{#if isGM}}
<a class="control-btn" data-action="addCountdown" data-tooltip="Add New Countdown">
<i class="fa-solid fa-plus-circle"></i>
</a>
{{/if}}
<a class="control-btn" data-action="toggleLock" data-tooltip="{{#if isLocked}}Unlock Window{{else}}Lock Window{{/if}}">
<i class="fa-solid {{#if isLocked}}fa-lock{{else}}fa-lock-open{{/if}}"></i>
</a>
<a class="control-btn" data-action="toggleViewMode" data-tooltip="{{#if isMinimized}}Maximize{{else}}Minimize{{/if}}">
<i class="fa-solid {{#if isMinimized}}fa-expand-alt{{else}}fa-compress-alt{{/if}}"></i>
</a>
</div>
</div>
<div class="countdowns-list">
{{#each countdowns as | countdown id |}}
<div class="countdown-item" data-id="{{id}}">
{{#unless ../isMinimized}}
<div class="countdown-name">{{countdown.name}}</div>
{{/unless}}
<div class="countdown-visual">
{{#if countdown.editable}}
<a class="value-control minus" data-action="decreaseCountdown" data-id="{{id}}">
<i class="fa-solid fa-minus"></i>
</a>
{{/if}}
<div class="icon-container" {{#if ../isMinimized}}data-tooltip="{{countdown.name}}"{{/if}}>
<img src="{{countdown.img}}" class="countdown-icon" />
<div class="value-overlay">{{countdown.progress.current}}</div>
</div>
{{#if countdown.editable}}
<a class="value-control plus" data-action="increaseCountdown" data-id="{{id}}">
<i class="fa-solid fa-plus"></i>
</a>
{{/if}}
</div>
</div>
{{else}}
<div class="no-countdowns">No Active Countdowns</div>
{{/each}}
</div>
</div>