4 Commits
Author SHA1 Message Date
tevansandClaude Fable 5 7dcbd96b13 Fix pause overlay showing GAME PAUSED while unpaused — v2.0.5
Root cause of the toggle cycle bug: the theme set display:flex on
#pause.dg2-pause unconditionally, out-specificity-ing core's hidden
(unpaused) state. After unpausing, the pause element stayed visible and
showed Foundry's default GAME PAUSED markup (the overlay injector
correctly skips unpaused renders). Scope the flex layout to
#pause.dg2-pause.paused and explicitly hide :not(.paused).

Verified three full pause/unpause cycles: paused = radar scope visible,
unpaused = element hidden. (Earlier tests misread visibility via
offsetParent, which is always null for position:fixed elements.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:39:52 +01:00
tevansandClaude Fable 5 7bbe36c29e Deterministic pause overlay via GamePause subclass — v2.0.4
The v2.0.3 pauseGame-hook re-injection raced Foundry's own pause re-render:
on slower clients the default GAME PAUSED markup landed after our overlay,
so pressing Space after an unpause showed the stock screen. Register a
DG2GamePause subclass (CONFIG.ui.pause) that applies the radar-scope
overlay inside _onRender - part of every render, no timing window. Hook
fallbacks retained (with retries) for cores lacking the AppV2 GamePause.
Verified with five rapid unpause/pause cycles at 150ms settle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:27:50 +01:00
tevansandClaude Fable 5 7d684ffdb2 Robust pause overlay + PNG NPC tokens — v2.0.3
The radar-scope pause overlay only applied via the renderGamePause hook,
which does not fire on every pause toggle path — pressing Space after an
unpause could show Foundry's default GAME PAUSED. The overlay injector is
now idempotent and wired to renderGamePause AND pauseGame (plus a ready
check), so the scope applies on every toggle; verified across repeated
unpause/pause cycles.

Also rasterize the three FALSE ALARM NPC tokens (Rostova, Changed Vance,
the Tear) to 1024px PNGs, matching the map fix in v2.0.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:16:11 +01:00
tevansandClaude Fable 5 ff13de2ca5 Radar-scope pause screen + Providence/Calamity critical events — v2.0.2
Pause: replace the stock spinner with an animated DG2 radar scope (centre-
swept beam, phosphor trail, range rings, glinting blips) captioned MISSION
PAUSED / ALL STATIONS HOLD.

Home rule (criticalEvents automation setting, default on): when an Agent
rolls a natural 01 on a percentile test, roll 1d6 on the PROVIDENCE table
(1 = recover 1D4 SAN ... 6 = the whole cell gains +20%/1 WP); a natural 00
rolls on the CALAMITY table (1 = equipment failure ... 6 = everyone in the
scene loses SAN as something notices the cell). Events post as dramatic
gold/red dossier chat cards with the d6 attached (themed 3D dice); the
Handler applies effects. Verified both paths end-to-end in isolated Foundry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:02:20 +01:00
14 changed files with 360 additions and 25 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+3 -3
View File
@@ -95,7 +95,7 @@ function npcActor({ key, name, type, img, token, stats, health, skills, notes, s
const aRostova = npcActor({
key: "rostova", name: "Dr. Elena Rostova", type: "npc",
img: `${A}/token-rostova.svg`, token: `${A}/token-rostova.svg`,
img: `${A}/token-rostova.png`, token: `${A}/token-rostova.png`,
stats: { str: 10, con: 10, dex: 11, int: 15, pow: 12, cha: 11 }, health: 10, san: 0,
skills: { computer_science: 70, science: 0, sigint: 40, first_aid: 30, alertness: 40 },
shortDescription: "FAA facility technician — deceased, flash-frozen.",
@@ -104,7 +104,7 @@ const aRostova = npcActor({
const aVance = npcActor({
key: "vance", name: "Deputy Marcus Vance (Changed)", type: "unnatural",
img: `${A}/token-vance.svg`, token: `${A}/token-vance.svg`,
img: `${A}/token-vance.png`, token: `${A}/token-vance.png`,
stats: { str: 17, con: 16, dex: 16, int: 6, pow: 15, cha: 3 }, health: 16,
san: { failed: "1D6", success: "1" },
skills: { alertness: 60, athletics: 70, dodge: 50, melee_weapons: 65, unarmed_combat: 55 },
@@ -115,7 +115,7 @@ const aVance = npcActor({
const aTear = npcActor({
key: "tear", name: "The Tear (Static Between Stations)", type: "unnatural",
img: `${A}/token-entity.svg`, token: `${A}/token-entity.svg`,
img: `${A}/token-entity.png`, token: `${A}/token-entity.png`,
stats: { str: 20, con: 30, dex: 20, int: 20, pow: 25, cha: 1 }, health: 30,
san: { failed: "1D20", success: "1D6" },
skills: { alertness: 90, dodge: 80 },
+146
View File
@@ -531,3 +531,149 @@
border: 1px solid var(--dg2-hairline);
border-radius: 4px;
}
/* ------------------------------------------------------------------ */
/* Delta Green 2 - pause overlay (radar scope) */
/* ------------------------------------------------------------------ */
#pause.dg2-pause {
background: none;
}
/* Layout ONLY while actually paused - never override Foundry's hidden
(unpaused) state, or the stock GAME PAUSED markup shows after unpausing. */
#pause.dg2-pause.paused {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
#pause.dg2-pause:not(.paused) {
display: none;
}
#pause.dg2-pause img { display: none; }
.dg2-pause-scope {
position: relative;
width: 190px;
height: 190px;
border-radius: 50%;
overflow: hidden;
border: 1px solid rgba(138, 163, 83, 0.5);
background:
radial-gradient(circle at 50% 50%, rgba(138, 163, 83, 0.10), rgba(10, 13, 16, 0.85) 70%);
box-shadow: 0 0 40px rgba(0, 0, 0, 0.75), inset 0 0 30px rgba(0, 0, 0, 0.6);
}
.dg2-pause-ring {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
border: 1px solid rgba(138, 163, 83, 0.30); border-radius: 50%;
}
.dg2-pause-ring.r1 { width: 32%; height: 32%; }
.dg2-pause-ring.r2 { width: 64%; height: 64%; }
.dg2-pause-ring.r3 { width: 94%; height: 94%; }
.dg2-pause-cross::before, .dg2-pause-cross::after {
content: ""; position: absolute; background: rgba(138, 163, 83, 0.22);
}
.dg2-pause-cross::before { left: 50%; top: 4%; bottom: 4%; width: 1px; }
.dg2-pause-cross::after { top: 50%; left: 4%; right: 4%; height: 1px; }
.dg2-pause-wedge {
position: absolute; inset: 0; border-radius: 50%;
animation: dg2-pause-spin 4s linear infinite;
background: conic-gradient(from 0deg at 50% 50%,
rgba(214, 240, 170, 0.85) 0deg,
rgba(138, 163, 83, 0) 6deg,
rgba(138, 163, 83, 0) 240deg,
rgba(138, 163, 83, 0.16) 310deg,
rgba(138, 163, 83, 0.45) 348deg,
rgba(214, 240, 170, 0.85) 360deg);
}
.dg2-pause-beam {
position: absolute; left: calc(50% - 1.5px); top: 3%; width: 3px; height: 47%;
transform-origin: bottom center;
animation: dg2-pause-spin 4s linear infinite;
background: linear-gradient(to top, rgba(230, 248, 195, 1), rgba(183, 216, 115, 0));
box-shadow: 0 0 12px rgba(183, 216, 115, 0.8);
}
.dg2-pause-hub {
position: absolute; left: 50%; top: 50%; width: 10px; height: 10px;
transform: translate(-50%, -50%); border-radius: 50%;
background: #c9e59a; box-shadow: 0 0 10px rgba(183, 216, 115, 0.9);
}
.dg2-pause-blip {
position: absolute; width: 6px; height: 6px; border-radius: 50%;
background: #c9e59a; box-shadow: 0 0 8px rgba(183, 216, 115, 0.9);
opacity: 0; animation: dg2-pause-blip 4s linear infinite;
}
.dg2-pause-blip.b1 { left: 64%; top: 36%; animation-delay: -0.6s; }
.dg2-pause-blip.b2 { left: 40%; top: 62%; animation-delay: -2.4s; }
@keyframes dg2-pause-spin { to { transform: rotate(360deg); } }
@keyframes dg2-pause-blip { 0% { opacity: 0.95; } 14% { opacity: 0.45; } 32% { opacity: 0; } 100% { opacity: 0; } }
.dg2-pause-caption {
display: flex; flex-direction: column; align-items: center; gap: 4px;
font-family: "PublicSans", sans-serif;
}
.dg2-pause-title {
font-size: 1.5rem; font-weight: 800; letter-spacing: 8px; text-transform: uppercase;
color: #e6ecef; text-shadow: 0 0 18px rgba(138, 163, 83, 0.55);
animation: dg2-pause-flicker 3.2s infinite;
}
.dg2-pause-sub {
font-size: 0.72rem; letter-spacing: 4px; color: #8aa353; text-transform: uppercase;
}
@keyframes dg2-pause-flicker {
0%, 100% { opacity: 1; }
92% { opacity: 1; }
93% { opacity: 0.35; }
94% { opacity: 1; }
96% { opacity: 0.6; }
97% { opacity: 1; }
}
/* ------------------------------------------------------------------ */
/* Delta Green 2 - critical event chat cards */
/* ------------------------------------------------------------------ */
.chat-message.dg2-msg-providence { border-left-color: #d6b45a; }
.chat-message.dg2-msg-providence .dg2-card-strip .dg2-card-type { color: #e8cd7a; }
.chat-message.dg2-msg-providence .dg2-chip {
color: #e8cd7a; background: rgba(214, 180, 90, 0.12); border-color: rgba(214, 180, 90, 0.5);
}
.chat-message.dg2-msg-calamity { border-left-color: #8b1a12; }
.chat-message.dg2-msg-calamity { animation: dg2-calamity-in 0.6s ease-out; }
.chat-message.dg2-msg-calamity .dg2-card-strip .dg2-card-type { color: #ff6a4d; }
.chat-message.dg2-msg-calamity .dg2-chip {
color: #ff8d75; background: rgba(139, 26, 18, 0.2); border-color: rgba(184, 50, 38, 0.6);
}
@keyframes dg2-calamity-in {
0% { transform: translateX(0); }
20% { transform: translateX(-4px); }
40% { transform: translateX(4px); }
60% { transform: translateX(-2px); }
80% { transform: translateX(2px); }
100% { transform: translateX(0); }
}
.dg2-crit-event .dg2-crit-lede {
font-size: 0.85rem; color: #aeb9c0; margin: 2px 0 6px;
}
.dg2-crit-event .dg2-crit-text {
font-size: 0.9rem; line-height: 1.45; color: #e6ecef;
border-top: 1px solid #272b32; padding-top: 6px;
}
+4 -1
View File
@@ -402,5 +402,8 @@
"DG2.Chat.LethalForce": "Lethal Force",
"DG2.Chat.TheUnnatural": "The Unnatural",
"DG2.Chat.EyesOnly": "Delta Green // Eyes Only",
"DG2.Chat.Target": "Target"
"DG2.Chat.Target": "Target",
"DG2.PausedSub": "All stations hold // awaiting Handler",
"DG.Settings.criticalEvents.name": "Critical Events (Home Rule)",
"DG.Settings.criticalEvents.hint": "A natural 01 on a percentile test triggers a d6 Providence boon; a natural 00 triggers a d6 Calamity. Escalating tables, announced in chat; the Handler applies effects."
}
+66 -11
View File
@@ -37,6 +37,24 @@ import RitualItemData from "./data/item/ritual.js";
const { Actors, Items } = foundry.documents.collections;
Hooks.once("init", async () => {
// Own the pause UI: applying our overlay inside _onRender means it is part
// of EVERY render of the pause application - no hook/timing races.
try {
const BasePause = foundry.applications?.ui?.GamePause;
if (BasePause) {
class DG2GamePause extends BasePause {
/** @override */
async _onRender(context, options) {
await super._onRender?.(context, options);
applyDG2PauseOverlay(this.element);
}
}
CONFIG.ui.pause = DG2GamePause;
}
} catch (ex) {
console.warn("Delta Green 2 | could not register pause subclass", ex);
}
Object.assign(CONFIG.Actor.dataModels, {
// The keys are the types defined in system.json documentTypes.Actor
agent: AgentData,
@@ -221,23 +239,60 @@ Hooks.once("diceSoNiceReady", (dice3d) => {
}
});
Hooks.on("renderGamePause", function (_, html, options) {
if (options.cssClass !== "paused") return;
/**
* Replace the stock pause spinner with the DG2 radar-scope animation.
* Idempotent: safe to call on every render/toggle; skips if already applied.
* Wired to BOTH renderGamePause and pauseGame so the scope appears no matter
* which path Foundry takes when the pause state changes.
*/
function applyDG2PauseOverlay(element) {
try {
const gamePausedOverrideText = game.i18n.translations.DG.MissionPaused;
const el = element ?? document.getElementById("pause");
if (!el || !game.paused) return;
if (el.querySelector(".dg2-pause-scope")) return; // already applied
if (typeof gamePausedOverrideText === "undefined") {
return;
}
const caption = game.i18n.translations.DG?.MissionPaused ?? "Mission Paused";
const sub =
game.i18n.translations.DG2?.PausedSub ??
"All stations hold // awaiting Handler";
html.querySelector("figcaption").textContent = gamePausedOverrideText;
html.querySelector("img").classList.remove("fa-spin"); // I don't like the logo spinning personally
} catch {
// noop
el.classList.add("dg2-pause");
el.innerHTML = `
<div class="dg2-pause-scope" aria-hidden="true">
<span class="dg2-pause-ring r1"></span>
<span class="dg2-pause-ring r2"></span>
<span class="dg2-pause-ring r3"></span>
<span class="dg2-pause-cross"></span>
<span class="dg2-pause-wedge"></span>
<span class="dg2-pause-beam"></span>
<span class="dg2-pause-hub"></span>
<span class="dg2-pause-blip b1"></span>
<span class="dg2-pause-blip b2"></span>
</div>
<figcaption class="dg2-pause-caption">
<span class="dg2-pause-title">${caption}</span>
<span class="dg2-pause-sub">${sub}</span>
</figcaption>`;
} catch (ex) {
console.warn("Delta Green 2 | pause overlay failed", ex);
}
}
Hooks.on("renderGamePause", (_app, html) => {
const el = html instanceof HTMLElement ? html : html?.[0];
applyDG2PauseOverlay(el);
});
Hooks.on("pauseGame", () => {
// Fallback for cores where the pause subclass isn't registered: re-apply
// after Foundry's own render, with retries to outlast a slow re-render.
for (const delay of [50, 250, 700]) {
setTimeout(() => applyDG2PauseOverlay(), delay);
}
});
Hooks.once("ready", () => applyDG2PauseOverlay());
function addEventListenerToChatMessage(element) {
element.addEventListener("click", (event) => {
const btnWithAction = event.target.closest("button[data-action]");
+123
View File
@@ -0,0 +1,123 @@
/**
* Delta Green 2 home rule — critical events.
*
* A natural 01 on any percentile test triggers PROVIDENCE: a d6 roll on an
* escalating table of boons (1 = a small mercy, 6 = the whole cell benefits).
* A natural 00 (100) triggers CALAMITY: a d6 roll on an escalating table of
* misfortunes (1 = bad but not crippling, 6 = everyone at the table pays).
*
* Events are announced with a dramatic chat card; the Handler applies the
* mechanical effects. Toggled by the `criticalEvents` automation setting.
*/
import { tagRollDice } from "../integrations/dice-so-nice.js";
const PROVIDENCE = [
{
title: "A MOMENT OF CLARITY",
text: "For one impossible second, the world makes sense. The static in the Agent's head goes quiet. <b>Recover 1D4 SAN.</b>",
},
{
title: "MUSCLE MEMORY",
text: "Training the Agent forgot they had takes over completely. <b>Mark this skill for improvement</b> as if it had been earned the hard way &mdash; and it costs nothing tonight.",
},
{
title: "ADRENALINE EDGE",
text: "Time dilates. Every detail is crystal. The Agent gains <b>+20% to their next test this scene</b>, any skill.",
},
{
title: "THE PROGRAM PROVIDES",
text: "Against all odds, exactly the right thing is at hand &mdash; a spare magazine, a working flashlight, a door someone forgot to lock. <b>Recover 1D4 WP and gain +20% to the Agent's next test this scene.</b>",
},
{
title: "UNTOUCHABLE",
text: "Tonight, the dark looks elsewhere. <b>The next attack or hazard that would harm the Agent this scene misses, glances off, or simply&hellip; doesn't.</b>",
},
{
title: "THE CELL IN PERFECT SYNC",
text: "One flawless action locks the whole team into rhythm &mdash; hand signals, breath, heartbeat. <b>Every Agent in the scene recovers 1 WP and gains +20% to their next test.</b> For a moment, Delta Green is winning.",
},
];
const CALAMITY = [
{
title: "EQUIPMENT FAILURE",
text: "The tool betrays its owner &mdash; the weapon jams, the lockpick snaps, the radio dies mid-word. <b>The item used is fouled or broken until repaired or reloaded.</b> Bad. Not crippling. Yet.",
},
{
title: "BLOOD ON THE SNOW",
text: "A misstep, a torn seam, an edge in the dark. <b>The Agent takes 1D4 damage</b> &mdash; nothing fatal, but it will be felt for the rest of the night.",
},
{
title: "RATTLED",
text: "The failure gets inside. Hands shake; the checklist scatters. <b>The Agent loses 1D4 WP and takes &minus;20% on their next test this scene.</b>",
},
{
title: "SOMETHING NOTICED",
text: "In the wake of the blunder there is a pause &mdash; the sense of an ear turning. <b>The Agent makes a SAN test (0/1D4).</b> Whatever is out there now knows one of their names.",
},
{
title: "THE PRICE IS PAID IN FULL",
text: "Everything that could go wrong, does, at once. <b>The Agent takes 1D6 damage AND their equipped item is dropped or broken.</b> Somewhere, quietly, a door that was closed is now open.",
},
{
title: "IT SEES ALL OF YOU",
text: "The failure tears something open, and for a heartbeat every Agent stands in the light of it. <b>Everyone in the scene loses 0/1D4 SAN and takes &minus;20% on their next test.</b> Radios whisper. Lights gutter. It knows the cell exists.",
},
];
/**
* Check an evaluated percentile roll for a natural 01 / 00 and, if the
* home rule is enabled and the roller is an Agent, post the event card.
*
* @param {DGPercentileRoll} roll - the evaluated roll (post-toChat).
* @returns {Promise<ChatMessage|null>}
*/
export async function maybeTriggerCriticalEvent(roll) {
try {
if (!game.settings.get("deltagreen2", "criticalEvents")) return null;
if (roll.actor?.type !== "agent") return null;
if (roll.total !== 1 && roll.total !== 100) return null;
const providence = roll.total === 1;
const d6 = new Roll("1d6");
await d6.evaluate();
tagRollDice(d6, providence ? "dg2-agency" : "dg2-unnatural");
const entry = (providence ? PROVIDENCE : CALAMITY)[d6.total - 1];
const actorName = roll.actor?.name ?? "The Agent";
const rollName = roll.localizedKey ?? "the test";
const strip = providence
? { type: "PROVIDENCE — NATURAL 01", cls: "dg2-msg-providence", icon: "fa-star-of-life" }
: { type: "CALAMITY — NATURAL 00", cls: "dg2-msg-calamity", icon: "fa-radiation" };
const lede = providence
? `For one impossible moment, everything breaks <i>${actorName}'s</i> way on <b>${rollName}</b>.`
: `On <b>${rollName}</b>, the night turns on <i>${actorName}</i>.`;
const content = `
<div class="dg2-card dg2-crit-event">
<div class="dg2-card-strip">
<span class="dg2-card-type">${strip.type}</span>
<span class="dg2-card-org">${game.i18n.localize("DG2.Chat.EyesOnly")}</span>
</div>
<div class="dg2-card-title">
<span class="dg2-card-name">${entry.title}</span>
<span class="dg2-chip"><i class="fas fa-dice-d6"></i> ${d6.total} / 6</span>
</div>
<p class="dg2-crit-lede">${lede}</p>
<p class="dg2-crit-text">${entry.text}</p>
</div>`;
return await ChatMessage.create({
content,
rolls: [d6],
sound: CONFIG.sounds.dice,
speaker: ChatMessage.getSpeaker({ actor: roll.actor }),
flags: { deltagreen2: { variant: providence ? "providence" : "calamity" } },
});
} catch (ex) {
console.warn("Delta Green 2 | critical event failed", ex);
return null;
}
}
+5 -1
View File
@@ -5,6 +5,7 @@ import {
tagRollDice,
colorsetForPercentileRoll,
} from "../integrations/dice-so-nice.js";
import { maybeTriggerCriticalEvent } from "../other/critical-events.js";
const { renderTemplate } = foundry.applications.handlebars;
const { DialogV2 } = foundry.applications.api;
@@ -410,10 +411,13 @@ export class DGPercentileRoll extends DGRoll {
[keyForUpdate]: true,
});
await maybeTriggerCriticalEvent(this);
return message;
}
return this.toMessage({ content: html });
const message = await this.toMessage({ content: html });
await maybeTriggerCriticalEvent(this);
return message;
}
/**
+4
View File
@@ -220,6 +220,10 @@ class AutomationSettings extends SettingForm {
default: true,
type: Boolean,
},
criticalEvents: {
default: true,
type: Boolean,
},
};
}
}
Binary file not shown.
+3 -3
View File
@@ -1,3 +1,3 @@
2026/07/16-15:09:13.184913 170e2b000 Recovering log #4
2026/07/16-15:09:13.186094 170e2b000 Delete type=0 #4
2026/07/16-15:09:13.186131 170e2b000 Delete type=3 #2
2026/07/16-22:12:46.376475 17235f000 Recovering log #4
2026/07/16-22:12:46.380006 17235f000 Delete type=0 #4
2026/07/16-22:12:46.380058 17235f000 Delete type=3 #2
+5 -5
View File
@@ -1,5 +1,5 @@
2026/07/16-15:09:13.093478 16f10f000 Delete type=3 #1
2026/07/16-15:09:13.095837 16f9a7000 Level-0 table #5: started
2026/07/16-15:09:13.096279 16f9a7000 Level-0 table #5: 19548 bytes OK
2026/07/16-15:09:13.096401 16f9a7000 Delete type=0 #3
2026/07/16-15:09:13.096523 16f9a7000 Manual compaction at level-0 from '!adventures!5zf355zpx5s1ux56' @ 72057594037927935 : 1 .. '!adventures!5zf355zpx5s1ux56' @ 0 : 0; will stop at (end)
2026/07/16-22:12:46.303554 1737b7000 Delete type=3 #1
2026/07/16-22:12:46.305623 17404f000 Level-0 table #5: started
2026/07/16-22:12:46.305975 17404f000 Level-0 table #5: 19548 bytes OK
2026/07/16-22:12:46.306072 17404f000 Delete type=0 #3
2026/07/16-22:12:46.306136 17404f000 Manual compaction at level-0 from '!adventures!5zf355zpx5s1ux56' @ 72057594037927935 : 1 .. '!adventures!5zf355zpx5s1ux56' @ 0 : 0; will stop at (end)
+1 -1
View File
@@ -15,7 +15,7 @@
"discord": "jalensailin"
}
],
"version": "2.0.1",
"version": "2.0.5",
"compatibility": {
"minimum": "13",
"verified": "14",