From ff13de2ca5edf29c79e96ad51b76e2b6d8913690 Mon Sep 17 00:00:00 2001 From: Tim Evans Date: Thu, 16 Jul 2026 22:02:20 +0100 Subject: [PATCH] =?UTF-8?q?Radar-scope=20pause=20screen=20+=20Providence/C?= =?UTF-8?q?alamity=20critical=20events=20=E2=80=94=20v2.0.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- css/dg2-tactical.css | 137 ++++++++++++++++++++++++++++++++ lang/en.json | 5 +- module/deltagreen.js | 27 +++++-- module/other/critical-events.js | 123 ++++++++++++++++++++++++++++ module/roll/roll.js | 6 +- module/settings.js | 4 + system.json | 2 +- 7 files changed, 294 insertions(+), 10 deletions(-) create mode 100644 module/other/critical-events.js diff --git a/css/dg2-tactical.css b/css/dg2-tactical.css index cacf37e..e6a246b 100644 --- a/css/dg2-tactical.css +++ b/css/dg2-tactical.css @@ -531,3 +531,140 @@ border: 1px solid var(--dg2-hairline); border-radius: 4px; } + + +/* ------------------------------------------------------------------ */ +/* Delta Green 2 - pause overlay (radar scope) */ +/* ------------------------------------------------------------------ */ + +#pause.dg2-pause { + background: none; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; +} + +#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; +} diff --git a/lang/en.json b/lang/en.json index ffcd4e4..f95f5b1 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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." } \ No newline at end of file diff --git a/module/deltagreen.js b/module/deltagreen.js index ce6029e..7bd2cdd 100644 --- a/module/deltagreen.js +++ b/module/deltagreen.js @@ -225,14 +225,27 @@ Hooks.on("renderGamePause", function (_, html, options) { if (options.cssClass !== "paused") return; try { - const gamePausedOverrideText = game.i18n.translations.DG.MissionPaused; + const caption = game.i18n.translations.DG?.MissionPaused ?? "Mission Paused"; + const sub = game.i18n.translations.DG2?.PausedSub ?? "ALL STATIONS HOLD // AWAITING HANDLER"; - if (typeof gamePausedOverrideText === "undefined") { - return; - } - - html.querySelector("figcaption").textContent = gamePausedOverrideText; - html.querySelector("img").classList.remove("fa-spin"); // I don't like the logo spinning personally + // Replace the default spinner with a DG2 radar-scope pause animation. + html.classList.add("dg2-pause"); + html.innerHTML = ` + +
+ ${caption} + ${sub} +
`; } catch { // noop } diff --git a/module/other/critical-events.js b/module/other/critical-events.js new file mode 100644 index 0000000..6032a12 --- /dev/null +++ b/module/other/critical-events.js @@ -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. Recover 1D4 SAN.", + }, + { + title: "MUSCLE MEMORY", + text: "Training the Agent forgot they had takes over completely. Mark this skill for improvement as if it had been earned the hard way — and it costs nothing tonight.", + }, + { + title: "ADRENALINE EDGE", + text: "Time dilates. Every detail is crystal. The Agent gains +20% to their next test this scene, any skill.", + }, + { + title: "THE PROGRAM PROVIDES", + text: "Against all odds, exactly the right thing is at hand — a spare magazine, a working flashlight, a door someone forgot to lock. Recover 1D4 WP and gain +20% to the Agent's next test this scene.", + }, + { + title: "UNTOUCHABLE", + text: "Tonight, the dark looks elsewhere. The next attack or hazard that would harm the Agent this scene misses, glances off, or simply… doesn't.", + }, + { + title: "THE CELL IN PERFECT SYNC", + text: "One flawless action locks the whole team into rhythm — hand signals, breath, heartbeat. Every Agent in the scene recovers 1 WP and gains +20% to their next test. For a moment, Delta Green is winning.", + }, +]; + +const CALAMITY = [ + { + title: "EQUIPMENT FAILURE", + text: "The tool betrays its owner — the weapon jams, the lockpick snaps, the radio dies mid-word. The item used is fouled or broken until repaired or reloaded. Bad. Not crippling. Yet.", + }, + { + title: "BLOOD ON THE SNOW", + text: "A misstep, a torn seam, an edge in the dark. The Agent takes 1D4 damage — 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. The Agent loses 1D4 WP and takes −20% on their next test this scene.", + }, + { + title: "SOMETHING NOTICED", + text: "In the wake of the blunder there is a pause — the sense of an ear turning. The Agent makes a SAN test (0/1D4). 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. The Agent takes 1D6 damage AND their equipped item is dropped or broken. 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. Everyone in the scene loses 0/1D4 SAN and takes −20% on their next test. 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} + */ +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 ${actorName}'s way on ${rollName}.` + : `On ${rollName}, the night turns on ${actorName}.`; + + const content = ` +
+
+ ${strip.type} + ${game.i18n.localize("DG2.Chat.EyesOnly")} +
+
+ ${entry.title} + ${d6.total} / 6 +
+

${lede}

+

${entry.text}

+
`; + + 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; + } +} diff --git a/module/roll/roll.js b/module/roll/roll.js index 3fcd609..1464ad2 100644 --- a/module/roll/roll.js +++ b/module/roll/roll.js @@ -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; } /** diff --git a/module/settings.js b/module/settings.js index f28fe24..ffb4c8d 100644 --- a/module/settings.js +++ b/module/settings.js @@ -220,6 +220,10 @@ class AutomationSettings extends SettingForm { default: true, type: Boolean, }, + criticalEvents: { + default: true, + type: Boolean, + }, }; } } diff --git a/system.json b/system.json index 7bddd54..604e40d 100644 --- a/system.json +++ b/system.json @@ -15,7 +15,7 @@ "discord": "jalensailin" } ], - "version": "2.0.1", + "version": "2.0.2", "compatibility": { "minimum": "13", "verified": "14",